diff --git a/crates/benilla-app/assets/ui/ActionBar.xml b/crates/benilla-app/assets/ui/ActionBar.xml index 2057650a..0e2e3e32 100644 --- a/crates/benilla-app/assets/ui/ActionBar.xml +++ b/crates/benilla-app/assets/ui/ActionBar.xml @@ -232,6 +232,36 @@ -- (ActionButton_GetPagedID:446-467 — MultiBarBottomLeft ⇒ page 6, MultiBarBottomRight ⇒ -- page 5, else the paged/bonus formula); we carry the fork as data — a fixed per-button -- page base set at OnLoad (MultiBars.xml), nil for the bonus-paged main bar. + -- **Adopt a button that never ran our OnLoad.** + -- + -- Our handlers read a region cache (`button.icon`, `.count`, `.cooldown`, …) that + -- `BenillaActionButton_OnLoad` fills. An addon inheriting `ActionBarButtonTemplate` gets + -- those handlers and supplies its OWN ``, which REPLACES the template's rather + -- than chaining — so ours never runs and every one of those reads was nil. zBar.xml:7 is + -- the corpus shape; it died in OUR file at `ActionBar.xml:529` on `button.icon`. + -- + -- Resolving from the derived names is not a fallback bolted on: it is exactly what our + -- OnLoad does, and what the REFERENCE does on every access (`ActionButton_Update` reads + -- `getglobal(this:GetName().."Icon")` each time rather than caching). We cache because it + -- is cheaper; this is the same lookup for the buttons that never got cached. + -- + -- It deliberately does NOT join `BENILLA_ACTION_BUTTONS`. That roster is the event fan-out + -- for OUR bars, and a foreign button's owner drives its own updates — adding it would have + -- us repainting somebody else's button behind their back. + function BenillaActionButton_Adopt(button) + if not button or button.icon then return end + local n = button:GetName() + if not n then return end + button.icon = getglobal(n .. "Icon") + button.hotkey = getglobal(n .. "HotKey") + button.count = getglobal(n .. "Count") + button.flash = getglobal(n .. "Flash") + button.border = getglobal(n .. "Border") + button.cooldown = getglobal(n .. "Cooldown") + button.flashing = button.flashing or 0 + button.flashtime = button.flashtime or 0 + end + function BenillaActionButton_Action(button) -- `index` falls back to the button's ID, which is where the REFERENCE reads it from: -- `ActionButton_GetPagedID` uses `this:GetID()`, and our `.index` is only that value @@ -260,6 +290,7 @@ -- ships no CVar state, so the branch collapses to the stock-client one: action-bar -- tooltips live at the screen's bottom-right, above the bags. function BenillaActionButton_OnEnter(button) + BenillaActionButton_Adopt(button) if not GameTooltip then return end -- engine tests load file subsets GameTooltip_SetDefaultAnchor(GameTooltip, button) GameTooltip:SetAction(BenillaActionButton_Action(button)) @@ -305,6 +336,7 @@ -- Ref ActionBarFrame.xml:12-22's fork verbatim: shift picks up, else uses (checkCursor=1 — -- the engine routes a click-with-payload to PlaceAction instead, decision 0216 §7). function BenillaActionButton_OnClick(button) + BenillaActionButton_Adopt(button) local action = BenillaActionButton_Action(button) if IsShiftKeyDown() then PickupAction(action) @@ -320,12 +352,14 @@ -- stops the *drag*, and shift-click stays the deliberate way to move an action with the bar -- locked — which is what makes the setting usable rather than a wall. function BenillaActionButton_OnDragStart(button) + BenillaActionButton_Adopt(button) if LOCK_ACTIONBAR == "1" then return end PickupAction(BenillaActionButton_Action(button)) BenillaActionButton_Update(button) end function BenillaActionButton_OnReceiveDrag(button) + BenillaActionButton_Adopt(button) if LOCK_ACTIONBAR == "1" then return end PlaceAction(BenillaActionButton_Action(button)) BenillaActionButton_Update(button) @@ -427,6 +461,7 @@ -- tints red/grey, an unbound one wears the RANGE_INDICATOR dot (both legs the ref's, -- unified in 1008 — the SCOPE note). function ActionButton_OnUpdate(elapsed) + BenillaActionButton_Adopt(this) local button = this if ( button.flashing == 1 ) then button.flashtime = button.flashtime - elapsed @@ -499,7 +534,78 @@ -- arguments are real state our per-container registration needs, so no adapter can supply -- them. That is the per-button event registration change already at the top of the arc -- queue, not a naming fix. + -- **`ActionButton_OnLoad()` — the reference's own, taking nothing and reading `this`.** + -- + -- This sat at the top of the arc queue for the whole wave, recorded as unadaptable: ours + -- takes `(button, index, base, commandPrefix)` and the reference's takes none, so "no + -- adapter can supply them". Reading ref ActionButton.lua:103-119 shows that was wrong — + -- the reference derives all three from `this` and it is our own signature that caches + -- them: + -- + -- index -> `this:GetID()` (what ActionButton_GetPagedID reads) + -- commandPrefix -> `this.buttonType`, a field the BAR sets, "ACTIONBUTTON" by default + -- (ref l.118 `ActionButton_UpdateHotkeys(this.buttonType)`) + -- base -> not an OnLoad concept at all; the reference forks on the PARENT's + -- NAME at read time. A foreign bar ("zBar1") matches no multibar, so + -- the paged/bonus formula applies — which is what leaving `base` nil + -- already does here. + -- + -- The events are registered on the BUTTON, as the reference does, and reach it through the + -- `` this change adds to `ActionBarButtonTemplate`. Our own bars do NOT call this + -- and never register, so they keep being driven by the BENILLA_ACTION_BUTTONS fan-out and + -- cannot be double-driven. + function ActionButton_OnLoad() + local button = this + if not button then return end + BenillaActionButton_Adopt(button) + button.index = button.index or button:GetID() + button.bindingCommand = (button.buttonType or "ACTIONBUTTON") .. (button.index or 1) + button.showgrid = 0 + button.flashing = 0 + button.flashtime = 0 + button:RegisterForDrag("LeftButton", "RightButton") + button:RegisterForClicks("LeftButtonUp", "RightButtonUp") + button:RegisterEvent("PLAYER_ENTERING_WORLD") + button:RegisterEvent("UPDATE_BONUS_ACTIONBAR") + button:RegisterEvent("ACTIONBAR_SHOWGRID") + button:RegisterEvent("ACTIONBAR_HIDEGRID") + button:RegisterEvent("ACTIONBAR_PAGE_CHANGED") + button:RegisterEvent("ACTIONBAR_SLOT_CHANGED") + button:RegisterEvent("UPDATE_BINDINGS") + BenillaActionButton_PaintHotkey(button) + BenillaActionButton_Update(button) + end + + -- The per-button event arm the reference's own template carries. Only buttons that ran + -- `ActionButton_OnLoad` ever registered, so this is a no-op for ours. + function ActionButton_OnEvent() + local button = this + if not button then return end + BenillaActionButton_Adopt(button) + if event == "ACTIONBAR_SHOWGRID" then + button.showgrid = 1 + elseif event == "ACTIONBAR_HIDEGRID" then + button.showgrid = 0 + elseif event == "UPDATE_BINDINGS" then + BenillaActionButton_PaintHotkey(button) + end + BenillaActionButton_Update(button) + end + + -- ref ActionButton.lua:446 — the action id a button displays, forked on its parent bar. + -- `BenillaActionButton_Action` IS this function; only the name was ours. CT_BarMod calls it + -- the moment there is an action to page, which is why an EMPTY-world survey never saw it: + -- it surfaced only when the fixture started seating a populated session. + function ActionButton_GetPagedID(button) + return BenillaActionButton_Action(button or this) + end + function ActionButton_Update() + -- Adopt here too: this adapter carries the REFERENCE's name, so `this` is very often a + -- button somebody else built (zBar's, CT_BarMod's) that never ran our OnLoad and so has + -- no region cache. The handler-boundary adopts do not cover it — an addon calls this + -- global directly, without any of our scripts in the stack. + BenillaActionButton_Adopt(this) BenillaActionButton_Update(this) end @@ -1075,6 +1181,7 @@ + ActionButton_OnEvent() BenillaActionButton_OnClick(self) BenillaActionButton_OnDragStart(self) BenillaActionButton_OnReceiveDrag(self) @@ -1537,4 +1644,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/benilla-app/assets/ui/ContainerFrameAdapters.xml b/crates/benilla-app/assets/ui/ContainerFrameAdapters.xml index 19b8960d..9eee5f11 100644 --- a/crates/benilla-app/assets/ui/ContainerFrameAdapters.xml +++ b/crates/benilla-app/assets/ui/ContainerFrameAdapters.xml @@ -78,5 +78,29 @@ end refItemButtonOnEnter(button) end + + -- ── `updateContainerFrameAnchors` — a no-op here, and that IS the benilla answer ──────── + -- The reference's body (l.480-489) opens `while ContainerFrame1.bags[index] do` and walks + -- the twelve `ContainerFrame1..12` windows it owns, positioning each into columns. benilla + -- does not build those windows at all — our bags are `BagFrame.xml`'s own, and they + -- position themselves. `ui_script::manifest` already records the same difference one + -- function over: *"ToggleBackpack walks twelve ContainerFrames we do not build; ours walks + -- our windows."* + -- + -- So this is not a stub standing in for something missing: there is nothing here for it to + -- anchor, and the reference body raises `attempt to index global 'ContainerFrame1'` the + -- moment anything calls it. FIVE corpus addons did — Bongos, Bongos_ActionBar and the + -- CT_BarMod family all HOOK it (`BLIZZARD_Original_updateContainerFrameAnchors` in the + -- traceback) and then call through, so the raise landed inside a Blizzard file with our + -- bag layout nowhere in the stack. + -- + -- Deliberately silent rather than warned: an addon calling this on every bag update would + -- turn a warn-once into a warn-always, and unlike the cases in the section above nothing is + -- LOST by doing nothing — the windows it would have moved are already where our own layout + -- put them. The header's "never silently stubbed" rule is about absent capability; this is + -- present capability with a different owner. + function updateContainerFrameAnchors() + end + ]]> diff --git a/crates/benilla-app/assets/ui/MinimapCluster.xml b/crates/benilla-app/assets/ui/MinimapCluster.xml index 7b2cf3e7..b7682be1 100644 --- a/crates/benilla-app/assets/ui/MinimapCluster.xml +++ b/crates/benilla-app/assets/ui/MinimapCluster.xml @@ -304,6 +304,56 @@ cluster — hoisted to the cluster the same offset lands past the screen edge. Unprefixed (this file's own convention: every frame here keeps the reference's bare name, unlike the newer Benilla*-prefixed windows). --> + + + + + diff --git a/crates/benilla-app/examples/addon_harness.rs b/crates/benilla-app/examples/addon_harness.rs index feee45a0..7205e211 100644 --- a/crates/benilla-app/examples/addon_harness.rs +++ b/crates/benilla-app/examples/addon_harness.rs @@ -176,8 +176,27 @@ fn main() { .map(|r| r.name.as_str()) .collect(); if !silent.is_empty() { + // **This list is a QUESTION, not a defect list, and the header used to say otherwise.** + // + // It read "read this list first", which invites treating every row as a bug. Most are not. + // The survey seats a player and an empty world: no buffs, no target, no combat, no cursor + // over an item. An addon with nothing to draw in that world draws nothing CORRECTLY — and + // that is most of this list. Measured on the vanilla corpus rather than assumed: of 48 rows + // checked, 6 ship no XML at all (pure libraries — Ace, LibStub, Stubby, DevTools), and of + // the rest the buff bars (CT_BuffMod 29 frames, ElkBuffBar 3, neither hidden at birth) are + // waiting on buffs that a fresh login does not have either. + // + // What the row DOES mean is "nothing here can be ruled out by the other four columns" — + // which is worth printing, and is not the same as "broken". + // + // The instrument change that would sharpen it is a POPULATED session fixture (a buff, a + // target, a live cooldown) so that "nothing to draw" and "failed to draw" stop sharing a + // row. Named here rather than done, because seating content changes the fixture for all + // 218 addons and deserves its own controlled A/B. println!( - "\n CLEAN ON EVERY OTHER COLUMN AND DREW NOTHING ({}) — read this list first:", + "\n DREW NOTHING, and clean on every other column ({}) — a QUESTION, not a defect\n \ + list: the seated world has one buff, one target and one running cooldown, so an addon\n \ + waiting on combat, a cursor over an item or a slash command belongs here too:", silent.len() ); for chunk in silent.chunks(4) { diff --git a/crates/benilla-app/src/addon_harness/mod.rs b/crates/benilla-app/src/addon_harness/mod.rs index d5786c08..abdab043 100644 --- a/crates/benilla-app/src/addon_harness/mod.rs +++ b/crates/benilla-app/src/addon_harness/mod.rs @@ -832,13 +832,26 @@ fn drive_session_start(script: &mut UiScript, name: &str, deps: &[String]) -> Ve // (`load_dependencies`) and there is no reason session errors should differ — charging it here // would count one library's fault once per addon that embeds it, which is the whole reason // one-VM-per-addon exists. + // + // **Attribution is by the RAISING CHUNK now, not by which event window the raise fell in** + // (decision 1226 — recorded when this was still window-based, fixed here). The window proxy + // broke on the shape it was written for: AceAddon drains its ENTIRE `nextAddon` queue on any + // `ADDON_LOADED` it sees (`AceAddon-2.0.lua:104-105`) and calls each consumer's + // `OnInitialize` there (`:230`). So the SURVEYED addon's own code runs inside a DEPENDENCY's + // window, and firing deps outside the mark charged those raises to nobody — the whole + // FuBar/Ace family was silently OVER-reported as surviving. `Region:SetParent` landing made + // FuBar_FuXPFu flip `ok -> fail` and that looked like a regression; it was this. + // + // Every `ADDON_LOADED` fires inside the mark now, and the dependency exemption is applied + // afterwards by asking WHOSE FILE raised. Chunk names have been truthful since 1217 + // (`@Interface\AddOns\\`), which is what makes the honest key available at all. + let before = script.errors().len(); for dep in deps { script.fire_event( "ADDON_LOADED", vec![benilla_ui::script::ScriptValue::Str(dep.clone())], ); } - let before = script.errors().len(); script.fire_event( "ADDON_LOADED", vec![benilla_ui::script::ScriptValue::Str(name.to_string())], @@ -849,7 +862,37 @@ fn drive_session_start(script: &mut UiScript, name: &str, deps: &[String]) -> Ve for _ in 0..10 { script.tick(0.1); } - script.errors().split_off(before) + let raised = script.errors().split_off(before); + raised + .into_iter() + .filter(|e| !raised_inside_a_dependencys_own_file(e, name, deps)) + .collect() +} + +/// Does this raise belong to a DEPENDENCY's file rather than the surveyed addon's? +/// +/// The rule `load_dependencies` already states for load errors, now enforceable for session errors +/// too: *a library that fails is its own row; blaming its consumers would count one fault once per +/// addon that embeds it.* Only the FIRST line is consulted — that is the raise site; the frames +/// below it are the call path, and a consumer calling into a library that then raises is still the +/// library's fault, exactly as it is at load time. +/// +/// **Conservative on purpose.** A chunk that names no addon folder at all — our own FrameXML, an +/// `[string "Frame:OnEvent"]` handler — is KEPT, because the surveyed addon is what drove it. Only +/// a chunk that positively names one of this addon's own dependencies is dropped. Getting that +/// backwards would swing the column the other way, and the whole point of 1226 is that a proxy +/// which errs silently in one direction is how the number drifted in the first place. +fn raised_inside_a_dependencys_own_file(err: &str, name: &str, deps: &[String]) -> bool { + let Some(first) = err.lines().next() else { + return false; + }; + // The surveyed addon's own folder always wins, even when a dependency's name is a substring of + // a path inside it. + if first.contains(&format!("\\{name}\\")) || first.starts_with(&format!("{name}\\")) { + return false; + } + deps.iter() + .any(|d| first.contains(&format!("\\{d}\\")) || first.starts_with(&format!("{d}\\"))) } /// Invoke the reference's own UI entry points, so an addon's OVERRIDES actually execute. @@ -1206,6 +1249,69 @@ fn seat_a_session(script: &mut UiScript) { ..Default::default() }), ); + + // ── A POPULATED world, not merely an inhabited one ────────────────────────────────────── + // + // The columns above answer "did it raise"; the render column answers "did it draw". Until + // now the survey seated a player into an EMPTY world — no buffs, no target, no live + // cooldown — so a buff bar, a target frame or a cooldown-text addon drew nothing and landed + // in the drew-nothing list beside the pure libraries. `CT_BuffMod` declares 29 frames with + // none hidden at birth and `ElkBuffBar` 3: neither was failing to build, both had nothing to + // put in them. + // + // That made "nothing to draw" and "failed to draw" share a row, and the second is the one + // worth finding. Seating content separates them — and, more usefully, exposes the addons + // that only fail WHEN there is something to draw, which no empty-world column can reach. + // + // Deliberately minimal and deliberately ordinary: one buff, one target, one occupied action + // with a running cooldown. Not a stress fixture — a Tuesday. + script.set_auras( + "player", + Some(vec![benilla_ui::script::AuraState { + spell_id: 1243, + name: Some("Power Word: Fortitude".into()), + icon: Some("Interface\\Icons\\Spell_Holy_WordFortitude".into()), + count: 1, + helpful: true, + cancelable: true, + ..Default::default() + }]), + ); + script.set_unit( + "target", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Target Dummy".into()), + health: 80, + max_health: 100, + level: 60, + power_type: 0, + power: 50, + max_power: 100, + class: Some("Warrior".into()), + class_file: Some("WARRIOR".into()), + ..Default::default() + }), + ); + script.set_action( + 1, + Some(benilla_ui::script::ActionSlot { + texture: Some("Interface\\Icons\\Ability_SteelMelee".into()), + kind: 0x00, + action: 100, + count: 0, + }), + ); + script.set_action_state( + 1, + Some(benilla_ui::script::ActionState { + usable: true, + // (startTime ms, duration ms, enable) — a cooldown with time left on it, which is + // what a cooldown-text addon (OmniCC, CooldownCount) needs before it draws anything. + cooldown: Some((1, 30_000, true)), + ..Default::default() + }), + ); } /// Every string key in the VM's `_G`. @@ -2841,6 +2947,68 @@ mod dependency_tests { /// dependency's OWN handler raising is the dependency's row, never its consumers'. Those /// events fire outside the error-capture window, so one library's fault cannot be counted once /// per addon that embeds it. + /// **A consumer's OWN code raising inside a DEPENDENCY's window is the consumer's row.** + /// + /// 1226's finding, now enforced. `AceAddon-2.0.lua:104-105` drains its entire `nextAddon` queue + /// on ANY `ADDON_LOADED` it sees and calls each consumer's `OnInitialize` there (`:230`). So + /// the surveyed addon's own file runs inside a dependency's window — and while attribution was + /// by window, those raises were charged to nobody and the whole FuBar/Ace family read as + /// surviving when it was not. + /// + /// Attribution is by the raising CHUNK now, which 1217 made truthful. The fixture is AceAddon's + /// shape reduced: the library drains a queue on the first ADDON_LOADED it sees, whoever it + /// names, and the consumer's callback raises from the consumer's own file. + /// + /// The second half is the half that must not regress: the LIBRARY's own raise, in the same + /// window, still belongs to the library. + #[test] + fn a_consumers_raise_inside_a_dependency_window_is_the_consumers_row() { + let tmp = + std::env::temp_dir().join(format!("benilla-harness-attrib-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let write = |name: &str, toc: &str, file: &str, body: &str| { + let dir = tmp.join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap(); + std::fs::write(dir.join(file), body).unwrap(); + }; + // AceAddon's shape: drain every queued consumer on the FIRST ADDON_LOADED, whoever it names. + write( + "QueueLib", + "## Interface: 11200\nlib.lua\n", + "lib.lua", + "QueueLibQueue = {}\n\ + QueueLibFrame = CreateFrame(\"Frame\")\n\ + QueueLibFrame:RegisterEvent(\"ADDON_LOADED\")\n\ + QueueLibFrame:SetScript(\"OnEvent\", function()\n\ + while table.getn(QueueLibQueue) > 0 do\n\ + local f = table.remove(QueueLibQueue, 1) f()\n\ + end\n\ + end)\n", + ); + // The consumer queues a callback that raises from ITS OWN file. + write( + "QueueUser", + "## Interface: 11200\n## Dependencies: QueueLib\nuse.lua\n", + "use.lua", + "table.insert(QueueLibQueue, function() error(\"consumer init blew up\") end)\n", + ); + + let reports = survey(&tmp); + let of = |n: &str| reports.iter().find(|r| r.name == n).unwrap(); + + assert!( + of("QueueUser") + .session_errors + .iter() + .any(|e| e.contains("consumer init blew up")), + "the consumer's own file raised — window or not, it is the consumer's row: {:?}", + of("QueueUser").session_errors + ); + + let _ = std::fs::remove_dir_all(&tmp); + } + #[test] fn each_loaded_addon_gets_its_own_addon_loaded_event() { let tmp = std::env::temp_dir().join(format!( diff --git a/crates/benilla-app/src/ui_script/action_bar_tests.rs b/crates/benilla-app/src/ui_script/action_bar_tests.rs index df0c1d48..b2e47b1f 100644 --- a/crates/benilla-app/src/ui_script/action_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/action_bar_tests.rs @@ -24,9 +24,9 @@ fn shipped_action_bar_drives_end_to_end() { ); if file == "ActionBar.xml" { assert_eq!( - report.frames, 59, + report.frames, 61, "bar + XP StatusBar (+ its numerals overlay) + exhaustion tick + max-level rail + art frame + 12 buttons (each with a Cooldown child) + 2 page buttons + the performance meter and its hover button, \ - + BonusActionBarFrame and its 12 buttons with their Cooldown children (25 — hidden, as the reference's is; decision 1223)" + + BonusActionBarFrame and its 12 buttons with their Cooldown children (25 — hidden, as the reference's is; decision 1223), + ReputationWatchBar and its status bar (2 — hidden, ref ReputationFrame.xml:869)" ); } } diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index bfe175b0..3862d3e6 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -523,6 +523,9 @@ pub(crate) struct Model { /// with nothing moving, and a move outlives the mouse button (the reference's mouse-up /// auto-stop skips the Lua drag type). pub(crate) moving: Option, + /// The in-flight `StartSizing` drag — the resize twin of [`Self::moving`], cleared by the same + /// `StopMovingOrSizing` (`0x776990`). + pub(crate) sizing: Option, /// The in-flight Slider thumb drag: set when a LeftButton press lands on a Slider's thumb, held /// until release / pointer-leave (decision 0250 §5, the engine's C++-equivalent thumb drag — /// like a scrollbar dragging in the real client, no Lua involved). `None` between drags @@ -1038,6 +1041,7 @@ impl Model { drag_registered: HashMap::new(), drag: None, moving: None, + sizing: None, slider_drag: None, gossip: None, gossip_selects: Vec::new(), diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index 56feb5b4..e0229793 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -31,7 +31,7 @@ mod layout_methods; mod movable; pub(crate) mod toplevel; pub(crate) use layout_methods::anchor_bits_eq; -pub(crate) use movable::{advance_move, FrameMove}; +pub(crate) use movable::{advance_move, advance_size, FrameMove, FrameSizing}; // ───────────────────────────────────────────────────────────────────────────────────────────── // id ↔ lightuserdata diff --git a/crates/benilla-ui/src/script/object/layout_methods.rs b/crates/benilla-ui/src/script/object/layout_methods.rs index 44caa59d..a22612b3 100644 --- a/crates/benilla-ui/src/script/object/layout_methods.rs +++ b/crates/benilla-ui/src/script/object/layout_methods.rs @@ -274,8 +274,18 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { /// **97 of the 108 addons that drew and then raised on being touched died on that one line.** No /// missing verb: `GetLeft` was always there, and always answered nil. /// -/// Cheap: `resolve_layout`'s tier-1 gate is one epoch comparison that returns immediately when -/// nothing has been touched, so a settled tree pays a compare per getter. +/// Cheap on a SETTLED tree: `resolve_layout`'s tier-1 gate is one epoch comparison that returns +/// immediately when nothing has been touched, so a run of getters pays a compare each. +/// +/// **The interleaved case is not, and this is measured rather than asserted.** A write bumps the +/// epoch, so `SetPoint; GetLeft; SetPoint; GetLeft; …` resolves the whole graph once per iteration. +/// Timed on a 200-frame tree: 30 alternating pairs cost **1.41 ms** against **54 µs** for the same +/// writes with a single read at the end — ~26x, about 47 µs per settle, and the per-settle half +/// scales with the GRAPH, not the loop. +/// +/// That is exactly the shape Dewdrop's menu builder has, so opening a menu pays it once. It buys a +/// menu that works at all, which is the trade taken here. If it ever reads as a hitch, the fix is a +/// narrower resolve (the queried frame's subtree), not a return to the stale cache. /// /// **It deliberately does NOT fire `OnSizeChanged`.** That drain runs Lua handlers, and re-entering /// Lua from inside a binding is how a borrow panic or an unbounded recursion happens. The diff --git a/crates/benilla-ui/src/script/object/movable.rs b/crates/benilla-ui/src/script/object/movable.rs index ef70ce10..6ae26be8 100644 --- a/crates/benilla-ui/src/script/object/movable.rs +++ b/crates/benilla-ui/src/script/object/movable.rs @@ -103,6 +103,19 @@ pub(crate) struct FrameMove { sample: (f32, f32), } +/// An in-flight `StartSizing` drag: which frame, which grip, and the cursor at the last pump. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct FrameSizing { + frame: FrameHandle, + /// The grip the caller named — the EDGES this drag moves. `LEFT` moves the left edge and + /// leaves the right where it is, `BOTTOMRIGHT` moves both of those, and so on. + left: bool, + right: bool, + top: bool, + bottom: bool, + sample: (f32, f32), +} + /// Populate `m`'s movable/resizable methods (see the module doc). pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { // SetMovable(flag) / IsMovable() — flag word bit 0x100 (`0x776420`/`0x7764d0` through the @@ -177,6 +190,57 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { start_moving(&mut model, h) })?, )?; + // StartSizing(point) — begin a resize drag from a named grip (`0x776830`, verified in wow-re's + // ledger; the reference's own caller is `FloatingChatFrame.lua:600`, + // `this:GetParent():StartSizing(anchorPoint)`). + // + // **What is verified and what is read, kept apart on purpose.** Verified: the verb exists, it + // takes the grip name, it returns nothing, and `StopMovingOrSizing` ends it (the same slot + // clear as a move). NOT recorded anywhere in wow-re — the ledger has it as ORCHESTRATION with + // no inline math — is WHICH EDGES a given grip moves. Taken here as the plain meaning of an + // anchor point, which is how the reference's own caller uses it (its resize grips pass the + // corner they sit in): the named edges follow the cursor and the opposite ones stay put. If an + // RE pass ever contradicts that, this comment is where to correct it. + // + // Four corpus addons reach it through ONE line — `FuBar_Panel.lua:980`, replicated into + // FuBar_CorkFu, FuBar_FuXPFu, FuBar_SpellStatusFu and oRA2 — which is 1207's rule and why the + // count is not four independent votes. + m.set( + "StartSizing", + lua.create_function(|lua, (this, point): (Table, Option)| { + let h = frame_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + if !model.arena.frame(h).is_some_and(|f| f.resizable) { + return Err(not_flagged(&model, h, "resizable")); + } + // Same "do not start a second one" guard `start_moving` carries. + if model.sizing.is_some() || model.moving.is_some() { + return Ok(()); + } + let p = point.unwrap_or_default().to_ascii_uppercase(); + let (left, right) = (p.contains("LEFT"), p.contains("RIGHT")); + let (top, bottom) = (p.contains("TOP"), p.contains("BOTTOM")); + // A grip naming no edge would resize nothing; the reference has no such call and we + // refuse rather than invent one. + if !(left || right || top || bottom) { + return Ok(()); + } + super::toplevel::raise(&mut model, h); + if let Some(f) = model.arena.frame_mut(h) { + f.user_placed = true; + } + let sample = model.cursor_pos; + model.sizing = Some(FrameSizing { + frame: h, + left, + right, + top, + bottom, + sample, + }); + Ok(()) + })?, + )?; // StopMovingOrSizing() — leave the drag (`0x776990` → `0x765640`): a state clear, and only // when `self` IS the frame in the slot. Nothing is written back — the pump has been moving the // anchors all along, so the frame simply keeps the last position it was dragged to. Harmless @@ -187,6 +251,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { lua.create_function(|lua, this: Table| { let h = frame_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); + if model.sizing.is_some_and(|sz| sz.frame == h) { + model.sizing = None; + } if model.moving.is_some_and(|mv| mv.frame == h) { model.moving = None; } @@ -283,6 +350,62 @@ fn start_moving(model: &mut Model, h: FrameHandle) -> mlua::Result<()> { /// Applies `(pos − sample)` scaled into the frame's anchor offsets and re-centers the sample; a /// zero delta does nothing at all, and a frame that died mid-move ends the move rather than /// writing to a dead handle. +/// Pump an in-flight `StartSizing`: move the gripped edges to the cursor, leave the others. +/// +/// The mirror of [`advance_move`] — same sample-and-recentre, same dead-frame bail, same +/// local-unit scaling (offsets are pre-scale, so a cursor delta divides by the frame's own scale on +/// the way in). Where a move translates every anchor, a resize moves the gripped edges only: the +/// width/height change, and the anchor offsets follow only for the edges that actually moved, which +/// is what keeps the OPPOSITE edge planted. +pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) { + let Some(sz) = model.sizing else { return }; + if model.arena.frame(sz.frame).is_none() { + model.sizing = None; + return; + } + let (dx, dy) = (pos.0 - sz.sample.0, pos.1 - sz.sample.1); + if dx == 0.0 && dy == 0.0 { + return; + } + let inv = 1.0 / eff_scale(model, sz.frame); + let (dx, dy) = (dx * inv, dy * inv); + let Some(input) = model.layout_inputs.get_mut(&sz.frame) else { + return; + }; + // Width grows when the RIGHT grip goes right, or the LEFT grip goes left. Height likewise with + // y-up: the TOP grip going up grows it, the BOTTOM grip going down grows it. + let dw = if sz.right { + dx + } else if sz.left { + -dx + } else { + 0.0 + }; + let dh = if sz.top { + dy + } else if sz.bottom { + -dy + } else { + 0.0 + }; + input.width = (input.width + dw).max(1.0); + input.height = (input.height + dh).max(1.0); + // The planted edge: a frame anchored by its LEFT that is gripped on the LEFT has to move too, + // or the resize would push the right edge instead. Only the gripped axis shifts. + if !input.anchors.is_empty() && (sz.left || sz.bottom) { + for a in &mut input.anchors { + if sz.left { + a.x_off += dx; + } + if sz.bottom { + a.y_off += dy; + } + } + } + model.touch_layout(); + model.sizing = Some(FrameSizing { sample: pos, ..sz }); +} + pub(crate) fn advance_move(model: &mut Model, pos: (f32, f32)) { let Some(mv) = model.moving else { return }; if model.arena.frame(mv.frame).is_none() { diff --git a/crates/benilla-ui/src/script/pointer.rs b/crates/benilla-ui/src/script/pointer.rs index 884255d2..03897105 100644 --- a/crates/benilla-ui/src/script/pointer.rs +++ b/crates/benilla-ui/src/script/pointer.rs @@ -204,6 +204,7 @@ impl UiScript { // crosses and a pump behind the early return would only ever advance when the cursor // left the frame it is dragging. super::object::advance_move(&mut model, (x, y)); + super::object::advance_size(&mut model, (x, y)); let drag_start = cursor::maybe_start_drag(&mut model, (x, y)); let new_handle = new_id.and_then(|id| model.id_to_frame.get(&id).copied()); if new_handle == model.mouseover { diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index 5b4e0c01..412eec6a 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -6,14 +6,9 @@ use mlua::{Lua, Table, Value}; -use super::object::{ - anchor_bits_eq, as_f32, decode_id, draw_layer_from_str, id_to_lud, point_from_str, -}; -use super::{ - JustifyH, JustifyV, Model, Outline, TexCoords, REG_REGION_META, REG_REGION_METHODS, - REG_WRAPPERS, SCREEN, -}; -use crate::layout::{Anchor, Point}; +use super::object::{anchor_bits_eq, as_f32, decode_id, id_to_lud, point_from_str}; +use super::{Model, REG_REGION_META, REG_REGION_METHODS, REG_WRAPPERS, SCREEN}; +use crate::layout::Anchor; use crate::widget::RegionHandle; /// Resolve `self` (a region wrapper) to its live [`RegionHandle`]. @@ -97,6 +92,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { Ok(()) } +mod layout; +mod paint; +mod text; + fn install_region_methods(lua: &Lua) -> mlua::Result<()> { let m = lua.create_table()?; @@ -213,6 +212,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { Ok(()) })?, )?; + m.set( "Hide", lua.create_function(|lua, this: Table| { @@ -222,6 +222,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { Ok(()) })?, )?; + m.set( "IsShown", lua.create_function(|lua, this: Table| { @@ -231,6 +232,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { Ok(if shown { Value::Integer(1) } else { Value::Nil }) })?, )?; + m.set( "IsVisible", lua.create_function(|lua, this: Table| { @@ -250,919 +252,11 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { })?, )?; - // SetAlpha/GetAlpha — the region's own alpha, distinct from the owner frame's. The ref kit reads - // it back to ramp a texture (CastingBarFrame's completion flash does - // `CastingBarFlash:SetAlpha(GetAlpha() + CASTING_BAR_FLASH_STEP)`), so the getter must return the - // region's value, never the frame's. Draw law + the open question: [`RegionData::alpha`]. - m.set( - "SetAlpha", - lua.create_function(|lua, (this, alpha): (Table, f32)| { - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - model.region_data.entry(rh).or_default().alpha = Some(alpha.clamp(0.0, 1.0)); - Ok(()) - })?, - )?; - m.set( - "GetAlpha", - lua.create_function(|lua, this: Table| { - let rh = region_handle_of(lua, &this)?; - let model = lua.app_data_ref::().expect("model"); - Ok(model - .region_data - .get(&rh) - .and_then(|d| d.alpha) - .unwrap_or(1.0)) - })?, - )?; - - m.set( - "SetVertexColor", - lua.create_function( - |lua, (this, r, g, b, a): (Table, f32, f32, f32, Option)| { - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let d = model.region_data.entry(rh).or_default(); - d.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]); - // The same slot `SetTextColor` writes on a FontString, so it is the same explicit - // colour set as far as font-object inheritance is concerned. - d.font_explicit.color = true; - Ok(()) - }, - )?, - )?; - // GetVertexColor — the setter's own pair, a real 5875 binding (`0x79aa50`, wow-re - // `system/ui/ledger.tsv`; it sits directly above `SetVertexColor 0x79abd0` in the same region - // method family). Never set = the untinted white every region draws at by default. - m.set( - "GetVertexColor", - lua.create_function(|lua, this: Table| { - let rh = region_handle_of(lua, &this)?; - let model = lua.app_data_ref::().expect("model"); - let c = model - .region_data - .get(&rh) - .and_then(|d| d.vertex_color) - .unwrap_or([1.0, 1.0, 1.0, 1.0]); - Ok((c[0], c[1], c[2], c[3])) - })?, - )?; - // SetGradientAlpha(orientation, r1,g1,b1,a1, r2,g2,b2,a2) and its alpha-less twin - // SetGradient(orientation, r1,g1,b1, r2,g2,b2) — the two-stop linear gradient the client - // generates into the same `+0xcc` texture slot the colour form of SetTexture fills. - // - // These were missing, and they were the single wall in front of the corpus's largest family: - // `FuBar\FuBar_Panel.lua:144` calls SetGradientAlpha while building the bar, so all 20 FuBar - // plugins died there — after the debugstack/chunk-name fix got them that far. - // - // The orientation token is matched case-insensitively and anything that is not "VERTICAL" is - // horizontal, which is the client's own leniency and matters because addons spell it both ways. - // The gradient is stored WHOLE (see `RegionData::gradient`); the paint folds it to its midpoint - // because a quad carries one tint today. That approximation is visible and is recorded at the - // field, not hidden here. - for (name, with_alpha) in [("SetGradientAlpha", true), ("SetGradient", false)] { - m.set( - name, - lua.create_function(move |lua, args: mlua::MultiValue| { - let mut it = args.into_iter(); - let this: Table = match it.next() { - Some(Value::Table(t)) => t, - _ => return Err(mlua::Error::runtime("expected a region")), - }; - let orientation = match it.next() { - Some(Value::String(s)) => s.to_str()?.to_string(), - // A missing/!string orientation is horizontal, like any non-"VERTICAL" token. - _ => String::new(), - }; - let n = if with_alpha { 8 } else { 6 }; - let mut c = [0.0f32; 8]; - for slot in c.iter_mut().take(n) { - *slot = it.next().as_ref().map(as_f32).unwrap_or(0.0); - } - let (start, end) = if with_alpha { - ([c[0], c[1], c[2], c[3]], [c[4], c[5], c[6], c[7]]) - } else { - // SetGradient has no alpha stops: both ends are opaque. - ([c[0], c[1], c[2], 1.0], [c[3], c[4], c[5], 1.0]) - }; - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let d = model.region_data.entry(rh).or_default(); - d.gradient = Some(super::Gradient { - vertical: orientation.eq_ignore_ascii_case("VERTICAL"), - start, - end, - }); - Ok(()) - })?, - )?; - } - m.set( - "SetTexture", - // The trailing three are `Value`, not `Option`, and that is a fidelity fix rather than - // laxity. The path form reads ONE argument (`0x770200`); only the colour form - // (`0x770360`) reads up to four. A C function takes what it wants off the Lua stack and - // ignores the rest, so `SetTexture(path, true)` is fine on the real client — and typing - // these as `Option` made us raise on it, `bad argument #3: error converting Lua - // boolean to f32`, in a call the client accepts silently. - // - // Found by `_LazyPig/LazyPigMenu.lua:182` - // (`texture_title:SetTexture("Interface\DialogFrame\UI-DialogBox-Header", true)`), which - // reached us only once the survey started seating the addon registry — the whole point of - // that instrument fix. The stray `true` is meaningless in 1.12 and the addon author - // presumably meant a later client's second parameter; either way the client shrugs. - lua.create_function( - |lua, (this, arg, g, b, a): (Table, Value, Value, Value, Value)| { - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let data = model.region_data.entry(rh).or_default(); - // A plain SetTexture makes the region ordinary again — drop any portrait circular mask - // and any live-unit-portrait binding. - data.circular = false; - data.portrait_unit = None; - // Both forms write the SAME `+0xcc` texture slot — the path form loads a file - // there (`0x770200`), the colour form generates an 8×8 solid into it - // (`0x770360`) — so each clears the other. NEITHER touches the vertex colour at - // `+0xb8`: a tint outlives the art it was tinting. - match &arg { - // SetTexture("") clears, same as SetTexture(nil) — the ref lua blanks state - // art with the empty string (QuestLogFrame.lua:165-166). - Value::String(s) if s.to_str()?.is_empty() => { - data.texture = None; - data.fill = None; - } - Value::String(s) => { - data.texture = Some(s.to_str()?.to_string()); - data.fill = None; - } - // The colour form, and the ONLY branch that looks at the trailing three. A - // non-numeric there takes the same default a missing one does, which is what - // reading off a C stack does: `lua_tonumber` on a non-number yields 0. - Value::Number(_) | Value::Integer(_) => { - let chan = |v: &Value, dflt: f32| match v { - Value::Number(_) | Value::Integer(_) => as_f32(v), - Value::String(s) => s - .to_str() - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or(dflt), - _ => dflt, - }; - data.fill = - Some([as_f32(&arg), chan(&g, 0.0), chan(&b, 0.0), chan(&a, 1.0)]); - data.texture = None; - } - // SetTexture(nil) clears (the live API's blank-the-region form); a cleared - // texture region draws nothing. - Value::Nil => { - data.texture = None; - data.fill = None; - } - _ => {} - } - Ok(()) - }, - )?, - )?; - // SetDesaturated(flag) -> shaderSupported — Texture only (`0x79c1e0`, wow-re ledger; the - // reference's own `ItemButtonTemplate.lua:69` is `local shaderSupported = - // icon:SetDesaturated(desaturated)`). - // - // **The RETURN is the whole design, and it is the half a plausible implementation drops.** - // 1.12 ran on cards that could not do the shader, so the verb reports whether it took effect - // and FrameXML falls back by hand: - // - // if ( not desaturated ) then r,g,b = 1,1,1 - // elseif ( not r or not shaderSupported ) then r,g,b = 0.5,0.5,0.5 end - // icon:SetVertexColor(r, g, b) - // - // We have no desaturation in the renderer, so we answer **nil — unsupported**, which is a real - // 1.12 machine's answer and not a lie. Claiming support would suppress that grey fallback and - // leave disabled icons drawn at full colour: strictly worse-looking than saying no. When a - // desaturating shader lands, flip the return and this comment with it. - // - // Why it matters far past one verb: **98 of the 109 addons that draw and then raise on being - // used, raise here** — `FuBar_Panel.lua:43`'s right-click reaches Dewdrop's `AddLine` - // (`Dewdrop-2.0.lua:2172`), which calls `button.arrow:SetDesaturated(true)` unguarded. A - // static scan costed this at 61 addons and it was declined; the use-probe costed it at 98 the - // moment anyone right-clicks. - m.set( - "SetDesaturated", - lua.create_function(|lua, (this, flag): (Table, Value)| { - let on = !matches!(flag, Value::Nil | Value::Boolean(false)); - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - model.region_data.entry(rh).or_default().desaturated = on; - // nil, not false: the reference's `shaderSupported` is a 1|nil C answer and callers - // write `not shaderSupported`. - Ok(Value::Nil) - })?, - )?; - // GetTexture() — Texture only (`0x79ba70`), verified in wow-re's widget-method batch - // (`system/ui/scratch/widget-api-batch-benilla.md`). Three contract details are each the kind a - // plausible implementation gets silently wrong, so each is spelled out: - // - // · **Exactly ONE return value**, never a multi-return. - // · **The colour form returns the literal string `"Solid Texture"`** (`0x835708`), NOT nil. - // `SetTexture(r,g,b)` synthesizes an 8x8 solid, and the getter reports that name — so an - // addon's `if not tex then` passes straight through on a colour-filled region. Returning nil - // here would look tidier and would be wrong in the one direction callers test for. - // · **The path is stripped at the LAST `.`** (`0x79baf0`): the loader appends `.blp`/`.tga` to - // what was set and the getter strips an extension back off. Taken verbatim rather than - // "strip only a real extension" — a directory containing a dot is mangled by the real client - // too, and this surface is a transcription, not an improvement. - // - // Four corpus addons: `AtlasQuest.lua:228` (`AQATLASMAP = AtlasMap:GetTexture()`) and - // `FuBarPlugin-2.0.lua:343` (`return self.iconFrame:GetTexture()`), each reached by two addons. - m.set( - "GetTexture", - lua.create_function(|lua, this: Table| { - let rh = region_handle_of(lua, &this)?; - let model = lua.app_data_mut::().expect("model"); - let Some(data) = model.region_data.get(&rh) else { - return Ok(None); - }; - if data.fill.is_some() { - return Ok(Some("Solid Texture".to_string())); - } - Ok(data.texture.as_ref().map(|t| match t.rfind('.') { - Some(i) => t[..i].to_string(), - None => t.clone(), - })) - })?, - )?; - // SetPortraitToTexture(path) — set the texture AND mark the region a portrait (drawn masked to - // its inscribed circle). The client's portraits are circular (model or icon fallback); the frame - // ring is a thin band whose transparent corners would otherwise show the square texture's edges. - // The live API's `SetPortraitToTexture(texture, path)` is a global crop helper; ours is the - // region-method face of the same intent. This is the *icon/path* portrait — distinct from the - // `SetPortraitTexture(region, unit)` live model bake, so it drops any live-unit binding. - // `SetTexture` on the same region clears the flag (a plain texture again). - m.set( - "SetPortraitToTexture", - lua.create_function(|lua, (this, path): (Table, String)| { - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let data = model.region_data.entry(rh).or_default(); - data.texture = Some(path); - data.circular = true; - data.portrait_unit = None; - Ok(()) - })?, - )?; - m.set( - "SetText", - lua.create_function(|lua, (this, text): (Table, Option)| { - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let data = model.region_data.entry(rh).or_default(); - data.text = text; - // Fresh text draws whole — an armed write-on gradient belongs to the old string. - data.alpha_gradient = None; - Ok(()) - })?, - )?; - // SetAlphaGradient(start, length) — the per-character write-on reveal (CSimpleFontString; - // the quest-description machinery, ref QuestFrame.lua:548/558). Returns whether `start` is - // still inside the text (chars) — the ref's OnUpdate loop advances until this goes false. - m.set( - "SetAlphaGradient", - lua.create_function(|lua, (this, start, length): (Table, f32, f32)| { - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let data = model.region_data.entry(rh).or_default(); - data.alpha_gradient = Some((start, length)); - let chars = data.text.as_deref().map_or(0, |t| t.chars().count()); - Ok(start < chars as f32) - })?, - )?; - // SetFormattedText(fmt, ...) = SetText(format(fmt, ...)) — routed through the stdlib's - // positional-aware `format` so `%N$s` specs behave (a consensus call across the 0068 targets). - m.set( - "SetFormattedText", - lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| { - let format: mlua::Function = lua - .globals() - .get::("string")? - .get::("format")?; - let text: String = format.call(args)?; - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let data = model.region_data.entry(rh).or_default(); - data.text = Some(text); - data.alpha_gradient = None; - Ok(()) - })?, - )?; - m.set( - "GetText", - lua.create_function(|lua, this: Table| { - let rh = region_handle_of(lua, &this)?; - let text = { - let model = lua.app_data_ref::().expect("model"); - model.region_data.get(&rh).and_then(|d| d.text.clone()) - }; - match text { - Some(t) => Ok(Value::String(lua.create_string(&t)?)), - None => Ok(Value::Nil), - } - })?, - )?; - // SetBlendMode("BLEND"|"ADD"|…) — the shared alphaMode enum (0x811aa8); only ADD changes - // draw behavior in v1 (DISABLE/ALPHAKEY/MOD are accepted as straight alpha, a stated gap). - m.set( - "SetBlendMode", - lua.create_function(|lua, (this, mode): (Table, String)| { - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - model.region_data.entry(rh).or_default().additive = mode.eq_ignore_ascii_case("ADD"); - Ok(()) - })?, - )?; - // Region explicit size (drawn centered on the owner; region anchors come later). - m.set( - "SetWidth", - lua.create_function(|lua, (this, w): (Table, f32)| { - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let d = model.region_data.entry(rh).or_default(); - let new = Some((w, d.size.map_or(0.0, |s| s.1))); - let changed = !size_bits_eq(d.size, new); - d.size = new; - if changed { - model.touch_layout(); - } - Ok(()) - })?, - )?; - m.set( - "SetHeight", - lua.create_function(|lua, (this, h): (Table, f32)| { - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let d = model.region_data.entry(rh).or_default(); - let new = Some((d.size.map_or(0.0, |s| s.0), h)); - let changed = !size_bits_eq(d.size, new); - d.size = new; - if changed { - model.touch_layout(); - } - Ok(()) - })?, - )?; - m.set( - "SetSize", - lua.create_function(|lua, (this, w, h): (Table, f32, f32)| { - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let d = model.region_data.entry(rh).or_default(); - let new = Some((w, h)); - let changed = !size_bits_eq(d.size, new); - d.size = new; - if changed { - model.touch_layout(); - } - Ok(()) - })?, - )?; - // GetStringWidth/GetStringHeight (FontString): the host-measured text extent from the measure - // round-trip ([`super::UiScript::set_measured_text`]) — the client asks its font engine for the - // laid-out string's metrics exactly here (`fontstring.md`), and the tooltip's auto-size sums - // these to fit its lines. `0` until the string has been measured (a frame's latency; converges). - // The stored measure only counts while its key matches the CURRENT text/font/wrap - // ([`RegionData::measure_key`]): after a SetText the old string's width is not this string's - // metric — serving it is how the whisper header's `GetWidth()` latched the edit-box insets on - // the previous header's width. A poll-until-nonzero caller (the chat header machine) now - // converges on the RIGHT measure instead of settling on a stale one. - // `GetWidth`/`GetHeight` prefer the measured extent, falling back to an explicit `SetSize` — the - // real client's `SmallTextTooltipText:GetWidth()` idiom (ref-GameTooltip.xml l.63). - fn measured_wh(lua: &Lua, this: &Table) -> mlua::Result<(f32, f32)> { - let rh = region_handle_of(lua, this)?; - let model = lua.app_data_ref::().expect("model"); - let d = model.region_data.get(&rh); - // The key carries the owner's effective_scale ([`RegionData::measure_key`]) — the same - // recipe the request loop stamps, or every read under a SetScale'd owner reports stale. - let scale = model - .arena - .region(rh) - .and_then(|r| model.arena.frame(r.owner)) - .map(|f| f.effective_scale) - .unwrap_or(1.0); - let m = d.and_then(|d| d.measured.filter(|m| m.key == d.measure_key(scale))); - let size = d.and_then(|d| d.size); - let w = m.map(|m| m.w).or(size.map(|s| s.0)).unwrap_or(0.0); - let h = m.map(|m| m.h).or(size.map(|s| s.1)).unwrap_or(0.0); - Ok((w, h)) - } - // GetStringWidth is the **natural, unwrapped** extent — never the declared box, and never the - // wrapped one (wow-re `fontstring-overflow.md`, "The measurement echo": the reference's getter - // re-measures the raw text with NO wrap constraint). Unlike `GetWidth` below it deliberately - // does NOT fall back to an explicit `SetSize`: the declared width is the very thing a caller - // asks this to be independent of. A kit that sizes a box from this number and then sets a width - // on the string — which is what the reference's own `PanelTemplates_TabResize` does — would - // otherwise read its own output back as its next input and never settle (decision 0997, the - // macro window's character tab changing width every frame). `0` until measured, as ever. - fn natural_w(lua: &Lua, this: &Table) -> mlua::Result { - let rh = region_handle_of(lua, this)?; - let model = lua.app_data_ref::().expect("model"); - let Some(d) = model.region_data.get(&rh) else { - return Ok(0.0); - }; - let scale = model - .arena - .region(rh) - .and_then(|r| model.arena.frame(r.owner)) - .map(|f| f.effective_scale) - .unwrap_or(1.0); - Ok(d.measured - .filter(|m| m.key == d.measure_key(scale)) - .map(|m| m.natural_w) - .unwrap_or(0.0)) - } - m.set( - "GetStringWidth", - lua.create_function(|lua, this: Table| natural_w(lua, &this))?, - )?; - m.set( - "GetStringHeight", - lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.1))?, - )?; - m.set( - "GetWidth", - lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.0))?, - )?; - m.set( - "GetHeight", - lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.1))?, - )?; - - // GetLeft/GetRight/GetTop/GetBottom — the region's RESOLVED edges (y-up UI units; frame twin - // in object.rs). An anchored region reads its own resolved rect; an unanchored one has no - // rect of its own (it draws relative to its owner at extract) → nil, same as pre-resolve. - for (name, pick) in [ - ("GetLeft", 0u8), - ("GetRight", 1u8), - ("GetTop", 2u8), - ("GetBottom", 3u8), - ] { - m.set( - name, - lua.create_function(move |lua, this: Table| { - let rh = region_handle_of(lua, &this)?; - let model = lua.app_data_ref::().expect("model"); - Ok(model.region_resolved.get(&rh).map(|r| match pick { - 0 => r.left, - 1 => r.right, - 2 => r.top, - _ => r.bottom, - })) - })?, - )?; - } - m.set( - "SetDrawLayer", - lua.create_function(|lua, (this, layer, sub): (Table, String, Option)| { - let rh = region_handle_of(lua, &this)?; - let dl = draw_layer_from_str(&layer) - .ok_or_else(|| mlua::Error::runtime(format!("unknown draw layer '{layer}'")))?; - let mut model = lua.app_data_mut::().expect("model"); - if let Some(region) = model.arena.region_mut(rh) { - region.draw_layer = dl; - if let Some(s) = sub { - region.sub_level = s.clamp(i64::from(i8::MIN), i64::from(i8::MAX)) as i8; - } - } - Ok(()) - })?, - )?; - - // Region anchors: SetPoint/ClearAllPoints/SetAllPoints mirror the frame versions - // ([`super::object`]) but write [`super::RegionData::anchors`]. An unspecified `relativeTo` - // defaults to the **owner frame**; a named one may be a frame or a sibling region (the real - // XML anchors regions to sibling regions everywhere — merchant label plate → `$parentSlot`). - m.set( - "SetPoint", - lua.create_function( - |lua, (this, p, a2, a3, a4, a5): (Table, String, Value, Value, Value, Value)| { - region_set_point(lua, &this, &p, [a2, a3, a4, a5]) - }, - )?, - )?; - m.set( - "ClearAllPoints", - lua.create_function(|lua, this: Table| { - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let d = model.region_data.entry(rh).or_default(); - let changed = !d.anchors.is_empty(); - d.anchors.clear(); - if changed { - model.touch_layout(); - } - Ok(()) - })?, - )?; - m.set( - "SetAllPoints", - lua.create_function(|lua, (this, target): (Table, Value)| { - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let owner = region_owner_id(&mut model, rh); - let rel_id = resolve_target(&mut model, &target, owner); - let pair = [ - Anchor::new(Point::TopLeft, rel_id, Point::TopLeft, 0.0, 0.0), - Anchor::new(Point::BottomRight, rel_id, Point::BottomRight, 0.0, 0.0), - ]; - let data = model.region_data.entry(rh).or_default(); - let same = data.anchors.len() == 2 - && data - .anchors - .iter() - .zip(&pair) - .all(|(a, b)| anchor_bits_eq(a, b)); - if !same { - data.anchors.clear(); - data.anchors.extend_from_slice(&pair); - model.touch_layout(); - } - Ok(()) - })?, - )?; - // SetJustifyH("LEFT"|"CENTER"|"RIGHT") — a FontString's horizontal justification (XML `justifyH`). - m.set( - "SetJustifyH", - lua.create_function(|lua, (this, j): (Table, String)| { - let rh = region_handle_of(lua, &this)?; - let jh = match j.to_ascii_uppercase().as_str() { - "LEFT" => JustifyH::Left, - "RIGHT" => JustifyH::Right, - _ => JustifyH::Center, - }; - let mut model = lua.app_data_mut::().expect("model"); - let d = model.region_data.entry(rh).or_default(); - d.justify_h = jh; - d.font_explicit.justify_h = true; - Ok(()) - })?, - )?; - - // SetJustifyV("TOP"|"MIDDLE"|"BOTTOM") — a FontString's vertical justification (XML `justifyV`). - m.set( - "SetJustifyV", - lua.create_function(|lua, (this, j): (Table, String)| { - let rh = region_handle_of(lua, &this)?; - let jv = match j.to_ascii_uppercase().as_str() { - "TOP" => JustifyV::Top, - "BOTTOM" => JustifyV::Bottom, - _ => JustifyV::Middle, - }; - let mut model = lua.app_data_mut::().expect("model"); - let d = model.region_data.entry(rh).or_default(); - d.justify_v = jv; - d.font_explicit.justify_v = true; - Ok(()) - })?, - )?; - - // SetRotation(radians) — spin the texture about its center, counterclockwise-positive (the - // later-era Texture API, shipped early: the world-map player arrow's stand-in rotation — - // see `QuadContent::Texture::rotation`). No-arg/nil resets to 0. - m.set( - "SetRotation", - lua.create_function(|lua, (this, radians): (Table, Option)| { - let rh = region_handle_of(lua, &this)?; - lua.app_data_mut::() - .expect("model") - .region_data - .entry(rh) - .or_default() - .rotation = radians.unwrap_or(0.0); - Ok(()) - })?, - )?; - - // SetTexCoord(left, right, top, bottom) — the 4-edge form (XML ``): a UV sub-rect in - // 0..1 texture space (top-left origin) the Texture region samples, slicing quadrant/atlas art - // (decision 0084). SetTexCoord(ULx,ULy, LLx,LLy, URx,URy, LRx,LRy) — the 8-arg affine form: an - // arbitrary UV quad (rotation/shear — the reference's `DrawRouteLine` route lines), stored per - // corner in the renderer's screen winding. - m.set( - "SetTexCoord", - lua.create_function(|lua, (this, rest): (Table, mlua::Variadic)| { - let rh = region_handle_of(lua, &this)?; - let coords = match rest.len() { - 4 => Some(TexCoords::Rect([rest[0], rest[1], rest[2], rest[3]])), - // The live arg order is UL, LL, UR, LR (corner pairs); [`TexCoords::Corners`] - // stores screen order [TL, TR, BR, BL]. - 8 => Some(TexCoords::Corners([ - [rest[0], rest[1]], // UL → TL - [rest[4], rest[5]], // UR → TR - [rest[6], rest[7]], // LR → BR - [rest[2], rest[3]], // LL → BL - ])), - // No args resets to the full texture (the live API's clear form). - 0 => None, - n => { - return Err(mlua::Error::runtime(format!( - "SetTexCoord: expected 4 (edges) or 8 (corner pairs) args, got {n}" - ))) - } - }; - lua.app_data_mut::() - .expect("model") - .region_data - .entry(rh) - .or_default() - .tex_coords = coords; - Ok(()) - })?, - )?; - // GetTexCoord() → left, right, top, bottom (the 4-edge form; full texture if never set; an - // affine mapping reports its bounding edges). - m.set( - "GetTexCoord", - lua.create_function(|lua, this: Table| { - let rh = region_handle_of(lua, &this)?; - let model = lua.app_data_ref::().expect("model"); - let [l, r, t, b] = model - .region_data - .get(&rh) - .and_then(|d| d.tex_coords) - .map(|tc| tc.edges()) - .unwrap_or([0.0, 1.0, 0.0, 1.0]); - Ok((l, r, t, b)) - })?, - )?; - // SetFontObject(GameFontNormal) — re-point this FontString at a Font object: its resolved paint - // (face/height/color/outline/shadow) becomes the region's, and the link is kept live, so a later - // `GameFontNormal:SetFont(…)` re-paints this region too ([`super::font`]'s module doc). - // - // All three argument forms the reference's own usage string names (`.rdata 0x87c5cc`: - // `SetFontObject(font or "font" or nil)`) — the **object**, which is what 3,180 of the corpus's - // 3,186 call sites pass (`Gratuity-2.0.lua:57`, every FuBar/Ace label); a **name string**, for - // our own shipped XML and the 6 sites that use it; and **nil**, which severs the link. A frame, - // a number, or an unknown name is an error — never a silent no-op (1203/1205/1211's class). - m.set( - "SetFontObject", - lua.create_function(|lua, (this, font): (Table, Value)| { - let name = super::font::resolve("SetFontObject", &font)?; - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - // The nil form: unlink, and leave the paint standing (the reference stores a null - // parent; nothing re-reads and nothing is cleared). - let Some(name) = name else { - model.region_data.entry(rh).or_default().font_object = None; - return Ok(()); - }; - let Some(fo) = model.font_objects.get(&name).cloned() else { - return Err(mlua::Error::runtime(format!( - "SetFontObject: no font object named '{name}' is registered" - ))); - }; - let d = model.region_data.entry(rh).or_default(); - d.font_object = Some(name); - // The severance mask is deliberately NOT reset here. §5-verified: the real "stop - // inheriting this property" signal is a CLEARED bit in the inheritMask at - // `FONTINSTANCE+0x2c` (FontString `+0xd4`, per-axis justify at `+0x124`), cleared by - // each local setter and never restored — "a FontString that set its own colour stays - // severed even across a later SetFontObject" (wow-re - // `system/ui/scratch/font-object-lua-surface.md`). This corrects our first cut, which - // reset it. - super::font::repaint(d, &fo); - Ok(()) - })?, - )?; - // GetFontObject() → the font OBJECT this FontString last resolved (or nil). - // - // The object, not its name: `Dewdrop-2.0.lua:2181` is - // `button.text:SetTextColor(button.text:GetFontObject():GetTextColor())` — 65 sites across 62 - // corpus addons that index the result immediately. A name string there raises. - m.set( - "GetFontObject", - lua.create_function(|lua, this: Table| { - let rh = region_handle_of(lua, &this)?; - let name = { - let model = lua.app_data_ref::().expect("model"); - model - .region_data - .get(&rh) - .and_then(|d| d.font_object.clone()) - .filter(|n| model.font_objects.contains_key(n)) - }; - match name { - Some(n) => Ok(Value::Table(super::font::wrapper(lua, &n)?)), - None => Ok(Value::Nil), - } - })?, - )?; - // SetNonSpaceWrap(enable) / CanNonSpaceWrap() — FontString only (`0x79e9f0` / `0x79ead0`). - // - // Two contract details from wow-re's batch, both easy to get wrong: - // · the getter is **`CanNonSpaceWrap`**, not `GetNonSpaceWrap`, and it answers **`1` or nil**, - // not a boolean — 1.12 predates that convention and an addon may compare against 1. - // · a **no-argument call ENABLES** it (the default is on), rather than being a query. - // - // `oRA2/Leader/Item.lua:561` is `f.textname:SetNonSpaceWrap(false)`, reached by two addons. - m.set( - "SetNonSpaceWrap", - lua.create_function(|lua, (this, enable): (Table, Value)| { - let on = match &enable { - Value::Nil => true, - Value::Boolean(b) => *b, - _ => true, - }; - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - model.region_data.entry(rh).or_default().non_space_wrap = Some(on); - Ok(()) - })?, - )?; - m.set( - "CanNonSpaceWrap", - lua.create_function(|lua, this: Table| { - let rh = region_handle_of(lua, &this)?; - let model = lua.app_data_mut::().expect("model"); - let on = model - .region_data - .get(&rh) - .and_then(|d| d.non_space_wrap) - .unwrap_or(true); - Ok(if on { Some(1i64) } else { None }) - })?, - )?; - // ── shadow, on the REGION ──────────────────────────────────────────────────────────────── - // These already existed on the font-object table; a FontString made by `CreateFontString` had - // none of them, and that is where the corpus calls them: - // `FuBar_NavigatorFu/NavigatorFu.lua:31` does - // `coordText:SetShadowColor(GameFontNormal:GetShadowColor())` — the GETTER on a font object, - // the SETTER on a fresh region — and `KLHThreatMeter/.../KTM_Gui.lua:404` is - // `fontstring:SetShadowColor(0,0,0,0.3)`. - // - // **`GetShadowColor` returns FOUR values, not three** (`0x79dd2f`, `mov eax,0x4` — wow-re's - // widget-method batch). Three is the plausible wrong answer and it silently drops the alpha - // that NavigatorFu is round-tripping. `GetShadowOffset` returns two, in UI units. - // - // Either half may be set before the other, so each starts from whatever is there — the same - // rule the font-object versions already follow. - m.set( - "SetShadowColor", - lua.create_function( - |lua, (this, r, g, b, a): (Table, f32, f32, f32, Option)| { - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let d = model.region_data.entry(rh).or_default(); - let offset = d.font_shadow.map_or([0.0, 0.0], |s| s.offset); - d.font_shadow = Some(crate::script::FontShadow { - offset, - color: [r, g, b, a.unwrap_or(1.0)], - }); - d.font_explicit.shadow = true; - Ok(()) - }, - )?, - )?; - m.set( - "GetShadowColor", - lua.create_function(|lua, this: Table| { - let rh = region_handle_of(lua, &this)?; - let model = lua.app_data_mut::().expect("model"); - let c = model - .region_data - .get(&rh) - .and_then(|d| d.font_shadow) - .map_or([0.0, 0.0, 0.0, 1.0], |s| s.color); - Ok((c[0], c[1], c[2], c[3])) - })?, - )?; - m.set( - "SetShadowOffset", - lua.create_function(|lua, (this, x, y): (Table, f32, f32)| { - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let d = model.region_data.entry(rh).or_default(); - let color = d.font_shadow.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color); - d.font_shadow = Some(crate::script::FontShadow { - offset: [x, y], - color, - }); - d.font_explicit.shadow = true; - Ok(()) - })?, - )?; - m.set( - "GetShadowOffset", - lua.create_function(|lua, this: Table| { - let rh = region_handle_of(lua, &this)?; - let model = lua.app_data_mut::().expect("model"); - let o = model - .region_data - .get(&rh) - .and_then(|d| d.font_shadow) - .map_or([0.0, 0.0], |s| s.offset); - Ok((o[0], o[1])) - })?, - )?; - - // SetFont(path, height [, flags]) — the direct face/size/outline setter (the real region API and - // the XML `font=`/``/`outline=` join). `flags` is an OUTLINETYPE-ish string - // ("OUTLINE"/"THICKOUTLINE"/…"); anything else clears the outline. A nil/empty `path` keeps the - // current face (so a FontString with only `` retains its inherited object's font). - // Returns true (the live API returns whether the font loaded; we always accept — face - // availability is the renderer's concern). - m.set( - "SetFont", - lua.create_function( - |lua, (this, path, height, flags): (Table, Option, Option, Option)| { - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let d = model.region_data.entry(rh).or_default(); - // Each argument actually supplied is an EXPLICIT set: it must survive a later - // mutation of the font object this region inherits (`FontExplicit`). - if let Some(p) = path.filter(|p| !p.is_empty()) { - d.font_path = Some(p); - d.font_explicit.face = true; - } - if let Some(h) = height { - d.font_height = Some(h); - d.font_explicit.height = true; - } - if let Some(f) = flags { - d.outline = Outline::flags(&f); - d.font_explicit.outline = true; - } - Ok(true) - }, - )?, - )?; - // SetTextHeight(height) — switch the FontString to the scaled-string regime (§5-verified, - // wow-re `fontstring-overflow.md`: `0x771600` is the ONLY clearer of the one-to-one bit - // `0x200`; the literal size then flows through UNCAPPED, magnified from the raster). Stored - // as the distinct [`RegionData::text_height`] — the font object is untouched, so GetFont - // keeps reporting the face's own height like the real API. - m.set( - "SetTextHeight", - lua.create_function(|lua, (this, height): (Table, f32)| { - let rh = region_handle_of(lua, &this)?; - lua.app_data_mut::() - .expect("model") - .region_data - .entry(rh) - .or_default() - .text_height = Some(height); - Ok(()) - })?, - )?; - // GetFont() → path, height, flags — the resolved face/size/outline (nil path if never set). - m.set( - "GetFont", - lua.create_function(|lua, this: Table| { - let rh = region_handle_of(lua, &this)?; - let model = lua.app_data_ref::().expect("model"); - let d = model.region_data.get(&rh); - let path = d.and_then(|d| d.font_path.clone()); - let height = d.and_then(|d| d.font_height); - let flags = d.map(|d| d.outline).unwrap_or_default().as_str(); - let path = match path { - Some(p) => Value::String(lua.create_string(&p)?), - None => Value::Nil, - }; - Ok((path, height, flags)) - })?, - )?; - // SetTextColor(r, g, b [, a]) — a FontString's text color. A different binding name for the - // same `+0xb8` vertex-colour slot `SetVertexColor` writes: a FontString has no texel of its own - // to multiply against, so its vertex colour IS the colour it draws. - m.set( - "SetTextColor", - lua.create_function( - |lua, (this, r, g, b, a): (Table, f32, f32, f32, Option)| { - let rh = region_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - let d = model.region_data.entry(rh).or_default(); - d.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]); - d.font_explicit.color = true; - Ok(()) - }, - )?, - )?; - // GetTextColor() → r, g, b, a — `SetTextColor`'s missing pair, and a real binding in the same - // FontInstance family. 11 corpus sites read it off a FontString directly (`CustomNameplates` - // re-tints a level tag from the name's colour; `TipBuddy` snapshots every tooltip line), on top - // of the 65 that reach it through `GetFontObject()`. Never set = the white every region draws - // at, same convention as `GetVertexColor` (the same slot). - m.set( - "GetTextColor", - lua.create_function(|lua, this: Table| { - let rh = region_handle_of(lua, &this)?; - let model = lua.app_data_ref::().expect("model"); - let c = model - .region_data - .get(&rh) - .and_then(|d| d.vertex_color) - .unwrap_or([1.0, 1.0, 1.0, 1.0]); - Ok((c[0], c[1], c[2], c[3])) - })?, - )?; + // The three clusters this file was split into (0716's budget). Order is immaterial — + // every one of them only writes into the same method table. + paint::install(lua, &m)?; + text::install(lua, &m)?; + layout::install(lua, &m)?; lua.set_named_registry_value(REG_REGION_METHODS, m)?; Ok(()) @@ -1170,7 +264,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { /// The layout [`super::layout::Handle`] a region anchors to by default: its **owner frame**'s id /// (minted if needed), or [`SCREEN`] if the region has somehow lost its owner. -fn region_owner_id(model: &mut Model, rh: RegionHandle) -> u32 { +pub(super) fn region_owner_id(model: &mut Model, rh: RegionHandle) -> u32 { match model.arena.region(rh).map(|r| r.owner) { Some(owner) => model.frame_id(owner), None => SCREEN, @@ -1179,7 +273,7 @@ fn region_owner_id(model: &mut Model, rh: RegionHandle) -> u32 { /// Resolve a `SetPoint`/`SetAllPoints` `relativeTo` argument (a frame/region wrapper table, a frame /// name, or nil) to a layout id, defaulting to `owner` when absent/unresolved. -fn resolve_target(model: &mut Model, target: &Value, owner: u32) -> u32 { +pub(super) fn resolve_target(model: &mut Model, target: &Value, owner: u32) -> u32 { match target { Value::Table(t) => decode_id(t) .ok() @@ -1223,7 +317,7 @@ fn resolve_target(model: &mut Model, target: &Value, owner: u32) -> u32 { /// Bit-exact equality for a region's explicit size — the layout gate's own lens /// (`InputFingerprint::input` feeds `f32::to_bits`), so a setter's no-op detection and the gate /// can never disagree; see [`anchor_bits_eq`]. -fn size_bits_eq(a: Option<(f32, f32)>, b: Option<(f32, f32)>) -> bool { +pub(super) fn size_bits_eq(a: Option<(f32, f32)>, b: Option<(f32, f32)>) -> bool { match (a, b) { (None, None) => true, (Some((aw, ah)), Some((bw, bh))) => { @@ -1236,7 +330,12 @@ fn size_bits_eq(a: Option<(f32, f32)>, b: Option<(f32, f32)>) -> bool { /// `Region:SetPoint(point [, relativeTo [, relativePoint]] [, x, y])` — the region twin of /// [`super::object`]'s frame `SetPoint`, writing [`super::RegionData::anchors`]. The overload is /// disambiguated by argument *type* exactly as the frame version. -fn region_set_point(lua: &Lua, this: &Table, point: &str, rest: [Value; 4]) -> mlua::Result<()> { +pub(super) fn region_set_point( + lua: &Lua, + this: &Table, + point: &str, + rest: [Value; 4], +) -> mlua::Result<()> { let point = point_from_str(point) .ok_or_else(|| mlua::Error::runtime(format!("SetPoint: unknown point '{point}'")))?; let rh = region_handle_of(lua, this)?; @@ -1279,3 +378,26 @@ fn region_set_point(lua: &Lua, this: &Table, point: &str, rest: [Value; 4]) -> m } Ok(()) } + +/// The measured extent a FontString reports, falling back to an explicit `SetSize`. +/// +/// Hoisted out of the text cluster when this file split (0716): `GetStringWidth`/`GetStringHeight` +/// live in `region::text` and `GetWidth`/`GetHeight` in `region::layout`, and both read it. +pub(super) fn measured_wh(lua: &Lua, this: &Table) -> mlua::Result<(f32, f32)> { + let rh = region_handle_of(lua, this)?; + let model = lua.app_data_ref::().expect("model"); + let d = model.region_data.get(&rh); + // The key carries the owner's effective_scale ([`RegionData::measure_key`]) — the same + // recipe the request loop stamps, or every read under a SetScale'd owner reports stale. + let scale = model + .arena + .region(rh) + .and_then(|r| model.arena.frame(r.owner)) + .map(|f| f.effective_scale) + .unwrap_or(1.0); + let m = d.and_then(|d| d.measured.filter(|m| m.key == d.measure_key(scale))); + let size = d.and_then(|d| d.size); + let w = m.map(|m| m.w).or(size.map(|s| s.0)).unwrap_or(0.0); + let h = m.map(|m| m.h).or(size.map(|s| s.1)).unwrap_or(0.0); + Ok((w, h)) +} diff --git a/crates/benilla-ui/src/script/region/layout.rs b/crates/benilla-ui/src/script/region/layout.rs new file mode 100644 index 00000000..185aae67 --- /dev/null +++ b/crates/benilla-ui/src/script/region/layout.rs @@ -0,0 +1,155 @@ +//! Region method-table cluster: **layout** — size, anchors and the resolved-rect readers. +//! Split out of `region.rs` at the 0716 file-size budget. + +use mlua::{Lua, Table, Value}; + +use crate::layout::{Anchor, Point}; +use crate::script::object::anchor_bits_eq; +use crate::script::Model; + +/// Resolve `self` (a region wrapper) to its live [`RegionHandle`]. +use super::{ + measured_wh, region_handle_of, region_owner_id, region_set_point, resolve_target, size_bits_eq, +}; + +/// Populate `m`'s layout methods (see the module doc). +pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { + // Region explicit size (drawn centered on the owner; region anchors come later). + m.set( + "SetWidth", + lua.create_function(|lua, (this, w): (Table, f32)| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let d = model.region_data.entry(rh).or_default(); + let new = Some((w, d.size.map_or(0.0, |s| s.1))); + let changed = !size_bits_eq(d.size, new); + d.size = new; + if changed { + model.touch_layout(); + } + Ok(()) + })?, + )?; + + m.set( + "SetHeight", + lua.create_function(|lua, (this, h): (Table, f32)| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let d = model.region_data.entry(rh).or_default(); + let new = Some((d.size.map_or(0.0, |s| s.0), h)); + let changed = !size_bits_eq(d.size, new); + d.size = new; + if changed { + model.touch_layout(); + } + Ok(()) + })?, + )?; + + m.set( + "SetSize", + lua.create_function(|lua, (this, w, h): (Table, f32, f32)| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let d = model.region_data.entry(rh).or_default(); + let new = Some((w, h)); + let changed = !size_bits_eq(d.size, new); + d.size = new; + if changed { + model.touch_layout(); + } + Ok(()) + })?, + )?; + + m.set( + "GetWidth", + lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.0))?, + )?; + + m.set( + "GetHeight", + lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.1))?, + )?; + + // GetLeft/GetRight/GetTop/GetBottom — the region's RESOLVED edges (y-up UI units; frame twin + // in object.rs). An anchored region reads its own resolved rect; an unanchored one has no + // rect of its own (it draws relative to its owner at extract) → nil, same as pre-resolve. + for (name, pick) in [ + ("GetLeft", 0u8), + ("GetRight", 1u8), + ("GetTop", 2u8), + ("GetBottom", 3u8), + ] { + m.set( + name, + lua.create_function(move |lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model"); + Ok(model.region_resolved.get(&rh).map(|r| match pick { + 0 => r.left, + 1 => r.right, + 2 => r.top, + _ => r.bottom, + })) + })?, + )?; + } + + // Region anchors: SetPoint/ClearAllPoints/SetAllPoints mirror the frame versions + // ([`super::object`]) but write [`super::RegionData::anchors`]. An unspecified `relativeTo` + // defaults to the **owner frame**; a named one may be a frame or a sibling region (the real + // XML anchors regions to sibling regions everywhere — merchant label plate → `$parentSlot`). + m.set( + "SetPoint", + lua.create_function( + |lua, (this, p, a2, a3, a4, a5): (Table, String, Value, Value, Value, Value)| { + region_set_point(lua, &this, &p, [a2, a3, a4, a5]) + }, + )?, + )?; + + m.set( + "ClearAllPoints", + lua.create_function(|lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let d = model.region_data.entry(rh).or_default(); + let changed = !d.anchors.is_empty(); + d.anchors.clear(); + if changed { + model.touch_layout(); + } + Ok(()) + })?, + )?; + + m.set( + "SetAllPoints", + lua.create_function(|lua, (this, target): (Table, Value)| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let owner = region_owner_id(&mut model, rh); + let rel_id = resolve_target(&mut model, &target, owner); + let pair = [ + Anchor::new(Point::TopLeft, rel_id, Point::TopLeft, 0.0, 0.0), + Anchor::new(Point::BottomRight, rel_id, Point::BottomRight, 0.0, 0.0), + ]; + let data = model.region_data.entry(rh).or_default(); + let same = data.anchors.len() == 2 + && data + .anchors + .iter() + .zip(&pair) + .all(|(a, b)| anchor_bits_eq(a, b)); + if !same { + data.anchors.clear(); + data.anchors.extend_from_slice(&pair); + model.touch_layout(); + } + Ok(()) + })?, + )?; + Ok(()) +} diff --git a/crates/benilla-ui/src/script/region/paint.rs b/crates/benilla-ui/src/script/region/paint.rs new file mode 100644 index 00000000..20f66483 --- /dev/null +++ b/crates/benilla-ui/src/script/region/paint.rs @@ -0,0 +1,397 @@ +//! Region method-table cluster: **paint** — what a Texture shows and how it is tinted, +//! blended, cropped and layered. Split out of `region.rs` at the 0716 file-size budget. + +use mlua::{Lua, Table, Value}; + +use crate::script::object::{as_f32, draw_layer_from_str}; +use crate::script::{Model, TexCoords}; + +/// Resolve `self` (a region wrapper) to its live [`RegionHandle`]. +use super::region_handle_of; + +/// Populate `m`'s paint methods (see the module doc). +pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { + // SetAlpha/GetAlpha — the region's own alpha, distinct from the owner frame's. The ref kit reads + // it back to ramp a texture (CastingBarFrame's completion flash does + // `CastingBarFlash:SetAlpha(GetAlpha() + CASTING_BAR_FLASH_STEP)`), so the getter must return the + // region's value, never the frame's. Draw law + the open question: [`RegionData::alpha`]. + m.set( + "SetAlpha", + lua.create_function(|lua, (this, alpha): (Table, f32)| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + model.region_data.entry(rh).or_default().alpha = Some(alpha.clamp(0.0, 1.0)); + Ok(()) + })?, + )?; + + m.set( + "GetAlpha", + lua.create_function(|lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model"); + Ok(model + .region_data + .get(&rh) + .and_then(|d| d.alpha) + .unwrap_or(1.0)) + })?, + )?; + + m.set( + "SetVertexColor", + lua.create_function( + |lua, (this, r, g, b, a): (Table, f32, f32, f32, Option)| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let d = model.region_data.entry(rh).or_default(); + d.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]); + // The same slot `SetTextColor` writes on a FontString, so it is the same explicit + // colour set as far as font-object inheritance is concerned. + d.font_explicit.color = true; + Ok(()) + }, + )?, + )?; + + // GetVertexColor — the setter's own pair, a real 5875 binding (`0x79aa50`, wow-re + // `system/ui/ledger.tsv`; it sits directly above `SetVertexColor 0x79abd0` in the same region + // method family). Never set = the untinted white every region draws at by default. + m.set( + "GetVertexColor", + lua.create_function(|lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model"); + let c = model + .region_data + .get(&rh) + .and_then(|d| d.vertex_color) + .unwrap_or([1.0, 1.0, 1.0, 1.0]); + Ok((c[0], c[1], c[2], c[3])) + })?, + )?; + + // SetGradientAlpha(orientation, r1,g1,b1,a1, r2,g2,b2,a2) and its alpha-less twin + // SetGradient(orientation, r1,g1,b1, r2,g2,b2) — the two-stop linear gradient the client + // generates into the same `+0xcc` texture slot the colour form of SetTexture fills. + // + // These were missing, and they were the single wall in front of the corpus's largest family: + // `FuBar\FuBar_Panel.lua:144` calls SetGradientAlpha while building the bar, so all 20 FuBar + // plugins died there — after the debugstack/chunk-name fix got them that far. + // + // The orientation token is matched case-insensitively and anything that is not "VERTICAL" is + // horizontal, which is the client's own leniency and matters because addons spell it both ways. + // The gradient is stored WHOLE (see `RegionData::gradient`); the paint folds it to its midpoint + // because a quad carries one tint today. That approximation is visible and is recorded at the + // field, not hidden here. + for (name, with_alpha) in [("SetGradientAlpha", true), ("SetGradient", false)] { + m.set( + name, + lua.create_function(move |lua, args: mlua::MultiValue| { + let mut it = args.into_iter(); + let this: Table = match it.next() { + Some(Value::Table(t)) => t, + _ => return Err(mlua::Error::runtime("expected a region")), + }; + let orientation = match it.next() { + Some(Value::String(s)) => s.to_str()?.to_string(), + // A missing/!string orientation is horizontal, like any non-"VERTICAL" token. + _ => String::new(), + }; + let n = if with_alpha { 8 } else { 6 }; + let mut c = [0.0f32; 8]; + for slot in c.iter_mut().take(n) { + *slot = it.next().as_ref().map(as_f32).unwrap_or(0.0); + } + let (start, end) = if with_alpha { + ([c[0], c[1], c[2], c[3]], [c[4], c[5], c[6], c[7]]) + } else { + // SetGradient has no alpha stops: both ends are opaque. + ([c[0], c[1], c[2], 1.0], [c[3], c[4], c[5], 1.0]) + }; + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let d = model.region_data.entry(rh).or_default(); + d.gradient = Some(crate::script::Gradient { + vertical: orientation.eq_ignore_ascii_case("VERTICAL"), + start, + end, + }); + Ok(()) + })?, + )?; + } + + m.set( + "SetTexture", + // The trailing three are `Value`, not `Option`, and that is a fidelity fix rather than + // laxity. The path form reads ONE argument (`0x770200`); only the colour form + // (`0x770360`) reads up to four. A C function takes what it wants off the Lua stack and + // ignores the rest, so `SetTexture(path, true)` is fine on the real client — and typing + // these as `Option` made us raise on it, `bad argument #3: error converting Lua + // boolean to f32`, in a call the client accepts silently. + // + // Found by `_LazyPig/LazyPigMenu.lua:182` + // (`texture_title:SetTexture("Interface\DialogFrame\UI-DialogBox-Header", true)`), which + // reached us only once the survey started seating the addon registry — the whole point of + // that instrument fix. The stray `true` is meaningless in 1.12 and the addon author + // presumably meant a later client's second parameter; either way the client shrugs. + lua.create_function( + |lua, (this, arg, g, b, a): (Table, Value, Value, Value, Value)| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let data = model.region_data.entry(rh).or_default(); + // A plain SetTexture makes the region ordinary again — drop any portrait circular mask + // and any live-unit-portrait binding. + data.circular = false; + data.portrait_unit = None; + // Both forms write the SAME `+0xcc` texture slot — the path form loads a file + // there (`0x770200`), the colour form generates an 8×8 solid into it + // (`0x770360`) — so each clears the other. NEITHER touches the vertex colour at + // `+0xb8`: a tint outlives the art it was tinting. + match &arg { + // SetTexture("") clears, same as SetTexture(nil) — the ref lua blanks state + // art with the empty string (QuestLogFrame.lua:165-166). + Value::String(s) if s.to_str()?.is_empty() => { + data.texture = None; + data.fill = None; + } + Value::String(s) => { + data.texture = Some(s.to_str()?.to_string()); + data.fill = None; + } + // The colour form, and the ONLY branch that looks at the trailing three. A + // non-numeric there takes the same default a missing one does, which is what + // reading off a C stack does: `lua_tonumber` on a non-number yields 0. + Value::Number(_) | Value::Integer(_) => { + let chan = |v: &Value, dflt: f32| match v { + Value::Number(_) | Value::Integer(_) => as_f32(v), + Value::String(s) => s + .to_str() + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(dflt), + _ => dflt, + }; + data.fill = + Some([as_f32(&arg), chan(&g, 0.0), chan(&b, 0.0), chan(&a, 1.0)]); + data.texture = None; + } + // SetTexture(nil) clears (the live API's blank-the-region form); a cleared + // texture region draws nothing. + Value::Nil => { + data.texture = None; + data.fill = None; + } + _ => {} + } + Ok(()) + }, + )?, + )?; + + // SetDesaturated(flag) -> shaderSupported — Texture only (`0x79c1e0`, wow-re ledger; the + // reference's own `ItemButtonTemplate.lua:69` is `local shaderSupported = + // icon:SetDesaturated(desaturated)`). + // + // **The RETURN is the whole design, and it is the half a plausible implementation drops.** + // 1.12 ran on cards that could not do the shader, so the verb reports whether it took effect + // and FrameXML falls back by hand: + // + // if ( not desaturated ) then r,g,b = 1,1,1 + // elseif ( not r or not shaderSupported ) then r,g,b = 0.5,0.5,0.5 end + // icon:SetVertexColor(r, g, b) + // + // We have no desaturation in the renderer, so we answer **nil — unsupported**, which is a real + // 1.12 machine's answer and not a lie. Claiming support would suppress that grey fallback and + // leave disabled icons drawn at full colour: strictly worse-looking than saying no. When a + // desaturating shader lands, flip the return and this comment with it. + // + // Why it matters far past one verb: **98 of the 109 addons that draw and then raise on being + // used, raise here** — `FuBar_Panel.lua:43`'s right-click reaches Dewdrop's `AddLine` + // (`Dewdrop-2.0.lua:2172`), which calls `button.arrow:SetDesaturated(true)` unguarded. A + // static scan costed this at 61 addons and it was declined; the use-probe costed it at 98 the + // moment anyone right-clicks. + m.set( + "SetDesaturated", + lua.create_function(|lua, (this, flag): (Table, Value)| { + let on = !matches!(flag, Value::Nil | Value::Boolean(false)); + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + model.region_data.entry(rh).or_default().desaturated = on; + // nil, not false: the reference's `shaderSupported` is a 1|nil C answer and callers + // write `not shaderSupported`. + Ok(Value::Nil) + })?, + )?; + + // GetTexture() — Texture only (`0x79ba70`), verified in wow-re's widget-method batch + // (`system/ui/scratch/widget-api-batch-benilla.md`). Three contract details are each the kind a + // plausible implementation gets silently wrong, so each is spelled out: + // + // · **Exactly ONE return value**, never a multi-return. + // · **The colour form returns the literal string `"Solid Texture"`** (`0x835708`), NOT nil. + // `SetTexture(r,g,b)` synthesizes an 8x8 solid, and the getter reports that name — so an + // addon's `if not tex then` passes straight through on a colour-filled region. Returning nil + // here would look tidier and would be wrong in the one direction callers test for. + // · **The path is stripped at the LAST `.`** (`0x79baf0`): the loader appends `.blp`/`.tga` to + // what was set and the getter strips an extension back off. Taken verbatim rather than + // "strip only a real extension" — a directory containing a dot is mangled by the real client + // too, and this surface is a transcription, not an improvement. + // + // Four corpus addons: `AtlasQuest.lua:228` (`AQATLASMAP = AtlasMap:GetTexture()`) and + // `FuBarPlugin-2.0.lua:343` (`return self.iconFrame:GetTexture()`), each reached by two addons. + m.set( + "GetTexture", + lua.create_function(|lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let model = lua.app_data_mut::().expect("model"); + let Some(data) = model.region_data.get(&rh) else { + return Ok(None); + }; + if data.fill.is_some() { + return Ok(Some("Solid Texture".to_string())); + } + Ok(data.texture.as_ref().map(|t| match t.rfind('.') { + Some(i) => t[..i].to_string(), + None => t.clone(), + })) + })?, + )?; + + // SetPortraitToTexture(path) — set the texture AND mark the region a portrait (drawn masked to + // its inscribed circle). The client's portraits are circular (model or icon fallback); the frame + // ring is a thin band whose transparent corners would otherwise show the square texture's edges. + // The live API's `SetPortraitToTexture(texture, path)` is a global crop helper; ours is the + // region-method face of the same intent. This is the *icon/path* portrait — distinct from the + // `SetPortraitTexture(region, unit)` live model bake, so it drops any live-unit binding. + // `SetTexture` on the same region clears the flag (a plain texture again). + m.set( + "SetPortraitToTexture", + lua.create_function(|lua, (this, path): (Table, String)| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let data = model.region_data.entry(rh).or_default(); + data.texture = Some(path); + data.circular = true; + data.portrait_unit = None; + Ok(()) + })?, + )?; + + // SetAlphaGradient(start, length) — the per-character write-on reveal (CSimpleFontString; + // the quest-description machinery, ref QuestFrame.lua:548/558). Returns whether `start` is + // still inside the text (chars) — the ref's OnUpdate loop advances until this goes false. + m.set( + "SetAlphaGradient", + lua.create_function(|lua, (this, start, length): (Table, f32, f32)| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let data = model.region_data.entry(rh).or_default(); + data.alpha_gradient = Some((start, length)); + let chars = data.text.as_deref().map_or(0, |t| t.chars().count()); + Ok(start < chars as f32) + })?, + )?; + + // SetBlendMode("BLEND"|"ADD"|…) — the shared alphaMode enum (0x811aa8); only ADD changes + // draw behavior in v1 (DISABLE/ALPHAKEY/MOD are accepted as straight alpha, a stated gap). + m.set( + "SetBlendMode", + lua.create_function(|lua, (this, mode): (Table, String)| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + model.region_data.entry(rh).or_default().additive = mode.eq_ignore_ascii_case("ADD"); + Ok(()) + })?, + )?; + + m.set( + "SetDrawLayer", + lua.create_function(|lua, (this, layer, sub): (Table, String, Option)| { + let rh = region_handle_of(lua, &this)?; + let dl = draw_layer_from_str(&layer) + .ok_or_else(|| mlua::Error::runtime(format!("unknown draw layer '{layer}'")))?; + let mut model = lua.app_data_mut::().expect("model"); + if let Some(region) = model.arena.region_mut(rh) { + region.draw_layer = dl; + if let Some(s) = sub { + region.sub_level = s.clamp(i64::from(i8::MIN), i64::from(i8::MAX)) as i8; + } + } + Ok(()) + })?, + )?; + + // SetRotation(radians) — spin the texture about its center, counterclockwise-positive (the + // later-era Texture API, shipped early: the world-map player arrow's stand-in rotation — + // see `QuadContent::Texture::rotation`). No-arg/nil resets to 0. + m.set( + "SetRotation", + lua.create_function(|lua, (this, radians): (Table, Option)| { + let rh = region_handle_of(lua, &this)?; + lua.app_data_mut::() + .expect("model") + .region_data + .entry(rh) + .or_default() + .rotation = radians.unwrap_or(0.0); + Ok(()) + })?, + )?; + + // SetTexCoord(left, right, top, bottom) — the 4-edge form (XML ``): a UV sub-rect in + // 0..1 texture space (top-left origin) the Texture region samples, slicing quadrant/atlas art + // (decision 0084). SetTexCoord(ULx,ULy, LLx,LLy, URx,URy, LRx,LRy) — the 8-arg affine form: an + // arbitrary UV quad (rotation/shear — the reference's `DrawRouteLine` route lines), stored per + // corner in the renderer's screen winding. + m.set( + "SetTexCoord", + lua.create_function(|lua, (this, rest): (Table, mlua::Variadic)| { + let rh = region_handle_of(lua, &this)?; + let coords = match rest.len() { + 4 => Some(TexCoords::Rect([rest[0], rest[1], rest[2], rest[3]])), + // The live arg order is UL, LL, UR, LR (corner pairs); [`TexCoords::Corners`] + // stores screen order [TL, TR, BR, BL]. + 8 => Some(TexCoords::Corners([ + [rest[0], rest[1]], // UL → TL + [rest[4], rest[5]], // UR → TR + [rest[6], rest[7]], // LR → BR + [rest[2], rest[3]], // LL → BL + ])), + // No args resets to the full texture (the live API's clear form). + 0 => None, + n => { + return Err(mlua::Error::runtime(format!( + "SetTexCoord: expected 4 (edges) or 8 (corner pairs) args, got {n}" + ))) + } + }; + lua.app_data_mut::() + .expect("model") + .region_data + .entry(rh) + .or_default() + .tex_coords = coords; + Ok(()) + })?, + )?; + + // GetTexCoord() → left, right, top, bottom (the 4-edge form; full texture if never set; an + // affine mapping reports its bounding edges). + m.set( + "GetTexCoord", + lua.create_function(|lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model"); + let [l, r, t, b] = model + .region_data + .get(&rh) + .and_then(|d| d.tex_coords) + .map(|tc| tc.edges()) + .unwrap_or([0.0, 1.0, 0.0, 1.0]); + Ok((l, r, t, b)) + })?, + )?; + Ok(()) +} diff --git a/crates/benilla-ui/src/script/region/text.rs b/crates/benilla-ui/src/script/region/text.rs new file mode 100644 index 00000000..a7606750 --- /dev/null +++ b/crates/benilla-ui/src/script/region/text.rs @@ -0,0 +1,428 @@ +//! Region method-table cluster: **text** — a FontString's string, its faces, colour, shadow +//! and justification. Split out of `region.rs` at the 0716 file-size budget. +//! +//! **These duplicate names the `` object table also carries, deliberately for now**: a region +//! may override its inherited font object per-property, and the severance mask (`FontExplicit`) is +//! what keeps the two apart. Collapsing them onto one shared block is the follow-on 1231 named. + +use mlua::{Lua, Table, Value}; + +use crate::script::{JustifyH, JustifyV, Model, Outline}; + +/// Resolve `self` (a region wrapper) to its live [`RegionHandle`]. +use super::{measured_wh, region_handle_of}; + +/// Populate `m`'s text methods (see the module doc). +pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { + m.set( + "SetText", + lua.create_function(|lua, (this, text): (Table, Option)| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let data = model.region_data.entry(rh).or_default(); + data.text = text; + // Fresh text draws whole — an armed write-on gradient belongs to the old string. + data.alpha_gradient = None; + Ok(()) + })?, + )?; + + // SetFormattedText(fmt, ...) = SetText(format(fmt, ...)) — routed through the stdlib's + // positional-aware `format` so `%N$s` specs behave (a consensus call across the 0068 targets). + m.set( + "SetFormattedText", + lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| { + let format: mlua::Function = lua + .globals() + .get::
("string")? + .get::("format")?; + let text: String = format.call(args)?; + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let data = model.region_data.entry(rh).or_default(); + data.text = Some(text); + data.alpha_gradient = None; + Ok(()) + })?, + )?; + + m.set( + "GetText", + lua.create_function(|lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let text = { + let model = lua.app_data_ref::().expect("model"); + model.region_data.get(&rh).and_then(|d| d.text.clone()) + }; + match text { + Some(t) => Ok(Value::String(lua.create_string(&t)?)), + None => Ok(Value::Nil), + } + })?, + )?; + + // GetStringWidth/GetStringHeight (FontString): the host-measured text extent from the measure + // round-trip ([`super::UiScript::set_measured_text`]) — the client asks its font engine for the + // laid-out string's metrics exactly here (`fontstring.md`), and the tooltip's auto-size sums + // these to fit its lines. `0` until the string has been measured (a frame's latency; converges). + // The stored measure only counts while its key matches the CURRENT text/font/wrap + // ([`RegionData::measure_key`]): after a SetText the old string's width is not this string's + // metric — serving it is how the whisper header's `GetWidth()` latched the edit-box insets on + // the previous header's width. A poll-until-nonzero caller (the chat header machine) now + // converges on the RIGHT measure instead of settling on a stale one. + // `GetWidth`/`GetHeight` prefer the measured extent, falling back to an explicit `SetSize` — the + // real client's `SmallTextTooltipText:GetWidth()` idiom (ref-GameTooltip.xml l.63). + // GetStringWidth is the **natural, unwrapped** extent — never the declared box, and never the + // wrapped one (wow-re `fontstring-overflow.md`, "The measurement echo": the reference's getter + // re-measures the raw text with NO wrap constraint). Unlike `GetWidth` below it deliberately + // does NOT fall back to an explicit `SetSize`: the declared width is the very thing a caller + // asks this to be independent of. A kit that sizes a box from this number and then sets a width + // on the string — which is what the reference's own `PanelTemplates_TabResize` does — would + // otherwise read its own output back as its next input and never settle (decision 0997, the + // macro window's character tab changing width every frame). `0` until measured, as ever. + fn natural_w(lua: &Lua, this: &Table) -> mlua::Result { + let rh = region_handle_of(lua, this)?; + let model = lua.app_data_ref::().expect("model"); + let Some(d) = model.region_data.get(&rh) else { + return Ok(0.0); + }; + let scale = model + .arena + .region(rh) + .and_then(|r| model.arena.frame(r.owner)) + .map(|f| f.effective_scale) + .unwrap_or(1.0); + Ok(d.measured + .filter(|m| m.key == d.measure_key(scale)) + .map(|m| m.natural_w) + .unwrap_or(0.0)) + } + m.set( + "GetStringWidth", + lua.create_function(|lua, this: Table| natural_w(lua, &this))?, + )?; + + m.set( + "GetStringHeight", + lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.1))?, + )?; + + // SetJustifyH("LEFT"|"CENTER"|"RIGHT") — a FontString's horizontal justification (XML `justifyH`). + m.set( + "SetJustifyH", + lua.create_function(|lua, (this, j): (Table, String)| { + let rh = region_handle_of(lua, &this)?; + let jh = match j.to_ascii_uppercase().as_str() { + "LEFT" => JustifyH::Left, + "RIGHT" => JustifyH::Right, + _ => JustifyH::Center, + }; + let mut model = lua.app_data_mut::().expect("model"); + let d = model.region_data.entry(rh).or_default(); + d.justify_h = jh; + d.font_explicit.justify_h = true; + Ok(()) + })?, + )?; + + // SetJustifyV("TOP"|"MIDDLE"|"BOTTOM") — a FontString's vertical justification (XML `justifyV`). + m.set( + "SetJustifyV", + lua.create_function(|lua, (this, j): (Table, String)| { + let rh = region_handle_of(lua, &this)?; + let jv = match j.to_ascii_uppercase().as_str() { + "TOP" => JustifyV::Top, + "BOTTOM" => JustifyV::Bottom, + _ => JustifyV::Middle, + }; + let mut model = lua.app_data_mut::().expect("model"); + let d = model.region_data.entry(rh).or_default(); + d.justify_v = jv; + d.font_explicit.justify_v = true; + Ok(()) + })?, + )?; + + // SetFontObject(GameFontNormal) — re-point this FontString at a Font object: its resolved paint + // (face/height/color/outline/shadow) becomes the region's, and the link is kept live, so a later + // `GameFontNormal:SetFont(…)` re-paints this region too ([`super::font`]'s module doc). + // + // All three argument forms the reference's own usage string names (`.rdata 0x87c5cc`: + // `SetFontObject(font or "font" or nil)`) — the **object**, which is what 3,180 of the corpus's + // 3,186 call sites pass (`Gratuity-2.0.lua:57`, every FuBar/Ace label); a **name string**, for + // our own shipped XML and the 6 sites that use it; and **nil**, which severs the link. A frame, + // a number, or an unknown name is an error — never a silent no-op (1203/1205/1211's class). + m.set( + "SetFontObject", + lua.create_function(|lua, (this, font): (Table, Value)| { + let name = crate::script::font::resolve("SetFontObject", &font)?; + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + // The nil form: unlink, and leave the paint standing (the reference stores a null + // parent; nothing re-reads and nothing is cleared). + let Some(name) = name else { + model.region_data.entry(rh).or_default().font_object = None; + return Ok(()); + }; + let Some(fo) = model.font_objects.get(&name).cloned() else { + return Err(mlua::Error::runtime(format!( + "SetFontObject: no font object named '{name}' is registered" + ))); + }; + let d = model.region_data.entry(rh).or_default(); + d.font_object = Some(name); + // The severance mask is deliberately NOT reset here. §5-verified: the real "stop + // inheriting this property" signal is a CLEARED bit in the inheritMask at + // `FONTINSTANCE+0x2c` (FontString `+0xd4`, per-axis justify at `+0x124`), cleared by + // each local setter and never restored — "a FontString that set its own colour stays + // severed even across a later SetFontObject" (wow-re + // `system/ui/scratch/font-object-lua-surface.md`). This corrects our first cut, which + // reset it. + crate::script::font::repaint(d, &fo); + Ok(()) + })?, + )?; + + // GetFontObject() → the font OBJECT this FontString last resolved (or nil). + // + // The object, not its name: `Dewdrop-2.0.lua:2181` is + // `button.text:SetTextColor(button.text:GetFontObject():GetTextColor())` — 65 sites across 62 + // corpus addons that index the result immediately. A name string there raises. + m.set( + "GetFontObject", + lua.create_function(|lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let name = { + let model = lua.app_data_ref::().expect("model"); + model + .region_data + .get(&rh) + .and_then(|d| d.font_object.clone()) + .filter(|n| model.font_objects.contains_key(n)) + }; + match name { + Some(n) => Ok(Value::Table(crate::script::font::wrapper(lua, &n)?)), + None => Ok(Value::Nil), + } + })?, + )?; + + // SetNonSpaceWrap(enable) / CanNonSpaceWrap() — FontString only (`0x79e9f0` / `0x79ead0`). + // + // Two contract details from wow-re's batch, both easy to get wrong: + // · the getter is **`CanNonSpaceWrap`**, not `GetNonSpaceWrap`, and it answers **`1` or nil**, + // not a boolean — 1.12 predates that convention and an addon may compare against 1. + // · a **no-argument call ENABLES** it (the default is on), rather than being a query. + // + // `oRA2/Leader/Item.lua:561` is `f.textname:SetNonSpaceWrap(false)`, reached by two addons. + m.set( + "SetNonSpaceWrap", + lua.create_function(|lua, (this, enable): (Table, Value)| { + let on = match &enable { + Value::Nil => true, + Value::Boolean(b) => *b, + _ => true, + }; + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + model.region_data.entry(rh).or_default().non_space_wrap = Some(on); + Ok(()) + })?, + )?; + + m.set( + "CanNonSpaceWrap", + lua.create_function(|lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let model = lua.app_data_mut::().expect("model"); + let on = model + .region_data + .get(&rh) + .and_then(|d| d.non_space_wrap) + .unwrap_or(true); + Ok(if on { Some(1i64) } else { None }) + })?, + )?; + + // ── shadow, on the REGION ──────────────────────────────────────────────────────────────── + // These already existed on the font-object table; a FontString made by `CreateFontString` had + // none of them, and that is where the corpus calls them: + // `FuBar_NavigatorFu/NavigatorFu.lua:31` does + // `coordText:SetShadowColor(GameFontNormal:GetShadowColor())` — the GETTER on a font object, + // the SETTER on a fresh region — and `KLHThreatMeter/.../KTM_Gui.lua:404` is + // `fontstring:SetShadowColor(0,0,0,0.3)`. + // + // **`GetShadowColor` returns FOUR values, not three** (`0x79dd2f`, `mov eax,0x4` — wow-re's + // widget-method batch). Three is the plausible wrong answer and it silently drops the alpha + // that NavigatorFu is round-tripping. `GetShadowOffset` returns two, in UI units. + // + // Either half may be set before the other, so each starts from whatever is there — the same + // rule the font-object versions already follow. + m.set( + "SetShadowColor", + lua.create_function( + |lua, (this, r, g, b, a): (Table, f32, f32, f32, Option)| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let d = model.region_data.entry(rh).or_default(); + let offset = d.font_shadow.map_or([0.0, 0.0], |s| s.offset); + d.font_shadow = Some(crate::script::FontShadow { + offset, + color: [r, g, b, a.unwrap_or(1.0)], + }); + d.font_explicit.shadow = true; + Ok(()) + }, + )?, + )?; + + m.set( + "GetShadowColor", + lua.create_function(|lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let model = lua.app_data_mut::().expect("model"); + let c = model + .region_data + .get(&rh) + .and_then(|d| d.font_shadow) + .map_or([0.0, 0.0, 0.0, 1.0], |s| s.color); + Ok((c[0], c[1], c[2], c[3])) + })?, + )?; + + m.set( + "SetShadowOffset", + lua.create_function(|lua, (this, x, y): (Table, f32, f32)| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let d = model.region_data.entry(rh).or_default(); + let color = d.font_shadow.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color); + d.font_shadow = Some(crate::script::FontShadow { + offset: [x, y], + color, + }); + d.font_explicit.shadow = true; + Ok(()) + })?, + )?; + + m.set( + "GetShadowOffset", + lua.create_function(|lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let model = lua.app_data_mut::().expect("model"); + let o = model + .region_data + .get(&rh) + .and_then(|d| d.font_shadow) + .map_or([0.0, 0.0], |s| s.offset); + Ok((o[0], o[1])) + })?, + )?; + + // SetFont(path, height [, flags]) — the direct face/size/outline setter (the real region API and + // the XML `font=`/``/`outline=` join). `flags` is an OUTLINETYPE-ish string + // ("OUTLINE"/"THICKOUTLINE"/…"); anything else clears the outline. A nil/empty `path` keeps the + // current face (so a FontString with only `` retains its inherited object's font). + // Returns true (the live API returns whether the font loaded; we always accept — face + // availability is the renderer's concern). + m.set( + "SetFont", + lua.create_function( + |lua, (this, path, height, flags): (Table, Option, Option, Option)| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let d = model.region_data.entry(rh).or_default(); + // Each argument actually supplied is an EXPLICIT set: it must survive a later + // mutation of the font object this region inherits (`FontExplicit`). + if let Some(p) = path.filter(|p| !p.is_empty()) { + d.font_path = Some(p); + d.font_explicit.face = true; + } + if let Some(h) = height { + d.font_height = Some(h); + d.font_explicit.height = true; + } + if let Some(f) = flags { + d.outline = Outline::flags(&f); + d.font_explicit.outline = true; + } + Ok(true) + }, + )?, + )?; + + // SetTextHeight(height) — switch the FontString to the scaled-string regime (§5-verified, + // wow-re `fontstring-overflow.md`: `0x771600` is the ONLY clearer of the one-to-one bit + // `0x200`; the literal size then flows through UNCAPPED, magnified from the raster). Stored + // as the distinct [`RegionData::text_height`] — the font object is untouched, so GetFont + // keeps reporting the face's own height like the real API. + m.set( + "SetTextHeight", + lua.create_function(|lua, (this, height): (Table, f32)| { + let rh = region_handle_of(lua, &this)?; + lua.app_data_mut::() + .expect("model") + .region_data + .entry(rh) + .or_default() + .text_height = Some(height); + Ok(()) + })?, + )?; + + // GetFont() → path, height, flags — the resolved face/size/outline (nil path if never set). + m.set( + "GetFont", + lua.create_function(|lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model"); + let d = model.region_data.get(&rh); + let path = d.and_then(|d| d.font_path.clone()); + let height = d.and_then(|d| d.font_height); + let flags = d.map(|d| d.outline).unwrap_or_default().as_str(); + let path = match path { + Some(p) => Value::String(lua.create_string(&p)?), + None => Value::Nil, + }; + Ok((path, height, flags)) + })?, + )?; + + // SetTextColor(r, g, b [, a]) — a FontString's text color. A different binding name for the + // same `+0xb8` vertex-colour slot `SetVertexColor` writes: a FontString has no texel of its own + // to multiply against, so its vertex colour IS the colour it draws. + m.set( + "SetTextColor", + lua.create_function( + |lua, (this, r, g, b, a): (Table, f32, f32, f32, Option)| { + let rh = region_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + let d = model.region_data.entry(rh).or_default(); + d.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]); + d.font_explicit.color = true; + Ok(()) + }, + )?, + )?; + + // GetTextColor() → r, g, b, a — `SetTextColor`'s missing pair, and a real binding in the same + // FontInstance family. 11 corpus sites read it off a FontString directly (`CustomNameplates` + // re-tints a level tag from the name's colour; `TipBuddy` snapshots every tooltip line), on top + // of the 65 that reach it through `GetFontObject()`. Never set = the white every region draws + // at, same convention as `GetVertexColor` (the same slot). + m.set( + "GetTextColor", + lua.create_function(|lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model"); + let c = model + .region_data + .get(&rh) + .and_then(|d| d.vertex_color) + .unwrap_or([1.0, 1.0, 1.0, 1.0]); + Ok((c[0], c[1], c[2], c[3])) + })?, + )?; + Ok(()) +} diff --git a/crates/benilla-ui/src/script/tests/movable.rs b/crates/benilla-ui/src/script/tests/movable.rs index 905abfec..cfbf4eb5 100644 --- a/crates/benilla-ui/src/script/tests/movable.rs +++ b/crates/benilla-ui/src/script/tests/movable.rs @@ -348,3 +348,67 @@ fn the_xml_movable_and_resizable_attributes_reach_the_methods() { (true, true) ); } + +/// **`StartSizing(grip)` moves the gripped edges and plants the opposite ones.** +/// +/// `0x776830`, verified in wow-re's ledger; the reference's own caller is +/// `FloatingChatFrame.lua:600`. Four corpus addons reach it through ONE line — +/// `FuBar_Panel.lua:980`, replicated into FuBar_CorkFu, FuBar_FuXPFu, FuBar_SpellStatusFu and oRA2 +/// (1207: one library, not four votes). +/// +/// What the ledger does NOT record is which edges a grip moves — it has the verb as pure +/// orchestration with no inline math. Taken here as the plain meaning of an anchor point, which is +/// how the reference's caller uses it. This test is where that reading is pinned, so an RE pass +/// that contradicts it fails here first. +#[test] +fn start_sizing_moves_the_gripped_edge_and_plants_the_other() { + let mut s = crate::script::UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + s.run( + r#" + f = CreateFrame("Frame", "Sizer", UIParent) + f:SetResizable(true) + f:SetWidth(200) f:SetHeight(100) + f:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 100, 50) + "#, + ) + .unwrap(); + s.resolve(); + + // Grip the RIGHT edge and drag 40 right: width grows, the left edge stays planted. + s.mouse_move(300.0, 100.0); + s.run("Sizer:StartSizing(\"RIGHT\")").unwrap(); + s.mouse_move(340.0, 100.0); + s.resolve(); + assert_eq!(s.eval::("return Sizer:GetWidth()").unwrap(), 240.0); + assert_eq!(s.eval::("return Sizer:GetLeft()").unwrap(), 100.0); + s.run("Sizer:StopMovingOrSizing()").unwrap(); + + // Grip the LEFT edge and drag 30 right: width SHRINKS and the left edge follows the cursor, + // so the right edge is the one that stays put. + let right_before = s.eval::("return Sizer:GetRight()").unwrap(); + s.run("Sizer:StartSizing(\"LEFT\")").unwrap(); + s.mouse_move(370.0, 100.0); + s.resolve(); + assert_eq!(s.eval::("return Sizer:GetWidth()").unwrap(), 210.0); + assert_eq!(s.eval::("return Sizer:GetLeft()").unwrap(), 130.0); + assert_eq!( + s.eval::("return Sizer:GetRight()").unwrap(), + right_before, + "the ungripped edge must not move" + ); + + // StopMovingOrSizing ends it: further motion changes nothing. + s.run("Sizer:StopMovingOrSizing()").unwrap(); + s.mouse_move(500.0, 100.0); + s.resolve(); + assert_eq!(s.eval::("return Sizer:GetWidth()").unwrap(), 210.0); + + // A frame that is not resizable refuses, like the movable family's siblings. + s.run("g = CreateFrame(\"Frame\", \"NotSizer\", UIParent)") + .unwrap(); + assert!( + s.run("NotSizer:StartSizing(\"RIGHT\")").is_err(), + "StartSizing must refuse a frame that is not resizable" + ); +}