//! 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); } /// 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")), } } /// 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; 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))?, )?; // 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: