diff --git a/crates/benilla-ui/src/script/compat/classic_api/frame.rs b/crates/benilla-ui/src/script/compat/classic_api/frame.rs index 3eb150cc..4a36503d 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/frame.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/frame.rs @@ -198,6 +198,40 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { .unwrap_or(0.0)) })?, )?; + + // `SetWordWrap(on)` / `CanWordWrap()`: 3.x's switch for whether a FontString wraps at all. + // 1.12's own `SetNonSpaceWrap` is a different question (where a wrap may fall, not whether + // one happens), so there is nothing to spell this as. + // + // **Stored, not applied**, on the same footing as `SetSpacing` and for the same stated + // reason: this client's text layout wraps to the declared width unconditionally and has no + // one-line-and-clip mode for the flag to drive, so honouring it would be inventing a + // layout mode, while answering nothing costs an addon its whole build — + // `Cinematic/Options.lua:4130` calls it on a label and aborted the options panel there. + // The pair round-trips, so a caller that reads back what it wrote gets its own answer. + fs.set( + "SetWordWrap", + lua.create_function(|lua, (this, on): (Table, Option)| { + let rh = crate::script::region::region_handle_of(lua, &this)?; + let on = !matches!(on, None | Some(Value::Nil) | Some(Value::Boolean(false))); + let mut model = lua.app_data_mut::().expect("model app_data"); + model.region_data.entry(rh).or_default().word_wrap = Some(on); + Ok(()) + })?, + )?; + fs.set( + "CanWordWrap", + lua.create_function(|lua, this: Table| { + let rh = crate::script::region::region_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model app_data"); + let on = model + .region_data + .get(&rh) + .and_then(|d| d.word_wrap) + .unwrap_or(true); + Ok(crate::script::binding_abi::flag(on)) + })?, + )?; } let m: Table = lua.named_registry_value(REG_FRAME_METHODS)?; @@ -225,6 +259,62 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; + // `RegisterUnitEvent(event, unit1 [, unit2])`: the stock registration, narrowed to the units + // whose fire the frame wants. Everything about the dispatch stays the reference's — the frame + // goes into the same `event_to_frames` list in the same registration order — and the filter is + // read in `tick.rs`'s walk, which skips a frame whose row for this event does not name `arg1`. + // + // Upstream added it so a unit frame could stop waking for 39 other raid members' `UNIT_AURA`; + // here the point is that addons written for it call it **unguarded**. `Cinematic/Frames.lua` + // registers `UNIT_AURA` for `"player"` this way at file scope (`:2611`), so the nil method + // stopped the file dead and took the rest of the addon's frame work with it. + // + // With no unit named at all this is a plain `RegisterEvent`: upstream's C function raises on a + // missing `unit1`, and widening is the one outcome that cannot silently lose an event. + m.set( + "RegisterUnitEvent", + lua.create_function( + |lua, (this, event, u1, u2): (Table, String, Option, Option)| { + let h = frame_handle_of(lua, &this)?; + let units: Vec = [u1, u2].into_iter().flatten().collect(); + let mut model = lua.app_data_mut::().expect("model app_data"); + let list = model.event_to_frames.entry(event.clone()).or_default(); + if !list.contains(&h) { + list.push(h); + } + model + .frame_events + .entry(h) + .or_default() + .insert(event.clone()); + let rows = model.frame_unit_events.entry(h).or_default(); + if units.is_empty() { + rows.remove(&event); + } else { + // A re-register replaces the unit list rather than adding to it, as upstream's + // does: the call states the whole filter. + rows.insert(event, units); + } + Ok(()) + }, + )?, + )?; + + // `SetFixedFrameStrata(on)` / `SetFixedFrameLevel(on)`: 9.0 added these so a frame could tell + // the *frame manager* to stop re-deciding its strata and level when a parent moved. This + // client has no such manager — strata and level are whatever `SetFrameStrata`/`SetFrameLevel` + // last wrote, and nothing else ever rewrites them — so the guarantee the caller is asking for + // already holds, and a no-op is the honest answer rather than a stub standing in for absent + // behaviour. Ace3 calls both unguarded right after building its dialog + // (`AceConfigDialog-3.0.lua:575-576`), which is where OpenMapClassic stopped once the border + // template existed. + for name in ["SetFixedFrameStrata", "SetFixedFrameLevel"] { + m.set( + name, + lua.create_function(|_, (_this, _on): (Table, Option)| Ok(()))?, + )?; + } + // `GetEffectiveAlpha()`: the propagated alpha the arena already resolves, where the stock // `GetAlpha` answers only this frame's own factor. m.set( @@ -420,7 +510,7 @@ pub(in crate::script) fn dispatch_attribute_click( #[cfg(test)] mod tests { - use crate::script::UiScript; + use crate::script::{ScriptValue, UiScript}; fn frame(s: &UiScript) { s.run(r#"f = CreateFrame("Frame", "CAFrame")"#).unwrap(); @@ -778,6 +868,56 @@ mod tests { } } + /// `RegisterUnitEvent` narrows the fire to the units it names and changes nothing else: the + /// frame sits in the same list in registration order, a fire for another unit is simply not + /// delivered, and a plain `RegisterEvent` afterwards widens it back — the shape upstream has, + /// and the one `Cinematic/Frames.lua:2611` registers `UNIT_AURA` for `"player"` with. + #[test] + fn register_unit_event_hears_only_its_units_until_a_plain_register_widens_it() { + let mut s = UiScript::new().unwrap(); + s.run( + r#" + seen = "" + local f = CreateFrame("Frame", "UnitFiltered") + f:RegisterUnitEvent("UNIT_AURA", "player", "pet") + f:SetScript("OnEvent", function() seen = seen .. arg1 .. "," end) + "#, + ) + .unwrap(); + assert_eq!( + s.eval::(r#"return UnitFiltered:IsEventRegistered("UNIT_AURA")"#) + .unwrap(), + 1, + "the registration itself is the stock one" + ); + for unit in ["target", "player", "party1", "pet"] { + s.fire_event("UNIT_AURA", vec![ScriptValue::Str(unit.into())]); + } + assert_eq!( + s.eval::("return seen").unwrap(), + "player,pet,", + "only the two named units were delivered" + ); + + // A plain register widens it, and the frame keeps its place rather than re-queueing. + s.run(r#"UnitFiltered:RegisterEvent("UNIT_AURA") seen = """#) + .unwrap(); + s.fire_event("UNIT_AURA", vec![ScriptValue::Str("target".into())]); + assert_eq!(s.eval::("return seen").unwrap(), "target,"); + + // And unregistering drops the filter with the registration, so a later plain register is + // not still narrowed by a stale row. + s.run( + r#"UnitFiltered:RegisterUnitEvent("UNIT_AURA", "player") + UnitFiltered:UnregisterEvent("UNIT_AURA") + UnitFiltered:RegisterEvent("UNIT_AURA") + seen = """#, + ) + .unwrap(); + s.fire_event("UNIT_AURA", vec![ScriptValue::Str("target".into())]); + assert_eq!(s.eval::("return seen").unwrap(), "target,"); + } + /// `SetShown` on a region is a composition, so it is only correct while the pair it composes /// still reaches the leaf tables. `REGION_LEAF_SHARED` registers them per-leaf rather than on /// the Region map, which is the kind of arrangement a tidying pass moves — pinned here so that diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 1d1e2f8e..0ec9e55c 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -712,6 +712,14 @@ pub(crate) struct Model { /// dispatches in that order across frames. Re-registering keeps a frame's position. pub(crate) event_to_frames: HashMap>, pub(crate) frame_events: HashMap>, + /// ClassicAPI's `RegisterUnitEvent` (`compat/classic_api/frame.rs`): per frame, per event, the + /// unit tokens whose fire this frame wants. A frame with a row for an event is dispatched only + /// when `arg1` is one of them, which is the whole of what the modern verb does — the + /// registration itself is the stock one, in the stock list, in registration order. + /// + /// Empty for every frame that used plain `RegisterEvent`, and `fire_event_into` checks the map + /// is non-empty before looking anything up, so an install with no such addon pays one load. + pub(crate) frame_unit_events: HashMap>>, /// ClassicAPI's `SetAttribute`/`GetAttribute` (`compat/classic_api/frame.rs`): a plain /// per-frame key/value store, which is all those two verbs fundamentally are. The *secure* /// half of the modern secure-template system (restricted execution, the state and attribute @@ -1986,6 +1994,7 @@ impl Model { model_facts_wanted: Vec::new(), dirty_editboxes: Vec::new(), event_to_frames: HashMap::new(), + frame_unit_events: HashMap::new(), frame_events: HashMap::new(), frame_attributes: HashMap::new(), all_event_frames: Vec::new(), diff --git a/crates/benilla-ui/src/script/object/events_regions.rs b/crates/benilla-ui/src/script/object/events_regions.rs index 5c0289bd..0593cc3e 100644 --- a/crates/benilla-ui/src/script/object/events_regions.rs +++ b/crates/benilla-ui/src/script/object/events_regions.rs @@ -25,6 +25,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { if !list.contains(&h) { list.push(h); } + // A plain register after `RegisterUnitEvent` widens the registration back to every + // unit, as it does upstream: the frame keeps its place in the list and loses the + // filter. + if let Some(filters) = model.frame_unit_events.get_mut(&h) { + filters.remove(&event); + } model.frame_events.entry(h).or_default().insert(event); Ok(()) })?, @@ -40,6 +46,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { if let Some(set) = model.frame_events.get_mut(&h) { set.remove(&event); } + if let Some(filters) = model.frame_unit_events.get_mut(&h) { + filters.remove(&event); + } Ok(()) })?, )?; @@ -63,6 +72,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { let h = frame_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); let events = model.frame_events.remove(&h).unwrap_or_default(); + model.frame_unit_events.remove(&h); for event in events { if let Some(list) = model.event_to_frames.get_mut(&event) { list.retain(|x| x != &h); diff --git a/crates/benilla-ui/src/script/tests/widget_surface.rs b/crates/benilla-ui/src/script/tests/widget_surface.rs index 006b5339..41468f0b 100644 --- a/crates/benilla-ui/src/script/tests/widget_surface.rs +++ b/crates/benilla-ui/src/script/tests/widget_surface.rs @@ -290,6 +290,44 @@ const ALLOWED_BEYOND: &[(&str, &str, &str)] = &[ "ClassicAPI: the laid-out wrapped height, the other half of GetStringWidth's measure — \ AceGUI-3.0's Label and CheckBox call it unguarded", ), + ( + "CanWordWrap", + "FontString", + "ClassicAPI: the read half of SetWordWrap, so the pair round-trips. True by default, \ + which is what this client's layout actually does", + ), + ( + "SetFixedFrameLevel", + "Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame", + "ClassicAPI: 9.0 told the frame manager to stop re-deciding a frame's level. There is no \ + such manager here — level is whatever SetFrameLevel last wrote and nothing rewrites it — \ + so the guarantee already holds and the no-op is the honest answer. Ace3 calls it \ + unguarded at AceConfigDialog-3.0.lua:576", + ), + ( + "SetFixedFrameStrata", + "Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame", + "ClassicAPI: as SetFixedFrameLevel, for strata. Ace3, AceConfigDialog-3.0.lua:575", + ), + ( + "SetWordWrap", + "FontString", + "ClassicAPI: 3.x's whether-to-wrap switch, which 1.12 has no spelling for (its \ + SetNonSpaceWrap decides WHERE a wrap may fall, not whether one happens). Stored, not \ + applied, like SetSpacing and for the same reason — the layout wraps to the declared \ + width unconditionally — and kept because the missing setter cost Cinematic its whole \ + options panel at Options.lua:4130", + ), + ( + "RegisterUnitEvent", + "Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame", + "ClassicAPI: the stock registration narrowed to named units — same list, same order, and \ + tick.rs's walk skips a frame whose row for the event does not name arg1. Addons written \ + for it do not guard it: Cinematic/Frames.lua:2611 registers UNIT_AURA for \"player\" at \ + file scope, so the nil method stopped the file dead. On every frame kind rather than \ + Frame alone because event registration lives on the one shared frame method table, which \ + is where 1.12 keeps RegisterEvent too — the two are registered alike or neither is", + ), ( "SetColorTexture", "Texture", diff --git a/crates/benilla-ui/src/script/tick.rs b/crates/benilla-ui/src/script/tick.rs index 75543728..89b02564 100644 --- a/crates/benilla-ui/src/script/tick.rs +++ b/crates/benilla-ui/src/script/tick.rs @@ -121,7 +121,23 @@ pub(crate) fn fire_event_into(lua: &Lua, event: &str, args: Vec) -> .get(event) .and_then(|l| l.get(pos + 1).copied()); let id = model.frame_id(h); + // ClassicAPI's `RegisterUnitEvent`: a frame holding a unit filter for this event hears + // it only for those units (`arg1`). The map is empty on an install with no such addon, + // and a frame that registered plainly has no row, so the common path is one + // `is_empty` and nothing else. + let filtered_out = !model.frame_unit_events.is_empty() + && model + .frame_unit_events + .get(&h) + .and_then(|m| m.get(event)) + .is_some_and(|units| { + !matches!(args.first(), Some(ScriptValue::Str(u)) if units.contains(u)) + }); drop(model); + if filtered_out { + at = next; + continue; + } reach.reached += 1; if let Err(e) = event::fire_event_handler(lua, id, event, &args) { model_mut().record_script_error(e.to_string()); diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index d4992e1e..23250e96 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -441,6 +441,15 @@ pub(crate) struct RegionData { /// `SetNonSpaceWrap`/`CanNonSpaceWrap` (`0x79e9f0`/`0x79ead0`), `None` on. Stored, not applied: /// the reference's flag `0x40` drives a mid-word wrap and the ellipsis fit count (`0x771ec0`). pub(crate) non_space_wrap: Option, + /// `SetWordWrap`/`CanWordWrap` (`compat/classic_api/frame.rs`), `None` on — 3.x's switch for + /// whether a FontString wraps at all, which 1.12 has no counterpart to (its own + /// `SetNonSpaceWrap` above decides *where* a wrap may fall, not whether one happens). + /// **Stored, not applied**, the same footing as [`Self::spacing`]: this client's text layout + /// wraps to the declared width unconditionally and has no "one line, clipped" mode for the + /// number to drive. Kept because the pair must round-trip and because the absence of the + /// *setter* is what costs — `Cinematic/Options.lua:4130` calls it on a label and aborted the + /// whole options build there. + pub(crate) word_wrap: Option, /// `SetSpacing`/`GetSpacing`: extra pixels between the lines of a wrapped FontString. /// **Stored, not applied** — this client's text layout models no line gap at all, so there is /// nothing for the number to drive yet. Kept rather than discarded because the pair must