//! The `Button`/`CheckButton` method surface — per-kind behavior over the frame arena //! (`CSimpleButton` `0x6eeab0` / `CSimpleCheckbox` `0x6eeb30`). //! //! Grounded in wow-re's byte-verified LoadXML tables (RF-28): the four state textures //! (`Normal/Pushed/Disabled/Highlight`), the `ButtonText` fontstring + `text` attribute, the //! `OnClick` script slot (`+0x4cc`); CheckButton runs Button's loader first and adds //! `CheckedTexture`/`DisabledCheckedTexture` + the `checked` bool (`+0x4dc`). Which texture *shows* //! is **latched on the transition**, not resolved at paint: [`ButtonState::set_state`] is the //! client's `SetState 0x779790` and [`settle`] is where the derived inputs reach it. Two stated v1 //! gaps: the highlight draws with normal blending (the client ADD-blends it; the quad pass has no //! blend modes yet), and `PushedTextOffset` is not modeled. Per-state label fonts *are*: the //! `*FontObject` trio picks which object each state inherits and `extract` re-resolves it every //! frame, while `SetFont` writes the button's own face/size/flags over all of them //! ([`crate::widget::ButtonFont`]). //! //! Method resolution: CheckButton's table is consulted first, then Button's, then the shared frame //! table — mirroring the client's class chain, and keeping duck-typing honest (`frame.SetChecked` //! is nil on a plain Button; `frame.GetText` is nil on a plain Frame). use mlua::{Lua, MultiValue, ObjectLike, Table, Value}; use super::object::{as_f32, frame_handle_of}; use super::region::region_wrapper; use super::{event, JustifyH, Model, RegionData}; use crate::justify::Justify; use crate::order::DrawLayer; use crate::widget::{ ButtonFont, ButtonState, ButtonVisualState, FrameHandle, FrameKind, KindState, RegionHandle, RegionKind, }; pub(super) const REG_BUTTON_METHODS: &str = "__benilla_button_methods"; pub(super) const REG_CHECKBUTTON_METHODS: &str = "__benilla_checkbutton_methods"; /// The Button state-texture/text slots (each an arena region created on first set). #[derive(Clone, Copy, PartialEq, Eq)] enum Slot { Normal, Pushed, Disabled, Highlight, Checked, DisabledChecked, Text, } impl Slot { fn get(self, bs: &ButtonState) -> Option { match self { Slot::Normal => bs.normal, Slot::Pushed => bs.pushed, Slot::Disabled => bs.disabled, Slot::Highlight => bs.highlight, Slot::Checked => bs.checked_tex, Slot::DisabledChecked => bs.disabled_checked, Slot::Text => bs.text, } } /// The three STATE slots go through [`ButtonState::set_state_slot`] — the client's /// `0x778fd0` family, which stores the slot *and* pushes it to the shown pointer when it is /// the current state's. Every other slot is a plain field: the highlight, the checked pair /// and the label are not in the state array and have show rules of their own. fn set(self, bs: &mut ButtonState, rh: Option) { match self { Slot::Normal => bs.set_state_slot(ButtonVisualState::Normal, rh), Slot::Pushed => bs.set_state_slot(ButtonVisualState::Pushed, rh), Slot::Disabled => bs.set_state_slot(ButtonVisualState::Disabled, rh), Slot::Highlight => bs.highlight = rh, Slot::Checked => bs.checked_tex = rh, Slot::DisabledChecked => bs.disabled_checked = rh, Slot::Text => bs.text = rh, } } /// The slot's region kind + default draw layer: state textures under the text, the highlight /// in its own top layer (drawLayer table `0x811a84`), the checked marks above the state. fn shape(self) -> (RegionKind, DrawLayer) { match self { Slot::Normal | Slot::Pushed | Slot::Disabled => { (RegionKind::Texture, DrawLayer::Artwork) } Slot::Checked | Slot::DisabledChecked => (RegionKind::Texture, DrawLayer::Overlay), Slot::Highlight => (RegionKind::Texture, DrawLayer::Highlight), Slot::Text => (RegionKind::FontString, DrawLayer::Overlay), } } } /// The NORMAL embedded font's justify word — `[button+0x390]` (`+0x33c`, the normal `CSimpleFont`, /// plus its `+0x54` justify cell): the element-level `` when the template /// wrote one (a local write on that instance, severed from what it inherits), else what the /// instance inherits from its font object, else the ctor default (CENTER). The word the label /// adopter anchors by — decision 1996. fn normal_font_justify(model: &Model, bs: &ButtonState) -> Justify { let mut word = Justify::default(); let inherited = || { bs.normal_font .as_deref() .and_then(|n| model.font_object(n)) .and_then(|fo| fo.justify_h) }; if let Some(j) = bs.normal_justify_h.or_else(inherited) { word.set_h(j); } word } /// `CSimpleButton::SetFontString 0x778d20`'s tail — the ONE path a button label enters by, /// whether `SetText` made it (`0x778dc0` allocates, then calls this) or Lua handed one over /// (`SetFontString`): anchor it to the button **only if it has no anchor of its own** and **by the /// NORMAL font's justify word** (`[button+0x390]`: LEFT→LEFT, RIGHT→RIGHT, else CENTER — never /// the string's own `+0x120`, which on a fresh string is the ctor's CENTER), then apply the /// button's per-state font to it on the spot (`0x779810`). wow-re /// `system/ui/scratch/resize-bounds-and-button-fontstring.md` §5.2, every clause VERIFIED. /// /// The word's source is the whole bug this fixes (decision 1996): a `UIMenuButtonTemplate` row /// has no `` — its label is born from the reference's `UIMenu_AddButton` → /// `button:SetText(text)` — and its `` is /// the only thing that puts the label at the row's left edge. Reading the fresh string's own /// word instead seated every row CENTER, and with the shortcut string anchored RIGHT in a fixed /// 104-wide row, "Macro" ran into "/macro". fn adopt_label(model: &mut Model, owner: FrameHandle, rh: RegionHandle) { let point = { let Some(frame) = model.arena.frame(owner) else { return; }; let KindState::Button(bs) = &frame.kind_state else { return; }; super::region::justify_anchor_point(normal_font_justify(model, bs).0) }; super::region::anchor_unanchored_at(model, rh, point); apply_normal_font(model, owner); } /// Point the ButtonText at the button's NORMAL font — object and local justify — so the label's /// *query* surface answers what its paint already shows. The live link the reference's per-state /// applier `0x779810` → `0x770c60` installs: the instance's field block flows onto the label /// behind the label's own severance mask. /// /// Before this, the two disagreed. `extract` resolves the per-state font object every frame and /// overlays it onto a CLONE of the region's data (`extract.rs` l.114-122), so the label painted /// correctly while `region_data.font_object` stayed `None` — and a dropdown row's /// `GetFontObject()` answered **nil**, `GetFont()` answered **nil, nil**. Nothing errored: a /// FontString with no font of its own is a legal state, so this was invisible in exactly the way /// 1205's silent-drop class predicts. /// /// The measured consequence is smaller than it first looks, and that is stated rather than implied. /// The corpus's 65 `GetFontObject` sites are on FontStrings the addon created and linked itself /// (Dewdrop-2.0 calls `text:SetFontObject(GameFontHighlightSmall)` one line before it reads the /// object back), so none of them were reaching this. What WAS reaching it is the reference's own /// `DropDownList1` OnLoad, which derives `UIDROPDOWNMENU_DEFAULT_TEXT_HEIGHT` from /// `DropDownList1Button1NormalText:GetFont()` and got nil. /// /// **Normal only, deliberately.** The highlight and disabled objects are transient states that /// extract overlays at paint time; linking either here would make a resting button report its hover /// font. The remaining divergence, stated: while a button is disabled or hovered, the reference's /// label reports that state's font and ours still reports the normal one. The paint is unaffected /// either way, and no corpus site reads a label's font mid-state. /// /// `font::repaint` honours the severance mask, so a `` or `SetTextColor` the label set /// for itself survives the link (the rule wow-re pinned in `font-object-lua-surface.md`), and the /// local justify goes behind the same mask. fn apply_normal_font(model: &mut Model, owner: FrameHandle) { let (rh, name, local_justify) = { let Some(frame) = model.arena.frame(owner) else { return; }; let KindState::Button(bs) = &frame.kind_state else { return; }; let Some(rh) = bs.text else { return; }; (rh, bs.normal_font.clone(), bs.normal_justify_h) }; // An unregistered name is not an error here: `SetTextFontObject` already accepted it, and the // loader's own log-and-continue rule (0068) owns the reporting. let fo = name.as_deref().and_then(|n| model.font_object(n).cloned()); let d = model.region_data.entry(rh).or_default(); match (&name, fo) { (None, _) => d.font_object = None, (Some(_), None) => {} (Some(name), Some(fo)) => { d.font_object = Some(name.clone()); super::font::repaint(d, &fo); } } if let Some(j) = local_justify { if !d.font_explicit.justify_h { d.justify.set_h(j); } } model.touch_measure(rh); } /// Which of the button's three embedded font instances a `<…Font>` element writes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum LabelFont { Normal, Highlight, Disabled, } /// `` / `` / `` — the /// loader-facing write of a per-state embedded font's own justify (`CSimpleButton::LoadXML /// 0x7788c0` → the `` loader `0x783c30` on `+0x33c`/`+0x3b8`/`+0x434`: a ``-typed /// element, so the attribute lands on the instance itself, severed from whatever it inherits). /// No 1.12 Lua verb writes this — the API trio sets the OBJECT each instance inherits, never its /// local fields — which is why it is crate-internal rather than a method. A label already /// adopted keeps its anchor (the reference decided that at adoption) while its own justify word /// follows the normal instance, as the live link propagates the write. pub(crate) fn set_label_font_justify_h_lua( lua: &Lua, wrapper: &Table, which: LabelFont, j: JustifyH, ) -> mlua::Result<()> { let owner = frame_handle_of(lua, wrapper)?; with_button(lua, wrapper, |bs| match which { LabelFont::Normal => bs.normal_justify_h = Some(j), LabelFont::Highlight => bs.highlight_justify_h = Some(j), LabelFont::Disabled => bs.disabled_justify_h = Some(j), })?; if which == LabelFont::Normal { let mut model = lua.app_data_mut::().expect("model app_data"); apply_normal_font(&mut model, owner); } Ok(()) } /// Run `f` over a frame's Button state under one short write borrow. /// /// **It no longer settles a state machine afterwards, because there is no longer one to settle** /// (decision 2134). The button's state is a latch the engine's own edges write; a Lua write moves /// it only when the write IS an edge (`Enable`/`Disable`, `SetButtonState`), and those call the /// edge themselves. `RegisterForClicks` and the texture setters no longer disturb the state at /// all — which is the reference's behaviour: re-registering a held button's clicks does not /// un-press it. fn with_button( lua: &Lua, this: &Table, f: impl FnOnce(&mut ButtonState) -> T, ) -> mlua::Result { let h = frame_handle_of(lua, this)?; let mut model = lua.app_data_mut::().expect("model app_data"); let frame = model .arena .frame_mut(h) .ok_or_else(|| mlua::Error::runtime("stale frame handle"))?; match &mut frame.kind_state { KindState::Button(bs) => Ok(f(bs)), _ => Err(mlua::Error::runtime("not a Button")), } } /// `Enable()` / `Disable()` — and the second thing they do, which is not the state texture. /// /// `Disable 0x77ffd0` ends `0x78009a call [vtbl+0x90](0)`, reaching the shared helper /// `0x779160`; the constructor reaches the same helper with `1` (`0x778766`), which is what puts /// a fresh button in NORMAL. That helper does **two** things: /// /// - `[vtbl+0x9c](state, 0)` = `SetButtonState 0x779790` — [`ButtonState::set_enabled`]'s half, /// whose two early-outs (nothing happens if the button is already in the state being asked for) /// are the helper's own; and /// - `0x7791bb push 4; call 0x76a730` — the per-layer enable for layer **4 = HIGHLIGHT**, written /// into the very `[frame+0x198]` array that `Enable/DisableDrawLayer` writes and that /// `0x76b3a0` reads back at draw time. /// /// So a disabled button's highlight is switched off **at the layer**, which takes every region /// the frame owns there with it — not only the HighlightTexture. That is why the highlight is no /// longer gated on `enabled` inside [`ButtonState::region_visible`]: one mechanism, in the place /// the client keeps it, instead of a second rule that agreed with it on the common case and /// disagreed on `` art the button did not put there itself. /// /// **The restore is NOT symmetric, and this comment used to claim it was** — *"one array and one /// writer, so an addon's `DisableDrawLayer("HIGHLIGHT")` is undone by the next `Enable()`"*. Both /// halves are false (wow-re `scratch/button-disabled-state-texture-law.md` §7, sharpened /// 2026-09-09 off `0x779160` read contiguously for exactly this question). The indexed form /// `[reg + 4*reg + 0x198]` has **three** sites image-wide — `0x76a717` (=1), `0x76a737` (=0) and /// `DrawLayer 0x76b3a6`'s read — so there are **two** writers; and the enable arm calls /// *neither* helper. It reaches the ON one only one hop away and only when the button is the /// frame under the cursor: /// /// ```text /// 779183 mov ecx,[esi+0xa0] ; the CSimpleTop singleton /// 779189 cmp esi,[ecx+0x7c] ; am I the hover target? /// 77918c jne 0x7791ce ; no -> return, layer untouched /// 779192 call [edx+0x4c] ; = 0x779490 -> 0x76b6a0 -> 0x76b6c9 push 4; call 0x76a710 /// ``` /// /// — i.e. `Enable()` on a hovered button restores the layer **by re-running the frame's Lua /// ``**, and on a non-hovered one restores nothing (the next hover-enter does it). Both /// restore paths are gated on the LockHighlight flag `[frame+0xf8]`, and the disable arm's /// `0x7791bb call 0x76a730(4)` is the one site in the image that is *not* — so /// `LockHighlight()` → `Disable()` → `Enable()` leaves the layer off in the reference, and only /// `UnlockHighlight()`+`LockHighlight()` or an explicit `EnableDrawLayer` brings it back. /// /// **Ours is the symmetric bit-flip**, which draws the same in the ordinary case — a highlight is /// only visible while hovered, and every hover-enter sets it — and diverges in two script-visible /// ways: we do not fire the `` the reference synthesises when you `Enable()` a hovered /// button, and we restore a locked-then-disabled highlight the reference leaves dark. Named here /// rather than fixed, because the faithful shape needs the layer restore moved onto the hover /// path where the reference keeps it. fn set_enabled(lua: &Lua, this: &Table, on: bool) -> mlua::Result<()> { let h = frame_handle_of(lua, this)?; let mut model = lua.app_data_mut::().expect("model app_data"); let frame = model .arena .frame_mut(h) .ok_or_else(|| mlua::Error::runtime("stale frame handle"))?; // The state half is [`ButtonState::set_enabled`], whose two early-outs are the client's: // `Enable()` on a button that is not DISABLED does nothing at all, and neither does // `Disable()` on one that already is. match &mut frame.kind_state { KindState::Button(bs) => bs.set_enabled(on), _ => return Err(mlua::Error::runtime("not a Button")), }; let bit = 1u8 << DrawLayer::Highlight.index(); if on { frame.disabled_layers &= !bit; } else { frame.disabled_layers |= bit; } Ok(()) } /// Run one of the engine's own state edges over frame `h`, if it is a live Button. /// /// The reference's edge set is exactly seven `SetButtonState` call sites and **six of them are /// engine-side** (wow-re `scratch/button-state-edge-set.md`, VERIFIED): Enable, Disable, the hide /// notify, mouse-down, mouse-up and the drag-threshold crossing. There is **no enter or leave /// edge** — which is why the pointer path no longer calls anything here when the cursor crosses a /// button's boundary (decision 2134). /// /// Harmless on a non-Button handle, and on a stale one. pub(super) fn edge(model: &mut Model, h: FrameHandle, f: impl FnOnce(&mut ButtonState)) { if let Some(frame) = model.arena.frame_mut(h) { if let KindState::Button(bs) = &mut frame.kind_state { f(bs); } } } /// Does a hover edge on frame `h` reach the **base** enter/leave notify — the HIGHLIGHT layer and /// the Lua ``/`` script slots — or is it swallowed on the way there? /// /// `CSimpleButton::OnEnter 0x779490` and `OnLeave 0x7794e0` both open on the state word, and the /// branch skips the *whole* body except the sound: /// /// ```text /// 779493 mov eax,[esi+0x328] /// 779499 test eax,eax /// 77949b je 0x7794b0 ; DISABLED -> skip the base notify AND the label restyle /// 77949d call 0x76b6a0 ; CSimpleFrame::OnEnter — highlight layer on, at +0x140 /// ``` /// /// — so **a DISABLED Button runs neither script**, and `0x7794b0`'s hover sound is the only thing /// past the branch (wow-re `scratch/button-state-edge-set.md` §3.1, VERIFIED; the base pair's /// script slots `+0x140`/`+0x148` are `ledger.tsv:7944/7945`). Every path that delivers a hover /// edge dispatches through the object's own vtable, so there is no way around the guard: the hover /// walk (`0x766218`–`0x76623c`, `[vt+0x50]` then `[vt+0x4c]`), `SetMouseFocus 0x764dc0` /// (`0x764ddd push 0`), and the hide/removal tail (`0x764cce mov edx,[edi]; push 1; /// call [edx+0x50]`). `CheckButton` inherits both slots; the Hyperlink and NamePlate buttons /// override them and tail straight back into this pair (`0x7cb8a5`), so the guard covers the /// family. /// /// The hover **target** still moves — the walk reassigns `[root+0x7c]` *before* it fires either /// notify — so `GetMouseFocus()` answers a disabled button; it is the scripts alone that stay /// quiet. This is what makes AtlasLoot's unset QuickLook buttons safe on the reference: their /// `` guards with `if this:IsEnabled() then`, which is a NUMBER `1`/`0` and therefore /// always truthy (`scratch/button-enabled-state.md`), so the body would index a nil /// `QuickLooks[n]` — the reference never runs it at all. /// /// `None` (the cursor over no frame) and every non-Button kind notify normally. pub(super) fn hover_notify_runs(model: &Model, h: Option) -> bool { let Some(h) = h else { return true }; match model.arena.frame(h).map(|f| &f.kind_state) { Some(KindState::Button(bs)) => bs.enabled(), _ => true, } } /// Write [`ButtonState::loot_slot`] — `LootButton:SetSlot`'s whole body, kept here beside the /// other `ButtonState` writers rather than reaching into the arena from `script::loot`. pub(super) fn set_loot_slot(lua: &Lua, this: &Table, slot: Option) -> mlua::Result<()> { let h = frame_handle_of(lua, this)?; let mut model = lua.app_data_mut::().expect("model app_data"); let frame = model .arena .frame_mut(h) .ok_or_else(|| mlua::Error::runtime("stale frame handle"))?; match (&frame.kind, &mut frame.kind_state) { (crate::widget::FrameKind::LootButton, KindState::Button(bs)) => { bs.loot_slot = slot; Ok(()) } // The reference's own guard: `SetSlot` type-checks `this` against the LootButton tag via // vtable slot 4 (`0x4c18ee`), so calling it on a plain Button raises rather than writing a // field nothing would ever read. _ => Err(mlua::Error::runtime( "SetSlot: 'this' is not a LootButton widget", )), } } /// Get-or-create the region behind `slot`; returns its id. fn ensure_slot(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result { let h = frame_handle_of(lua, this)?; let mut model = lua.app_data_mut::().expect("model app_data"); let existing = match &model .arena .frame(h) .ok_or_else(|| mlua::Error::runtime("stale frame handle"))? .kind_state { KindState::Button(bs) => slot.get(bs), _ => return Err(mlua::Error::runtime("not a Button")), }; let rh = match existing { Some(rh) => rh, None => { let (kind, layer) = slot.shape(); let rh = model .arena .create_region(h, kind, layer, 0) .ok_or_else(|| mlua::Error::runtime("stale frame handle"))?; // The highlight draws ADD by contract (the live API's SetHighlightTexture default // blend; highlight art is authored for it — under straight alpha it reads as a // dark box over the icon). model.region_data.insert( rh, RegionData { blend: if slot == Slot::Highlight { crate::script::BlendMode::Add } else { crate::script::BlendMode::default() }, ..Default::default() }, ); model.touch_layout(); // a region entered the layout gate's read set (decision 0740) if let Some(frame) = model.arena.frame_mut(h) { if let KindState::Button(bs) = &mut frame.kind_state { slot.set(bs, Some(rh)); } } // A freshly built slot region gets its creation-path implicit anchor (decision 1310) // — an EXISTING slot region is never touched here; the get half of get-or-create // changes no geometry. Which anchor depends on the slot: the reference's C++ string // setters SetAllPoints a fresh state texture outright (`0x778f9d`/`0x7790db` — fresh // means zero anchors, so the conditional form is equivalent), while a fresh LABEL is // handed to the adopter (`SetText 0x778dc0` → `0x778d20`), which anchors it by the // button's normal font and links it (decision 1996). The XML loader re-derives after // applying authored `` (see `loader/widgets.rs`). match slot { Slot::Text => adopt_label(&mut model, h, rh), _ => super::region::implicit_creation_anchor(&mut model, rh), } rh } }; Ok(model.region_id(rh)) } /// Point `slot` at `rh` (or at nothing) and hand back whatever it held before, when that is a /// different region. The reference's slot store `0x778fd0` ends in the outgoing object's /// vtable-slot-0 dtor, so the caller frees it. fn swap_slot( model: &mut Model, h: FrameHandle, slot: Slot, rh: Option, ) -> mlua::Result> { let frame = model .arena .frame_mut(h) .ok_or_else(|| mlua::Error::runtime("stale frame handle"))?; let KindState::Button(bs) = &mut frame.kind_state else { return Err(mlua::Error::runtime("not a Button")); }; let outgoing = slot.get(bs).filter(|old| Some(*old) != rh); slot.set(bs, rh); model.touch_layout(); Ok(outgoing) } /// Free the region a slot just gave up — never the one that just moved in. fn free_outgoing(lua: &Lua, outgoing: Option, incoming: RegionHandle) { if let Some(old) = outgoing.filter(|old| *old != incoming) { super::region::free_region( &mut lua.app_data_mut::().expect("model app_data"), old, ); } } /// The shared body of `SetTexture(texture | "path" | nil)` / `(r, g, b [, a])`. /// /// The reference forks on `lua_type(L, 2)` into four legs (`0x781970`, carved instruction by /// instruction in wow-re `button-state-texture-path-setter.md` §1). **Two of them never touch the /// slot's own region at all**, so they are resolved *before* [`ensure_slot`] — which would /// otherwise lazily build a region for a call whose entire purpose is to replace or drop one. fn set_slot_texture(lua: &Lua, this: &Table, slot: Slot, args: &MultiValue) -> mlua::Result<()> { match args.front() { // **A widget object installs THAT region into the slot** (`0x781b0b`: `push the Texture // object; push idx; call 0x778fd0`) — it does not load a path into the slot's own region. // Three corpus addons build a highlight exactly this way and every one of them was a // silent no-op here: Bongos' bar-drag button (`bar.lua:64-71`), _Nameplates' mouseover // glow (`_Nameplates.lua:217-222`) and Quiver's option rows (`Quiver.bundle.lua:8949-53`) // each `CreateTexture` on the button, colour it, `SetAllPoints`, and hand it over. Some(Value::Table(t)) => { let rh = super::region::region_handle_of(lua, t)?; let h = frame_handle_of(lua, this)?; let mut model = lua.app_data_mut::().expect("model app_data"); // The reference type-checks the handed object and raises rather than installing a // FontString into a texture slot (`0x781ab1`, `0x87a1e0`). if model.arena.region(rh).map(|r| r.kind) != Some(RegionKind::Texture) { return Err(mlua::Error::runtime("Wrong object type, expected texture")); } let outgoing = swap_slot(&mut model, h, slot, Some(rh))?; drop(model); free_outgoing(lua, outgoing, rh); return Ok(()); } // **nil CLEARS the slot** (`0x781b5a`: `push 0; push idx; call 0x778fd0`, whose tail runs // the outgoing object's dtor). Clearing the pointer alone would be worse than doing // nothing: `ButtonState::region_visible` shows any region that is not one of the button's // own slots unconditionally, so an unhooked state texture would start drawing in *every* // state instead of none. `TheoryCraft/TheoryCraftUI.lua:215-217` strips a talent-rank // button's normal/pushed/highlight art with three of these. // // A call with NO argument is left alone: `LUA_TNONE` is not `LUA_TNIL`, and the // reference's own nil test is `0x781b4f call 0x6f3400 == 0`. Some(Value::Nil) => { let h = frame_handle_of(lua, this)?; let mut model = lua.app_data_mut::().expect("model app_data"); let outgoing = swap_slot(&mut model, h, slot, None)?; drop(model); if let Some(old) = outgoing { super::region::free_region( &mut lua.app_data_mut::().expect("model app_data"), old, ); } return Ok(()); } _ => {} } let id = ensure_slot(lua, this, slot)?; let mut model = lua.app_data_mut::().expect("model app_data"); let rh = *model.id_to_region.get(&id).expect("slot region id"); let data = model.region_data.entry(rh).or_default(); match args.front() { // `SetTexture("")` CLEARS — the live API's blank form (ref QuestLogFrame.lua:165 // clears the quest rows' +/- slot with exactly this), same as SetTexture(nil)/("") on a // plain region. Some(Value::String(s)) if s.to_str()?.is_empty() => { data.texture = None; data.fill = None; } Some(Value::String(s)) => { data.texture = Some(s.to_str()?.to_string()); data.fill = None; } // The colour form generates a solid texture into the same slot the path form loads into // ([`RegionData::fill`]) — never a tint, so each clears the other. Some(v @ (Value::Number(_) | Value::Integer(_))) => { let arg = |i: usize| args.get(i).map(as_f32); data.fill = Some([ as_f32(v), arg(1).unwrap_or(0.0), arg(2).unwrap_or(0.0), arg(3).unwrap_or(1.0), ]); data.texture = None; } _ => {} } Ok(()) } /// The shared body of `GetTexture` — the region wrapper, or nil while unset. fn get_slot_texture(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result { let rh = with_button(lua, this, |bs| slot.get(bs))?; let id = { let mut model = lua.app_data_mut::().expect("model app_data"); rh.map(|rh| model.region_id(rh)) }; match id { Some(id) => Ok(Value::Table(region_wrapper(lua, id)?)), None => Ok(Value::Nil), } } /// Register one `SetTexture`/`GetTexture` pair on `m`. fn texture_pair(lua: &Lua, m: &Table, name: &str, slot: Slot) -> mlua::Result<()> { m.set( format!("Set{name}Texture"), lua.create_function(move |lua, (this, args): (Table, MultiValue)| { set_slot_texture(lua, &this, slot, &args) })?, )?; m.set( format!("Get{name}Texture"), lua.create_function(move |lua, this: Table| get_slot_texture(lua, &this, slot))?, )?; Ok(()) } pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let m = lua.create_table()?; texture_pair(lua, &m, "Normal", Slot::Normal)?; texture_pair(lua, &m, "Pushed", Slot::Pushed)?; texture_pair(lua, &m, "Disabled", Slot::Disabled)?; texture_pair(lua, &m, "Highlight", Slot::Highlight)?; // SetText/GetText target the ButtonText fontstring (RF-28: the `text` attr routes there). m.set( "SetText", lua.create_function(|lua, (this, text): (Table, Option)| { let text = super::binding_abi::text_arg(lua, text)?; // `CSimpleButton::SetText 0x778dc0` opens `if (!text) return;` (`0x778dcc`, wow-re // `button-label-build-and-anchor-order.md`) — a nil never reaches the label at all, so // it neither clears the text nor lazily creates the FontString. That guard is the // BUTTON's own: a `FontString:SetText(nil)` is not a no-op, it truncates the cell // (`0x771d80`, decision 2110). Below the guard the button is a pass-through to // `[button+0x338]->0x771d80`. let Some(text) = text else { return Ok(()) }; let id = ensure_slot(lua, &this, Slot::Text)?; let mut model = lua.app_data_mut::().expect("model app_data"); let rh = *model.id_to_region.get(&id).expect("text region id"); model.region_data.entry(rh).or_default().text = Some(text); model.touch_measure(rh); Ok(()) })?, )?; // GetText — like the FontString's own (`region::text`, decision 2110), an **empty label reads // back nil**: `Button:GetText 0x780e10` carries the same first-byte substitution at `0x780ec5`, // over three nil conditions rather than two (no label region, a NULL cell, an empty cell). // Its EditBox neighbour deliberately does not — the law is per binding. m.set( "GetText", lua.create_function(|lua, this: Table| { let rh = with_button(lua, &this, |bs| bs.text)?; let text = { let model = lua.app_data_ref::().expect("model app_data"); rh.and_then(|rh| model.region_data.get(&rh)) .and_then(|d| d.text.clone()) .filter(|t| !t.is_empty()) }; match text { Some(t) => Ok(Value::String(lua.create_string(&t)?)), None => Ok(Value::Nil), } })?, )?; m.set( "GetFontString", lua.create_function(|lua, this: Table| get_slot_texture(lua, &this, Slot::Text))?, )?; // SetFontString(fontString) — ADOPT a caller-made FontString as this Button's label. // // Byte-carved end to end by wow-re (decision 1505, // `system/ui/scratch/resize-bounds-and-button-fontstring.md` // §5): the binding `0x780a60` is gates + a delegate to `CSimpleButton::SetFontString // 0x778d20`, which is the SAME function `SetText`'s lazy creation path funnels through — so // adopting and creating share their whole tail, and only the allocation differs. // // The idiom it exists for: build the label yourself so you can style and place it, then hand // it to the button so `SetText` and the per-state font machinery drive it. Quiver's // `Component/Select.wow.lua` builds every dropdown option row that way, and with the method // nil the row died on its first line — the second of B267's three walls. // // **It raises, in three distinct ways, and never silently no-ops on a bad argument** — the // first shape I built here was a lenient no-op and the bytes say otherwise: // // · a **missing** argument, `nil`, a number, a string or a boolean → `lua_type(L,2) != TABLE` // → `Usage: %s:SetFontString(fontstring)` (`0x87a100`). Missing and nil take the identical // leg (`index2adr` answers NULL → type −1), so `SetFontString(nil)` **cannot clear the // label from Lua**; the C++ clear exists but no binding reaches it. // · a **table that is not a widget** → `%s:SetFontString(): Couldn't find 'this' in // fontstring` (`0x87a160`). // · a **Frame or a Texture** — anything that is not a FontString → `%s:SetFontString(): // Wrong object type, expected fontstring` (`0x87a124`), gated by an `IsA` against the // FontString token, which a Texture fails. // // `%s` is the BUTTON's name (or ``), never the argument's. Zero return values. // // What `0x778d20` then does, all four clauses VERIFIED and all four here: // // · **`new == old` is a total no-op** — the first compare, before anything is touched. // · **the previous label is DESTROYED**, not orphaned (`old->vtable[0](1)`, the scalar // deleting destructor, returning the storage to the FontString pool). So a button whose // `SetText` already lazily made one does not leak a stray string behind the new label. // · **the string is RE-PARENTED to the button and its draw layer forced to ARTWORK** // (`0x77fd10(parent, 2, 1)` — layer id 2 in the client's own `.rdata 0x811a80` name table). // Always, whatever it was parented to or drawn in before. // · **it is anchored only if it has NO anchors of its own** (a scan of all nine // `anchorPoints` slots): LEFT/RIGHT/CENTER by the justify bits of the **button's normal // font** (`[button+0x390]`), to the matching point on the button — [`adopt_label`], the // same `& 7` → LEFT(1)/RIGHT(4)/else-CENTER chain the FontString post-step runs, over the // other word. (It used to reuse the post-step and read the string's own word; the two // only agree when the normal font has no justify — decision 1996.) // · **the button's per-state font is applied immediately** (`0x779810`) — the same // [`adopt_label`] links the label to the normal font, so its query surface answers. m.set( "SetFontString", lua.create_function(|lua, args: MultiValue| { let mut it = args.into_iter(); let this = match it.next() { Some(Value::Table(t)) => t, _ => return Err(mlua::Error::runtime("expected a button")), }; let who = { let h = frame_handle_of(lua, &this)?; let model = lua.app_data_ref::().expect("model app_data"); model .arena .frame(h) .and_then(|f| f.name.clone()) .unwrap_or_else(|| "".to_string()) }; let Some(Value::Table(fs)) = it.next() else { return Err(mlua::Error::runtime(format!( "Usage: {who}:SetFontString(fontstring)" ))); }; // The two remaining legs are DIFFERENT questions and the reference asks them in this // order: first "is this a framescript object at all" (`0x780b27`), then "is it a // FontString" (`0x780b90`'s `IsA` against the FontString token). A Frame or a Texture // passes the first and fails the second — so resolving straight to a region handle // would collapse both onto the first message and mislabel every widget argument. let rh = { let not_an_object = || { mlua::Error::runtime(format!( "{who}:SetFontString(): Couldn't find 'this' in fontstring" )) }; let id = super::object::decode_id(&fs).map_err(|_| not_an_object())?; let model = lua.app_data_ref::().expect("model app_data"); let known_widget = model.id_to_frame.contains_key(&id) || model.id_to_region.contains_key(&id); if !known_widget { return Err(not_an_object()); } match model.id_to_region.get(&id).copied() { Some(rh) if model.arena.region(rh).map(|r| r.kind) == Some(RegionKind::FontString) => { rh } _ => { return Err(mlua::Error::runtime(format!( "{who}:SetFontString(): Wrong object type, expected fontstring" ))) } } }; let old = with_button(lua, &this, |bs| bs.text)?; if old == Some(rh) { return Ok(()); } let owner = frame_handle_of(lua, &this)?; with_button(lua, &this, |bs| bs.text = Some(rh))?; let mut model = lua.app_data_mut::().expect("model app_data"); if let Some(dead) = old { // DESTROYED, not orphaned — the shared region-lifetime law, whose doc carries the // reason (`0x778d3c`'s scalar deleting destructor). super::region::free_region(&mut model, dead); } model.arena.set_region_owner(rh, Some(owner)); if let Some(r) = model.arena.region_mut(rh) { r.draw_layer = DrawLayer::Artwork; } adopt_label(&mut model, owner, rh); // A new label, a new owner and a possible new anchor — the layout's read set moved, // and the string's extents are what the button's own `GetTextWidth` reports. model.touch_layout(); model.touch_measure(rh); Ok(()) })?, )?; // GetTextWidth / GetTextHeight — the Button's OWN text-extent readers (`0x782290` / `0x782390`, // wow-re `system/ui/scratch/item9-firing34-merge.md` l.36 and the Button method carve in // `widget-api-batch-benilla.md` Q8, which lists both present on Button and `GetStringWidth` // absent). Both are thin forwards onto the label FontString's own extent vtable slots // (`0x1c` / `0x20`), which is exactly what this delegation is. // // **Who asks.** `Bagnon_Forever/database/ui.lua:61` sizes its character-switch dropdown from // `button:GetTextWidth() + 40` over every saved character — with the method nil the whole // dropdown died on the first row, so the director could not switch characters in Bagnon at all. // The idiom generalises: it is how a 1.12 kit fits a button to its label, and the reference's // own `MoneyFrame.lua` l.202 (`SetWidth(GetTextWidth() + iconWidth)`) is the same shape. // // **Which measurement**, since a FontString carries two. The label's `GetStringWidth` — the // NATURAL, unwrapped extent — never the laid-out `GetWidth`. A button label does not wrap, so // the two agree in the ordinary case; where they differ, serving the laid-out width would hand // every "size the button to its text" caller its own previous output as its next input, which // is decision 0997's measured feedback loop (the macro window's tab that changed width every // frame). The unwrapped extent is the one that settles. // // `0` before the host has measured the string (a frame's latency, as everywhere else), and `0` // for a Button with no label at all — the reference dereferences its FontString pointer `+0x338` // here where `SetFont`/`GetFont` deliberately do not, and what a null one does is NOT byte-read, // so this answers the harmless number rather than raising on a guess. for (name, region_getter) in [ ("GetTextWidth", "GetStringWidth"), // `GetHeight`, not a `GetStringHeight` — 1.12 has no such method (byte-verified absent // in every encoding), and the reference's own `Button:GetTextHeight 0x782390` is this // same call on the embedded FontString rather than a separate API. ("GetTextHeight", "GetHeight"), ] { m.set( name, lua.create_function(move |lua, this: Table| { let label = get_slot_texture(lua, &this, Slot::Text)?; match label { Value::Table(t) => t.call_method::(region_getter, ()), _ => Ok(0.0), } })?, )?; } // The per-state label fonts (the 1.12 API trio; XML `// // ` route here through the loader). Stored as font-object NAMES — extract // re-points the ButtonText to the current state's object each frame, so Enable/Disable and // hover swap the label's paint with no Lua involvement (the client's own behavior: // UIPanelButtonTemplate's gold/white/gray label states). // // Each takes the font OBJECT, its name, or nil, like `FontString:SetFontObject` — across the // trio the corpus splits 5 object-form to 4 string-form, so accepting one shape only would // break about half the callers, and nil clears the state back to the default. Because the // state is stored as a NAME and re-resolved at every `extract`, a later mutation of that font // object reaches these labels for free. m.set( "SetTextFontObject", lua.create_function(|lua, (this, font): (Table, Value)| { let name = super::font::resolve("SetTextFontObject", &font)?; let owner = frame_handle_of(lua, &this)?; with_button(lua, &this, |bs| bs.normal_font = name)?; let mut model = lua.app_data_mut::().expect("model app_data"); apply_normal_font(&mut model, owner); Ok(()) })?, )?; m.set( "SetHighlightFontObject", lua.create_function(|lua, (this, font): (Table, Value)| { let name = super::font::resolve("SetHighlightFontObject", &font)?; with_button(lua, &this, |bs| bs.highlight_font = name) })?, )?; m.set( "SetDisabledFontObject", lua.create_function(|lua, (this, font): (Table, Value)| { let name = super::font::resolve("SetDisabledFontObject", &font)?; with_button(lua, &this, |bs| bs.disabled_font = name) })?, )?; // SetFont(file, height [, flags]) / GetFont() — the Button's own font, `0x780880`/`0x79f3b0` // (wow-re `system/ui/scratch/widget-api-batch-benilla.md` Q8, §5-verified). // `_LazyPig/LazyPigMenu.lua:214` calls it straight on a `CreateFrame("Button", …)`, and it is // that addon's only blocker. // // Three contract details, each a plausible implementation's silent divergence: // // · **It returns ZERO values.** The shared impl `0x79f210` pushes `1`/nil (a real font-load // probe on a Font object — `!OmniCC` uses it), but `Button:SetFont` DISCARDS it // (`xor eax,eax`), so a failed font load is undetectable from a Button. Returning `true` // here would look harmless and would hand an addon a probe the client does not have. // · **It never touches the label.** `SetFont`/`GetFont` do not dereference the FontString // pointer `+0x338` at all, so styling a bare `CreateFrame("Button")` with no `` // is no error, no crash and **no lazy creation** — which is why the style is stored on the // button ([`ButtonState::font`]) and `extract` applies it to whatever label exists. A later // `SetText` (which *does* lazily create one, `0x778dc0`) then picks it up, so either call // order works, exactly as the reference's does. // · **It retunes all three embedded fonts** (normal `+0x33c`, disabled `+0x434`, highlight // `+0x3b8`) and `GetFont` reads back only the normal one — see [`ButtonFont`] for why one // record is the faithful shape for three identical writes. // // The argument gate is the shared impl's: arg2 `lua_isstring` and arg3 `lua_isnumber`, else // `luaL_error("Usage: %s:SetFont(\"font\", fontHeight [, flags])")` (`0x87c69c`). Both // predicates are the *coercing* ones in 5.0 — `lua_isstring` takes a number, `lua_isnumber` // takes a numeric string — so the leniency is transcribed rather than tightened. m.set( "SetFont", lua.create_function( |lua, (this, file, height, flags): (Table, Value, Value, Option)| { let usage = || { mlua::Error::runtime( "Usage: