//! 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 interaction state ([`ButtonState::region_visible`], applied at extract) — faithful to the //! documented widget model (texture array + current pointer `+0x4c4`), not byte-pinned. 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, Model, RegionData}; use crate::order::DrawLayer; use crate::widget::{ ButtonFont, ButtonState, 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, } } fn set(self, bs: &mut ButtonState, rh: crate::widget::RegionHandle) { match self { Slot::Normal => bs.normal = Some(rh), Slot::Pushed => bs.pushed = Some(rh), Slot::Disabled => bs.disabled = Some(rh), Slot::Highlight => bs.highlight = Some(rh), Slot::Checked => bs.checked_tex = Some(rh), Slot::DisabledChecked => bs.disabled_checked = Some(rh), Slot::Text => bs.text = Some(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), } } } /// Point the ButtonText at the button's NORMAL font object, so the label's *query* surface answers /// what its paint already shows. /// /// 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`). fn link_label_to_font_object(lua: &Lua, text: Option, name: Option<&str>) { let Some(rh) = text else { return }; let mut model = lua.app_data_mut::().expect("model app_data"); let Some(name) = name else { model.region_data.entry(rh).or_default().font_object = None; return; }; // 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 Some(fo) = model.font_object(name).cloned() else { return; }; let d = model.region_data.entry(rh).or_default(); d.font_object = Some(name.to_string()); super::font::repaint(d, &fo); model.touch_measure(rh); } /// Run `f` over a frame's Button state under one short write borrow. 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")), } } /// 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 { additive: slot == Slot::Highlight, ..Default::default() }, ); model.touch_layout(); // a region entered the layout gate's read set (decision 0740) // A freshly built slot region gets the creation-path implicit anchor (decision 1310): // the reference's C++ string setters SetAllPoints a fresh state texture outright // (`0x778f9d`/`0x7790db` — fresh means zero anchors, so the conditional form is // equivalent), and ButtonText creation runs the FontString post-step (`0x778b96` → // `0x771480`), which seats a fresh label CENTER. The XML loader re-derives after // applying authored `` (see `loader/widgets.rs`); an EXISTING slot region is // never touched here — the get half of get-or-create changes no geometry. super::region::implicit_creation_anchor(&mut model, rh); if let Some(frame) = model.arena.frame_mut(h) { if let KindState::Button(bs) = &mut frame.kind_state { slot.set(bs, rh); } } rh } }; Ok(model.region_id(rh)) } /// The shared body of `SetTexture(path)` / `(r, g, b [, a])`. fn set_slot_texture(lua: &Lua, this: &Table, slot: Slot, args: &MultiValue) -> mlua::Result<()> { 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 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 = text; model.touch_measure(rh); Ok(()) })?, )?; 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()) }; 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, to the matching point on // the button. That is exactly [`super::region::implicit_creation_anchor`]'s FontString arm, // which already transcribes the same `& 7` → LEFT(1)/RIGHT(4)/else-CENTER chain, so it is // reused rather than re-written. One stated difference: the reference reads the *button's* // Normal `CSimpleFont` justify word and ours reads the string's own — they agree in the // ordinary case, because our extract resolves the button's per-state font onto the label // every frame anyway. // // The fifth clause — apply the button's per-state font immediately — needs no code: our // extract re-points the label at the current state's font object every frame // ([`ButtonState::normal_font`]), so binding the label IS applying it. 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; } super::region::implicit_creation_anchor(&mut model, 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 text = with_button(lua, &this, |bs| { bs.normal_font.clone_from(&name); bs.text })?; link_label_to_font_object(lua, text, name.as_deref()); 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: