From 37998deccfa7d62d8ca72d99a9d61c3711f324f4 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 14 Aug 2026 18:50:00 +0700 Subject: [PATCH] text justification becomes one law across every font table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the justify verbs had been written three times and drifted three ways; one implementation now serves the region, font-object and message-frame tables, and a cleared axis paints centre, as the client's draw path really does - MessageFrame and ScrollingMessageFrame gain the ten-verb font block they were missing entirely, so font calls on message frames stop raising - EnableKeyboard and IsKeyboardEnabled land as real Frame methods — eight addons were dying on their absence - the harness's METHODS ranking states its own limits instead of reading as a build queue Co-Authored-By: Claude Fable 5 --- crates/benilla-app/examples/addon_harness.rs | 15 +- crates/benilla-ui/src/justify.rs | 318 ++++++++++++++++++ crates/benilla-ui/src/lib.rs | 1 + crates/benilla-ui/src/loader/geometry.rs | 11 +- crates/benilla-ui/src/loader/regions.rs | 13 +- crates/benilla-ui/src/script/editbox.rs | 6 +- .../benilla-ui/src/script/editbox/methods.rs | 19 +- crates/benilla-ui/src/script/extract.rs | 10 +- crates/benilla-ui/src/script/font.rs | 65 ++-- crates/benilla-ui/src/script/font_block.rs | 91 +++-- .../benilla-ui/src/script/messageframe/mod.rs | 49 ++- .../src/script/messageframe/plain.rs | 24 ++ .../src/script/messageframe/scrolling.rs | 24 ++ crates/benilla-ui/src/script/mod.rs | 1 + .../src/script/object/events_regions.rs | 5 +- .../src/script/object/frame_state.rs | 34 ++ crates/benilla-ui/src/script/region.rs | 40 +++ crates/benilla-ui/src/script/region/text.rs | 315 +++++------------ .../src/script/tests/font_object.rs | 267 ++++++++++++++- .../benilla-ui/src/script/tests/toplevel.rs | 25 +- crates/benilla-ui/src/script/tooltip/mod.rs | 4 +- crates/benilla-ui/src/script/tooltip/verbs.rs | 4 +- crates/benilla-ui/src/script/types.rs | 18 +- crates/benilla-ui/src/widget/kinds/editbox.rs | 43 +-- crates/benilla-ui/src/widget/mod.rs | 17 + crates/benilla-ui/src/widget/propagation.rs | 13 + 26 files changed, 1064 insertions(+), 368 deletions(-) create mode 100644 crates/benilla-ui/src/justify.rs diff --git a/crates/benilla-app/examples/addon_harness.rs b/crates/benilla-app/examples/addon_harness.rs index 7205e211..8c2ed19c 100644 --- a/crates/benilla-app/examples/addon_harness.rs +++ b/crates/benilla-app/examples/addon_harness.rs @@ -365,7 +365,20 @@ fn main() { // `--why ` now reads any row back by name. let methods = addon_harness::method_demand(&reports); if !methods.is_empty() { - println!("\n most-wanted missing METHODS (addons calling each as obj:Name(), no widget has it):"); + // The header states BOTH limits, because the list read as a build queue and is not one + // (decision 1240). It counts addons that NAME the verb, which is neither "addons blocked + // by it" nor "call sites": its top three were once RegisterTabCompletion/IsModule/ + // IsModuleActive — AceConsole-2.0's and AceAddon-2.0's own methods on their own objects, + // one library file replicated into 56 and 38 addons — and its fourth, EnableKeyboard, was + // a real absent Frame verb that was blocking none of its 8, every one of which died + // earlier and elsewhere. + println!("\n most-wanted missing METHODS — addons that NAME each as obj:Name(), and"); + println!(" no widget answers. NOT a blocker list and NOT a build queue:"); + println!(" · a big number is usually ONE library file replicated (1207/1210), and"); + println!( + " · a third-party library's own method on its own object is not ours to write." + ); + println!(" Open the call site before ranking it (decision 1240)."); for (name, count) in methods.into_iter().take(16) { println!(" {count:>4} {name}"); } diff --git a/crates/benilla-ui/src/justify.rs b/crates/benilla-ui/src/justify.rs new file mode 100644 index 00000000..ab0995e2 --- /dev/null +++ b/crates/benilla-ui/src/justify.rs @@ -0,0 +1,318 @@ +//! The justify enum — **one** transcription of `.rdata 0x811ad0`, shared by every table that +//! speaks it. +//! +//! ## Why this is a module and not three sets of `match` arms +//! +//! Justification is set on **three** widget tables — a `FontString` (`0x87c1d8`), a `` +//! object (`0x87c7c8`) and an `EditBox` — and each had been transcribed *independently* from the +//! same byte law. Each drifted its own way, and each looked right alone: +//! +//! | | FontString ([`crate::script::region`]) | Font ([`crate::script::font`]) | EditBox ([`crate::widget::kinds::editbox`]) | the bytes | +//! |---|---|---|---|---| +//! | `GetJustifyH`/`V` | **absent** | present | present | present (`0x79e5f0`/`0x79e7f0`) | +//! | space-padded token | rejected | `.trim()`ed, **accepted** | rejected | rejected (whole-string) | +//! | unmatched string | silently `CENTER` | silently `CENTER` | **raises** | **raises** | +//! | cross-axis token | silently `CENTER` | silently `CENTER` | **clears the axis** | clears the axis | +//! | axis storage | resolved enum → **the dword** (1239) | resolved enum | **the raw dword** | one dword | +//! +//! That is the seam class the addon-lane round trip named: halves transcribed separately from one +//! law drift where they meet, while every test on every side still passes. **The EditBox column is +//! the one that was right** — written last, straight from the bytes, and never consulted by the two +//! older tables. So this module is not new law: it is that column, lifted to where all three reach +//! it. It sits at the crate root rather than under `script` because `widget::kinds` needs it too. +//! +//! ## The law +//! +//! Storage is a single dword — `CSimpleFont+0x54`, `CSimpleFontString+0x120` — with **bits 0–2 +//! horizontal and bits 3–5 vertical**. `SetJustifyH 0x79fc20` computes `(cur ^ parsed) & 0x07 ^ +//! cur`, i.e. `(cur & !0x07) | (parsed & 0x07)`, replacing only its own axis (VERIFIED at +//! `0x79fc5d`–`0x79fc64`); `SetJustifyV 0x79fce0` uses mask `0x38`. The ctor default is `0x212` = +//! `CENTER | MIDDLE | 0x200`, bit 9 being outside both masks and read by neither accessor. +//! +//! Two consequences fall out of that arithmetic, and both are transcribed below: +//! +//! - **A cross-axis token is accepted and clears.** `SetJustifyH("TOP")` parses fine (`0x08`) but +//! `0x08 & 0x07 == 0`, so it erases the horizontal justification with **no error raised**, and a +//! following `GetJustifyH()` answers `"UNKNOWN"` — while the glyphs keep drawing **centred**. +//! The two readers genuinely disagree, which is why [`Justify`] stores the dword rather than a +//! resolved enum: [`Justify::name_h`] is the getter's answer and [`Justify::paint_h`] is the +//! draw path's, and they differ only in this state. +//! - **A non-token raises.** `0x6f1990` scans all six entries with `SStrCmpI` — whole-string, +//! case-insensitive, **no trimming** — and returns 0 on a miss, which is the caller's cue to +//! raise `Usage: %s:SetJustifyH("justify")` (`.rdata 0x87c77c`) / `…SetJustifyV…` +//! (`0x87c7a0`). Per `script::binding_abi`, that arm does not return: it abandons the caller's +//! statement. +//! +//! Recorded in wow-re `system/ui/scratch/font-object-lua-surface.md` §9.4 and, for the +//! fall-through and the unconditional inherit clear, `system/ui/scratch/justify-fallthrough-law.md` +//! (§5 quad, arbitrated). + +use crate::script::{JustifyH, JustifyV}; + +/// `.rdata 0x811ad0` — six `{u32 bits, const char* name}` entries, in image order. The order is +/// load-bearing twice over: `0x6f1990` scans it linearly, and `0x6f1a00` answers with the **first** +/// entry whose bit is set. +const TOKENS: [(u32, &str); 6] = [ + (0x01, "LEFT"), + (0x02, "CENTER"), + (0x04, "RIGHT"), + (0x08, "TOP"), + (0x10, "MIDDLE"), + (0x20, "BOTTOM"), +]; + +/// The literal `0x6f1a00` answers when no bit in the requested axis is set (`.data 0x838044`). +pub const UNKNOWN: &str = "UNKNOWN"; + +/// The horizontal axis — bits 0–2, `SetJustifyH`'s mask. +pub const H_MASK: u32 = 0x07; +/// The vertical axis — bits 3–5, `SetJustifyV`'s mask. +pub const V_MASK: u32 = 0x38; + +/// What a justify argument turned out to be — the three outcomes of `0x6f1990` followed by the +/// setter's mask. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Set { + /// A token carrying a bit on *this* axis: the new justification. + To(J), + /// A token that matched the table but carries no bit on this axis — `SetJustifyH("TOP")`. + /// The reference **clears** the axis and raises nothing; `GetJustifyH()` then answers + /// `"UNKNOWN"` while the glyphs keep drawing centred ([`Justify::paint_h`]). + /// + /// Not hypothetical: 13 corpus sites call `SetJustifyV("CENTER")` meaning "middle" + /// (`FonzAppraiser` ×12, `Roid-Macros`), which parses and then erases the vertical axis. + Clears, + /// No entry matched. The caller raises its own `Usage:` string; it does not coerce. + NoMatch, +} + +/// The ctor default — `CENTER | MIDDLE | 0x200`. Bit 9 is outside both axis masks and is read by +/// neither accessor nor the gx translator. `CSimpleFontString`'s **own** ctor writes it at +/// `0x770dd3`, so a FontString that has never been told otherwise reads `CENTER`/`MIDDLE`. +const CTOR_DEFAULT: u32 = 0x212; + +/// The justify **dword** — `CSimpleFont+0x54`, `CSimpleFontString+0x120`, `CSimpleEditBox`'s own — +/// with bits 0–2 horizontal and bits 3–5 vertical. +/// +/// Stored as the dword rather than a pair of resolved enums because **an axis with no bit set is a +/// real, reachable state** and a resolved enum cannot hold it: `SetJustifyH("TOP")` parses `0x08`, +/// contributes nothing to mask `0x07`, and erases the axis with no error raised. +/// +/// The two readers then disagree, faithfully, and that is the whole reason this type exists: +/// [`Justify::name_h`] (the Lua getter) answers `"UNKNOWN"`, while [`Justify::paint_h`] (the draw +/// path) answers `CENTER`. Anything that collapses the two loses one of them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Justify(pub u32); + +impl Default for Justify { + fn default() -> Self { + Justify(CTOR_DEFAULT) + } +} + +impl Justify { + /// Replace the horizontal bits — `(cur & !0x07) | (parsed & 0x07)`. + pub fn set_h(&mut self, j: JustifyH) { + self.0 = set_axis(self.0, H_MASK, bits_h(j)); + } + /// Replace the vertical bits — `(cur & !0x38) | (parsed & 0x38)`. + pub fn set_v(&mut self, j: JustifyV) { + self.0 = set_axis(self.0, V_MASK, bits_v(j)); + } + /// Erase the horizontal axis, as a cross-axis token does. + pub fn clear_h(&mut self) { + self.0 = set_axis(self.0, H_MASK, 0); + } + /// Erase the vertical axis. + pub fn clear_v(&mut self) { + self.0 = set_axis(self.0, V_MASK, 0); + } + + /// `GetJustifyH`'s answer — `0x6f1a00`, so `"UNKNOWN"` for a cleared axis. + pub fn name_h(self) -> &'static str { + name_of(self.0, H_MASK) + } + /// `GetJustifyV`'s answer. + pub fn name_v(self) -> &'static str { + name_of(self.0, V_MASK) + } + + /// What the glyphs actually draw at horizontally — the ui→gx translator `0x44d420`, whose one + /// call site image-wide is `0x772693`. + /// + /// **It is a priority ladder, not a switch, and the fall-through is `CENTER` — not `LEFT`.** + /// Each axis's result register is pre-set to `1` *between* the `test` and the `jcc` + /// (`0x44d4fb mov ebx,0x1`, then `0x44d516 je`), so an all-clear axis exits with `1` still in + /// it, and `1` is bit-identical to CENTER. A cleared axis is therefore visually + /// indistinguishable from the ctor default. + /// + /// **The trap, quoted from the finding:** the gx layer's `else` arms genuinely *are* LEFT and + /// BOTTOM, but both are unreachable from a FontString — `0x44d420` emits only `{0,1,2}` and + /// `0x5c1c30` rejects `>= 3`. *Mapping the ui bitmask onto the gx enum with a `0` default + /// inverts both axes.* + pub fn paint_h(self) -> JustifyH { + if self.0 & 0x04 != 0 { + JustifyH::Right + } else if self.0 & 0x01 != 0 { + JustifyH::Left + } else { + JustifyH::Center // 0x02 set, or NOTHING set — the pre-set `1` + } + } + + /// What the glyphs draw at vertically — the same ladder, `TOP > BOTTOM > MIDDLE`, falling + /// through to `MIDDLE`. `0x44d536`'s redundant `mov esi,0x1` on the MIDDLE arm is MSVC's own + /// tell that `default:` and `MIDDLE` share a constant. + pub fn paint_v(self) -> JustifyV { + if self.0 & 0x08 != 0 { + JustifyV::Top + } else if self.0 & 0x20 != 0 { + JustifyV::Bottom + } else { + JustifyV::Middle + } + } +} + +fn bits_h(j: JustifyH) -> u32 { + match j { + JustifyH::Left => 0x01, + JustifyH::Center => 0x02, + JustifyH::Right => 0x04, + } +} + +fn bits_v(j: JustifyV) -> u32 { + match j { + JustifyV::Top => 0x08, + JustifyV::Middle => 0x10, + JustifyV::Bottom => 0x20, + } +} + +/// `0x6f1990` — a linear scan of all six entries with `SStrCmpI`: **whole-string, +/// case-insensitive, and without trimming**, so `"left "` is a miss. +pub fn parse_bits(s: &str) -> Option { + TOKENS + .iter() + .find(|(_, name)| s.eq_ignore_ascii_case(name)) + .map(|(bits, _)| *bits) +} + +/// `0x6f1a00` — the **first** entry whose bit is set within `mask`, else the literal `UNKNOWN`. +pub fn name_of(bits: u32, mask: u32) -> &'static str { + TOKENS + .iter() + .find(|(b, _)| bits & mask & b != 0) + .map_or(UNKNOWN, |(_, name)| *name) +} + +/// Replace one axis's bits with `parsed`'s, masked to that axis — `0x79fc5d`'s +/// `(cur ^ parsed) & mask ^ cur`, i.e. `(cur & !mask) | (parsed & mask)`. This is the operation +/// that makes a cross-axis token *clear* rather than raise. +pub fn set_axis(cur: u32, mask: u32, parsed: u32) -> u32 { + (cur & !mask) | (parsed & mask) +} + +/// `SetJustifyH`'s argument, resolved against mask `0x07`. +pub fn parse_h(s: &str) -> Set { + match parse_bits(s) { + None => Set::NoMatch, + Some(0x01) => Set::To(JustifyH::Left), + Some(0x02) => Set::To(JustifyH::Center), + Some(0x04) => Set::To(JustifyH::Right), + Some(_) => Set::Clears, + } +} + +/// `SetJustifyV`'s argument, resolved against mask `0x38`. +pub fn parse_v(s: &str) -> Set { + match parse_bits(s) { + None => Set::NoMatch, + Some(0x08) => Set::To(JustifyV::Top), + Some(0x10) => Set::To(JustifyV::Middle), + Some(0x20) => Set::To(JustifyV::Bottom), + Some(_) => Set::Clears, + } +} + +/// `GetJustifyH`'s answer for a resolved horizontal justification, through the same table. +pub fn name_h(j: JustifyH) -> &'static str { + name_of(bits_h(j), H_MASK) +} + +/// `GetJustifyV`'s answer for a resolved vertical justification, through the same table. +pub fn name_v(j: JustifyV) -> &'static str { + name_of(bits_v(j), V_MASK) +} + +/// The `Usage:` string `SetJustifyH` raises on a miss (`.rdata 0x87c77c`). The `%s` is spelled as +/// the widget type, matching the shipped convention in `script::font_block`. +pub fn usage_h(widget: &str) -> mlua::Error { + mlua::Error::runtime(format!("Usage: <{widget}>:SetJustifyH(\"justify\")")) +} + +/// The `Usage:` string `SetJustifyV` raises on a miss (`.rdata 0x87c7a0`). +pub fn usage_v(widget: &str) -> mlua::Error { + mlua::Error::runtime(format!("Usage: <{widget}>:SetJustifyV(\"justify\")")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_match_is_case_insensitive_and_whole_string() { + assert_eq!(parse_h("LEFT"), Set::To(JustifyH::Left)); + assert_eq!(parse_h("left"), Set::To(JustifyH::Left)); + assert_eq!(parse_h("LeFt"), Set::To(JustifyH::Left)); + // `SStrCmpI` compares the whole string; the client does not trim, and neither do we — + // the Font-object path used to, which is the divergence this module exists to end. + assert_eq!(parse_h("left "), Set::NoMatch); + assert_eq!(parse_h(" left"), Set::NoMatch); + assert_eq!(parse_h("leftmost"), Set::NoMatch); + assert_eq!(parse_h(""), Set::NoMatch); + } + + #[test] + fn an_unmatched_string_does_not_coerce_to_center() { + // The whole point of the raise: the reference tells the addon it passed nonsense, where we + // used to silently answer CENTER on both tables. + assert_eq!(parse_h("MIDDLE_LEFT"), Set::NoMatch); + assert_eq!(parse_v("CENTERED"), Set::NoMatch); + } + + #[test] + fn a_cross_axis_token_matches_but_carries_no_bit_for_this_axis() { + for v in ["TOP", "MIDDLE", "BOTTOM"] { + assert_eq!(parse_h(v), Set::Clears, "SetJustifyH({v:?})"); + } + for h in ["LEFT", "CENTER", "RIGHT"] { + assert_eq!(parse_v(h), Set::Clears, "SetJustifyV({h:?})"); + } + } + + #[test] + fn every_token_round_trips_through_the_one_table() { + assert_eq!(name_h(JustifyH::Left), "LEFT"); + assert_eq!(name_h(JustifyH::Center), "CENTER"); + assert_eq!(name_h(JustifyH::Right), "RIGHT"); + assert_eq!(name_v(JustifyV::Top), "TOP"); + assert_eq!(name_v(JustifyV::Middle), "MIDDLE"); + assert_eq!(name_v(JustifyV::Bottom), "BOTTOM"); + } + + #[test] + fn the_formatter_is_first_bit_set_within_the_axis_else_unknown() { + // `0x6f1a00`'s two documented edges, neither reachable through our resolved enums but both + // part of the law being transcribed: no bit in the axis answers the literal UNKNOWN, and a + // dword with two bits set answers the *first* table entry rather than the largest. + assert_eq!(name_of(0x00, H_MASK), "UNKNOWN"); + assert_eq!(name_of(0x38, H_MASK), "UNKNOWN"); // vertical bits only + assert_eq!(name_of(0x05, H_MASK), "LEFT"); // LEFT | RIGHT + assert_eq!(name_of(0x30, V_MASK), "MIDDLE"); // MIDDLE | BOTTOM + // The ctor default `0x212` reads CENTER/MIDDLE, and bit 9 is invisible to both axes. + assert_eq!(name_of(0x212, H_MASK), "CENTER"); + assert_eq!(name_of(0x212, V_MASK), "MIDDLE"); + } +} diff --git a/crates/benilla-ui/src/lib.rs b/crates/benilla-ui/src/lib.rs index f993d822..ddbd3878 100644 --- a/crates/benilla-ui/src/lib.rs +++ b/crates/benilla-ui/src/lib.rs @@ -23,6 +23,7 @@ pub mod bindings_xml; pub mod framexml; +pub mod justify; pub mod layout; pub mod loader; pub mod markup; diff --git a/crates/benilla-ui/src/loader/geometry.rs b/crates/benilla-ui/src/loader/geometry.rs index 45f4e19b..589c532a 100644 --- a/crates/benilla-ui/src/loader/geometry.rs +++ b/crates/benilla-ui/src/loader/geometry.rs @@ -78,14 +78,11 @@ impl Loader<'_> { if el.attr_bool("toplevel") { self.call(wrapper, "SetToplevel", true, dbg); } - // Still a genuine gap — the window-behaviour surface (input arbitration, decision 0068). - // Flagged rather than silently dropped or faked: accepting an attribute without - // implementing it is strictly worse than saying so. + // `enableKeyboard="true"` — the XML half of the flag, which enables BOTH key kinds + // (`scripts-auto-enable.md` §1-2). The flag is now real and round-trips; key delivery is + // still not gated on it, which the method's own doc states rather than this warning. if el.attr_bool("enableKeyboard") { - self.warn_once( - "EnableKeyboard", - "`enableKeyboard` ignored: EnableKeyboard is not in the v1 object model (gap)", - ); + self.call(wrapper, "EnableKeyboard", true, dbg); } } diff --git a/crates/benilla-ui/src/loader/regions.rs b/crates/benilla-ui/src/loader/regions.rs index 1d402b5a..0fd1f137 100644 --- a/crates/benilla-ui/src/loader/regions.rs +++ b/crates/benilla-ui/src/loader/regions.rs @@ -265,7 +265,18 @@ impl Loader<'_> { .and_then(abs_value); let outline = region.attr("outline").map(str::to_string); if font.is_some() || height.is_some() || outline.is_some() { - self.call_region(wrapper, "SetFont", (font, height, outline), dbg); + // NOT the `SetFont` binding. XML supplies any subset of the three, while the + // reference's `SetFont` requires a path AND a height (raising `0x87c69c` otherwise), + // because the real client applies XML font attributes in C++ and never through the Lua + // method. Routing the loader through the binding is exactly what forced that binding to + // stay lenient — one name doing two jobs. This is the XML job. + if let Err(e) = + crate::script::apply_font_parts(self.lua, wrapper, font, height, outline) + { + self.report + .errors + .push(format!("{dbg}: region font attrs: {e}")); + } } } } diff --git a/crates/benilla-ui/src/script/editbox.rs b/crates/benilla-ui/src/script/editbox.rs index 7d7cf330..434b1476 100644 --- a/crates/benilla-ui/src/script/editbox.rs +++ b/crates/benilla-ui/src/script/editbox.rs @@ -480,12 +480,12 @@ pub(super) fn refresh_text_region_justify(lua: &Lua, this: &Table) -> mlua::Resu /// `` inherits the FontString CENTER/MIDDLE defaults and /// an empty focused box parks its caret mid-box (the mail send tab's original sin). fn apply_text_region_justify(data: &mut RegionData, multi_line: bool) { - data.justify_h = crate::script::JustifyH::Left; - data.justify_v = if multi_line { + data.justify.set_h(crate::script::JustifyH::Left); + data.justify.set_v(if multi_line { crate::script::JustifyV::Top } else { crate::script::JustifyV::Middle - }; + }); } // ── small shared helpers ───────────────────────────────────────────────────────────────────── diff --git a/crates/benilla-ui/src/script/editbox/methods.rs b/crates/benilla-ui/src/script/editbox/methods.rs index 4011baf0..dca0fb40 100644 --- a/crates/benilla-ui/src/script/editbox/methods.rs +++ b/crates/benilla-ui/src/script/editbox/methods.rs @@ -251,16 +251,21 @@ fn install_font_block(lua: &Lua, m: &Table) -> mlua::Result<()> { // Every font method acts on the box's implicit FontString: each binding's shim loads // `[this+0x324]` and hands it to the shared implementation, and that offset is // `EditBoxState::text_region`. Created on demand, exactly like every other text-touching path. - crate::script::font_block::install(lua, m, |lua, this| { - let h = frame_handle_of(lua, this)?; - super::ensure_text_region(lua, h).ok_or_else(|| mlua::Error::runtime("not an EditBox")) - })?; + crate::script::font_block::install( + lua, + m, + |lua, this| { + let h = frame_handle_of(lua, this)?; + super::ensure_text_region(lua, h).ok_or_else(|| mlua::Error::runtime("not an EditBox")) + }, + "EditBox", + )?; // SetJustifyH("LEFT"|"CENTER"|"RIGHT") / SetJustifyV("TOP"|"MIDDLE"|"BOTTOM") → 0 values. // - // Two verified traps, both of which a plausible implementation gets wrong, and both of which - // our older FontString/Font copies of these verbs do get wrong (they coerce anything unknown to - // CENTER/MIDDLE — noted, not fixed here): + // Two verified traps, both of which a plausible implementation gets wrong. This table was the + // only one that got them right; the FontString and `` copies coerced anything unknown to + // CENTER/MIDDLE until 1237 lifted this law into [`crate::justify`], which all three now share: // · an **unrecognised token RAISES** `Usage: %s:SetJustifyH("justify")` (`0x87c77c`), rather // than falling back to a default; // · a token from the **other axis** parses fine and then masks to nothing — `SetJustifyH("TOP")` diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index 2cc20a67..6d302929 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -250,10 +250,10 @@ impl UiScript { // The object's own justify (`` — // how the ref left-aligns a ButtonText). if let Some(j) = fo.justify_h { - data.justify_h = j; + data.justify.set_h(j); } if let Some(j) = fo.justify_v { - data.justify_v = j; + data.justify.set_v(j); } } // `Button:SetFont` sits BETWEEN the two: it is a local set on the button's own @@ -311,8 +311,10 @@ impl UiScript { QuadContent::Text { text: data.text, color: data.vertex_color, - justify_h: data.justify_h, - justify_v: data.justify_v, + // The gx translator's answer, not the getter's: a cleared axis draws + // CENTER/MIDDLE (`0x44d420`), where `GetJustifyH` says "UNKNOWN". + justify_h: data.justify.paint_h(), + justify_v: data.justify.paint_v(), font: data.font_path, font_height: data.font_height, text_height: data.text_height, diff --git a/crates/benilla-ui/src/script/font.rs b/crates/benilla-ui/src/script/font.rs index 418cce8a..d5d1d3b9 100644 --- a/crates/benilla-ui/src/script/font.rs +++ b/crates/benilla-ui/src/script/font.rs @@ -72,7 +72,9 @@ use mlua::{Lua, Table, Value}; use super::object::publish_global; -use super::{FontObject, FontShadow, JustifyH, JustifyV, Model, Outline, RegionData}; +use crate::justify; + +use super::{FontObject, FontShadow, Model, Outline, RegionData}; /// The shared metatable of every font-object handle. const REG_FONT_META: &str = "__benilla_font_meta"; @@ -210,12 +212,12 @@ pub(crate) fn repaint(d: &mut RegionData, fo: &FontObject) { } if !ex.justify_h { if let Some(j) = fo.justify_h { - d.justify_h = j; + d.justify.set_h(j); } } if !ex.justify_v { if let Some(j) = fo.justify_v { - d.justify_v = j; + d.justify.set_v(j); } } } @@ -335,15 +337,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { m.set( "SetFont", lua.create_function( - |lua, (this, path, height, flags): (Table, Option, Option, Option)| { - let ok = path.as_deref().is_some_and(|p| !p.is_empty()); + |lua, (this, file, height, flags): (Table, Value, Value, Option)| { + // The same gate the FontString and EditBox tables enter — one `0x79f210`, three + // entry points. This side used to take `Option`s, so `SetFont()` answered **nil** + // where the reference raises `0x87c69c`; the FontString's copy answered the boolean + // `true` for everything. Neither was the shared routine's contract. + let (path, height) = super::font_block::set_font_args(&file, &height, "Font")?; + let ok = !path.is_empty(); edit(lua, &this, |fo| { if ok { - fo.font = path; - } - if let Some(h) = height { - fo.height = Some(h); + fo.font = Some(path); } + fo.height = Some(height); if let Some(f) = flags { // The LUA flags spelling ("OUTLINE"/"THICKOUTLINE"), not the XML // attribute's ("NORMAL"/"THICK") — this read the XML one, so every @@ -454,13 +459,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { )?; // ── justification ─────────────────────────────────────────────────────────────────────── + // + // The token table, the whole-string match, the raise on a miss and the cross-axis clear are + // [`crate::justify`]'s, shared with the FontString table's identical pair. This side used to + // `.trim()` its argument and the other did not — one law, two transcriptions, and at most one + // of them could be right (the bytes say no trim: `SStrCmpI` compares the whole string). + // + // `justify_h`'s `Option` here is *inheritance*, not the reference's cleared axis: `None` means + // "this font object does not specify justification", which is what the resolve at + // [`resolve_font_object`] and `extract.rs` test. A fresh object therefore reads CENTER/MIDDLE + // through `unwrap_or_default`, which is also the client's ctor default `0x212`. m.set( "SetJustifyH", lua.create_function(|lua, (this, j): (Table, String)| { - let jh = match j.trim().to_ascii_uppercase().as_str() { - "LEFT" => JustifyH::Left, - "RIGHT" => JustifyH::Right, - _ => JustifyH::Center, + let jh = match justify::parse_h(&j) { + justify::Set::To(jh) => jh, + justify::Set::Clears => return Ok(()), + justify::Set::NoMatch => return Err(justify::usage_h("Font")), }; edit(lua, &this, |fo| fo.justify_h = Some(jh)) })?, @@ -468,20 +483,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { m.set( "GetJustifyH", lua.create_function(|lua, this: Table| { - Ok(match read(lua, &this)?.justify_h.unwrap_or_default() { - JustifyH::Left => "LEFT", - JustifyH::Center => "CENTER", - JustifyH::Right => "RIGHT", - }) + Ok(justify::name_h( + read(lua, &this)?.justify_h.unwrap_or_default(), + )) })?, )?; m.set( "SetJustifyV", lua.create_function(|lua, (this, j): (Table, String)| { - let jv = match j.trim().to_ascii_uppercase().as_str() { - "TOP" => JustifyV::Top, - "BOTTOM" => JustifyV::Bottom, - _ => JustifyV::Middle, + let jv = match justify::parse_v(&j) { + justify::Set::To(jv) => jv, + justify::Set::Clears => return Ok(()), + justify::Set::NoMatch => return Err(justify::usage_v("Font")), }; edit(lua, &this, |fo| fo.justify_v = Some(jv)) })?, @@ -489,11 +502,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { m.set( "GetJustifyV", lua.create_function(|lua, this: Table| { - Ok(match read(lua, &this)?.justify_v.unwrap_or_default() { - JustifyV::Top => "TOP", - JustifyV::Middle => "MIDDLE", - JustifyV::Bottom => "BOTTOM", - }) + Ok(justify::name_v( + read(lua, &this)?.justify_v.unwrap_or_default(), + )) })?, )?; diff --git a/crates/benilla-ui/src/script/font_block.rs b/crates/benilla-ui/src/script/font_block.rs index 294e1afd..7a4ba9d5 100644 --- a/crates/benilla-ui/src/script/font_block.rs +++ b/crates/benilla-ui/src/script/font_block.rs @@ -60,10 +60,13 @@ //! the reference's `SetTextColor` and `SetShadowColor` read r, g and b with a **bare `lua_tonumber`** //! (a non-number silently becomes `0.0`) and carry **no usage string at all**, so they *cannot //! raise* — `SetTextColor()` sets opaque black. Ours take `f32`, so a missing or non-numeric channel -//! errors instead. Alpha matches (`lua_isnumber`-gated, default 1.0). `script::region`'s and -//! `script::font`'s copies are strict in the same way, so correcting only this one would put two -//! answers in the codebase for one contract; it belongs with the collapse below. No corpus site is -//! known to depend on it. +//! errors instead. Alpha matches (`lua_isnumber`-gated, default 1.0). +//! +//! **The collapse below has now landed, which is what was blocking this**: the FontString and +//! EditBox tables both reach these two verbs *here*, so the correction is a one-place change rather +//! than three copies drifting. It is deliberately still not made, for a reason of its own: it turns +//! a loud error into a silent opaque black, which is faithful but is a real behaviour change and +//! belongs in its own landing with its own corpus A/B. No corpus site is known to depend on it. //! //! ## Where it lands, and why that is the mechanism rather than an approximation //! @@ -89,14 +92,18 @@ //! own state rather than on its text region, because our EditBox draw law forces the region's //! justification (see `script::editbox::methods`). They live there for that reason, not here. //! -//! ## The one duplicate this leaves, named rather than left to be found +//! ## Who installs this, and the duplicates it replaced //! -//! `script::region`'s FontString table still carries its own hand-written copies of these ten. They -//! are the same contract over the same fields and should collapse onto this module — one -//! `font_block::install(lua, &m, region_handle_of)` — when `region.rs` is split (it is over the -//! file-size budget, which is why that half is not done here). The collapse carries one correction -//! with it: that copy's `SetFont` answers the **boolean `true`**, where the bytes above say the -//! number `1` or nil. +//! Two tables: the `EditBox`'s (`script::editbox::methods`) and the **`FontString`'s** +//! (`script::region::text`). The FontString's used to hand-write its own copy of all ten; 1238 +//! collapsed them, deleting 226 lines and carrying the correction that copy had been hiding — its +//! `SetFont` answered the boolean `true`, where the bytes above say the number `1` or nil. +//! +//! The `` object's table (`script::font`) is **not** installed from here, and that is not an +//! oversight: it writes a [`FontObject`](super::FontObject) record rather than a +//! [`RegionData`](super::RegionData), so it shares the *marshalling* ([`set_font_args`]) rather +//! than the body. Its `SetFont` had drifted the other way — `Option` arguments, so `SetFont()` +//! answered nil where `0x87c69c` raises — which is why the gate is one function now. use mlua::{Lua, Table, Value}; @@ -110,11 +117,55 @@ use crate::widget::RegionHandle; /// returning an error is how a method called on the wrong receiver reports it. pub(super) type ResolveRegion = fn(&Lua, &Table) -> mlua::Result; +/// `SetFont(path, fontHeight [, flags])`'s shared **argument gate** — the entry of `0x79f210`, +/// which Font `0x7a0270`, FontString `0x79d4f0` and EditBox `0x797210` all reach. +/// +/// arg 2 must pass `lua_isstring` and arg 3 `lua_isnumber` — both of which coerce, so a numeric +/// string is accepted for either — else `Usage: %s:SetFont("font", fontHeight [, flags])` +/// (`.rdata 0x87c69c`). That arm is `luaL_error`, which longjmps, so it is an `Err` here and +/// **never** a nil answer (`super::binding_abi`). +/// +/// The returned path may be **empty**, which is the caller's *load-failure* edge rather than an +/// argument error: it answers nil with nothing raised, because `!OmniCC/main.lua:41`'s +/// `if not f:SetFont(saved, size) then revert end` is a font-file validity probe. The height is +/// still applied on that edge — only the face is rejected. +/// +/// Shared because it was not: the `` table took `Option, Option` and so answered +/// nil for `SetFont()` where the reference raises, while the FontString's copy answered the boolean +/// `true` for everything. One gate is the only way three entry points stay one routine. +pub(super) fn set_font_args( + file: &Value, + height: &Value, + widget: &str, +) -> mlua::Result<(String, f32)> { + let usage = || { + mlua::Error::runtime(format!( + "Usage: <{widget}>:SetFont(\"font\", fontHeight [, flags])" + )) + }; + let path = match file { + Value::String(s) => s.to_str()?.to_string(), + Value::Number(_) | Value::Integer(_) => as_f32(file).to_string(), + _ => return Err(usage()), + }; + let height = match height { + Value::Number(_) | Value::Integer(_) => as_f32(height), + Value::String(s) => s.to_str()?.parse::().map_err(|_| usage())?, + _ => return Err(usage()), + }; + Ok((path, height)) +} + /// Install the ten shared font-block methods onto `m`, reading `this` through `resolve`. /// /// The caller names the subset: this installs exactly the ten the module doc tabulates, and a /// widget whose real table lacks one of them must not use this installer for it. -pub(super) fn install(lua: &Lua, m: &Table, resolve: ResolveRegion) -> mlua::Result<()> { +pub(super) fn install( + lua: &Lua, + m: &Table, + resolve: ResolveRegion, + widget: &'static str, +) -> mlua::Result<()> { // ── the font object, and the live link to it ──────────────────────────────────────────── // SetFontObject(font | "font" | nil) → 0 values. All three argument forms the reference's own // usage string names (`.rdata 0x87c5cc`: `Usage: %s:SetFontObject(font or "font" or nil)`). @@ -187,21 +238,7 @@ pub(super) fn install(lua: &Lua, m: &Table, resolve: ResolveRegion) -> mlua::Res "SetFont", lua.create_function( move |lua, (this, file, height, flags): (Table, Value, Value, Option)| { - let usage = || { - mlua::Error::runtime( - "Usage: :SetFont(\"font\", fontHeight [, flags])".to_string(), - ) - }; - let path = match &file { - Value::String(s) => s.to_str()?.to_string(), - Value::Number(_) | Value::Integer(_) => as_f32(&file).to_string(), - _ => return Err(usage()), - }; - let height = match &height { - Value::Number(_) | Value::Integer(_) => as_f32(&height), - Value::String(s) => s.to_str()?.parse::().map_err(|_| usage())?, - _ => return Err(usage()), - }; + let (path, height) = set_font_args(&file, &height, widget)?; let rh = resolve(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); let d = model.region_data.entry(rh).or_default(); diff --git a/crates/benilla-ui/src/script/messageframe/mod.rs b/crates/benilla-ui/src/script/messageframe/mod.rs index 0deffef5..4a971447 100644 --- a/crates/benilla-ui/src/script/messageframe/mod.rs +++ b/crates/benilla-ui/src/script/messageframe/mod.rs @@ -60,6 +60,53 @@ pub(super) struct MessageFont { pub(super) justify_h: JustifyH, } +/// The FontString region a message frame's **style** lives on — found, or created on demand. +/// +/// Both classes draw their lines through one font instance, and [`UiScript::message_frame_font`] +/// reads it off the frame's first `FontString` region. That region normally comes from the +/// declared direct-child `` (LoadXML's "special" font string — our ChatFrame has one). +/// A frame built by `CreateFrame("MessageFrame")` has none, so the ten shared font-block verbs +/// need somewhere to write: this is that somewhere, and it is created **only** when one of them is +/// actually called, so a frame nobody styles never grows a region. +/// +/// **It is seated LEFT deliberately.** A frame with no declared FontString falls back to LEFT (the +/// chat shape) per [`MessageFont::justify_h`]'s law — but a freshly created `RegionData` defaults +/// to the *FontString* default of CENTER. Creating the region at the default would silently +/// re-justify a frame the moment an addon touched its font, which is a visible change nobody +/// asked for. +pub(super) fn ensure_font_region( + lua: &mlua::Lua, + fh: FrameHandle, +) -> Option { + let mut model = lua.app_data_mut::().expect("model app_data"); + let existing = model.arena.frame(fh).and_then(|frame| { + frame + .regions + .iter() + .find(|&&rh| { + matches!( + model.arena.region(rh).map(|r| r.kind), + Some(crate::widget::RegionKind::FontString) + ) + }) + .copied() + }); + if existing.is_some() { + return existing; + } + let rh = model.arena.create_region( + fh, + crate::widget::RegionKind::FontString, + crate::order::DrawLayer::Overlay, + 0, + )?; + let mut data = crate::script::RegionData::default(); + data.justify.set_h(JustifyH::Left); + model.region_data.insert(rh, data); + model.touch_layout(); + Some(rh) +} + impl UiScript { pub(super) fn message_frame_font(model: &Model, fh: FrameHandle) -> MessageFont { model @@ -81,7 +128,7 @@ impl UiScript { height: d.font_height, shadow: d.font_shadow, outline: d.outline, - justify_h: d.justify_h, + justify_h: d.justify.paint_h(), }) }) .unwrap_or(MessageFont { diff --git a/crates/benilla-ui/src/script/messageframe/plain.rs b/crates/benilla-ui/src/script/messageframe/plain.rs index 43eba069..50cb4851 100644 --- a/crates/benilla-ui/src/script/messageframe/plain.rs +++ b/crates/benilla-ui/src/script/messageframe/plain.rs @@ -170,6 +170,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { lua.create_function(|lua, this: Table| with_mf(lua, &this, |mf| mf.fade_duration))?, )?; + // ── the shared font block ─────────────────────────────────────────────────────────────── + // + // `Set/GetFontObject · Set/GetFont · Set/GetTextColor · Set/GetShadowColor · + // Set/GetShadowOffset` are real entries on this class's table, not a courtesy. wow-re's + // registrar carve is explicit about the membership — *"Exposed on: FontString, Font object, + // EditBox, MessageFrame, ScrollingMessageFrame, SimpleHTML. NOT on Button"* — and names this + // class's own shims calling the shared implementations (`GetShadowColor 0x794810`). We shipped the block on two + // of the six and this is the third and fourth; SimpleHTML is a widget kind we do not have at + // all. + // + // Demand is observed, not counted: `BigWigs/Plugins/Messages.lua:212` is + // `self.msgframe:SetFontObject(GameFontNormalLarge)` on a frame it has just given + // `SetInsertMode("TOP")`, and BigWigs dies there every session. + crate::script::font_block::install( + lua, + &m, + |lua, this| { + let h = frame_handle_of(lua, this)?; + super::ensure_font_region(lua, h) + .ok_or_else(|| mlua::Error::runtime("not a MessageFrame")) + }, + "MessageFrame", + )?; + lua.set_named_registry_value(REG_MESSAGEFRAME_METHODS, m)?; Ok(()) } diff --git a/crates/benilla-ui/src/script/messageframe/scrolling.rs b/crates/benilla-ui/src/script/messageframe/scrolling.rs index a15cfe02..d15d33c7 100644 --- a/crates/benilla-ui/src/script/messageframe/scrolling.rs +++ b/crates/benilla-ui/src/script/messageframe/scrolling.rs @@ -200,6 +200,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.fade_duration))?, )?; + // ── the shared font block ─────────────────────────────────────────────────────────────── + // + // `Set/GetFontObject · Set/GetFont · Set/GetTextColor · Set/GetShadowColor · + // Set/GetShadowOffset` are real entries on this class's table, not a courtesy. wow-re's + // registrar carve is explicit about the membership — *"Exposed on: FontString, Font object, + // EditBox, MessageFrame, ScrollingMessageFrame, SimpleHTML. NOT on Button"* — and names this + // class's own shims calling the shared implementations (`GetShadowColor 0x792240`). We shipped the block on two + // of the six and this is the third and fourth; SimpleHTML is a widget kind we do not have at + // all. + // + // Demand is observed, not counted: `BigWigs/Plugins/Messages.lua:212` is + // `self.msgframe:SetFontObject(GameFontNormalLarge)` on a frame it has just given + // `SetInsertMode("TOP")`, and BigWigs dies there every session. + crate::script::font_block::install( + lua, + &m, + |lua, this| { + let h = frame_handle_of(lua, this)?; + super::ensure_font_region(lua, h) + .ok_or_else(|| mlua::Error::runtime("not a ScrollingMessageFrame")) + }, + "ScrollingMessageFrame", + )?; + lua.set_named_registry_value(REG_SCROLLINGMESSAGEFRAME_METHODS, m)?; // SubmitChatInput(text) — the chat input EditBox's OnEnterPressed hands the typed line here; it diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index f7c9c8fa..0083aae5 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -153,6 +153,7 @@ pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo}; pub use pet::{PetActionView, PetStats}; pub use quest::{QuestAction, QuestItemView, QuestPanel, QuestSelect, QuestState}; pub use quest_log::{QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState}; +pub(crate) use region::apply_font_parts; pub use session::SessionRequest; pub use shapeshift::ShapeshiftFormView; pub use skills::{SkillEntry, SkillsState}; diff --git a/crates/benilla-ui/src/script/object/events_regions.rs b/crates/benilla-ui/src/script/object/events_regions.rs index 8a63da3a..77ef1b2d 100644 --- a/crates/benilla-ui/src/script/object/events_regions.rs +++ b/crates/benilla-ui/src/script/object/events_regions.rs @@ -173,8 +173,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { /// * **`OnKeyDown` / `OnKeyUp` / `OnChar`** (14 + 1 + 4 corpus sites over 13 addons) — **raising, /// and now unblocked**: the delivery law was the second §5 this work dispatched, and it landed /// (wow-re `scratch/frame-key-script-delivery.md`). benilla still has none of the machinery it -/// describes — no `EnableKeyboard`, no keyboard index, keys routed straight to the focused -/// EditBox — so the names stay out until it exists, which is the whole rule above. What has to +/// describes — `EnableKeyboard`/`IsKeyboardEnabled` now exist and the flag round-trips, but there +/// is still no keyboard index and no strata walk, and keys are routed straight to the focused +/// EditBox — so the names stay out until that exists, which is the whole rule above. What has to /// get built, so the next pass does not have to re-derive it: /// /// The frame must be in the hit-test root's **kind-0 / kind-1 bucket** diff --git a/crates/benilla-ui/src/script/object/frame_state.rs b/crates/benilla-ui/src/script/object/frame_state.rs index 04e50ace..6b4caabb 100644 --- a/crates/benilla-ui/src/script/object/frame_state.rs +++ b/crates/benilla-ui/src/script/object/frame_state.rs @@ -409,6 +409,40 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { Ok(model.arena.is_mouse_enabled(h)) })?, )?; + // EnableKeyboard(flag) / IsKeyboardEnabled() — `0x776f90` / `0x776ff0`, real Frame entries in + // wow-re's frame-API carve, beside the mouse pair above. + // + // 8 corpus addons call this and were RAISING on it (`ColorPickerPlus:258`, + // `Dewdrop-2.0.lua:2021-2022` — which is in ~65 addons — `AckisRecipeList/ARLFrame.lua:1650`). + // The flag round-trips; **key delivery is not gated on it**, exactly as the wheel's flag + // shipped in 1198, because the machinery it would gate does not exist here yet. + // + // Why that is the right half rather than a silent stub, which this codebase has been bitten by + // three times (1203/1205/1211): the majority of the corpus calls pass **false**, and for those + // our behaviour is already the reference's — we deliver no keys to arbitrary frames either. The + // `true` callers want delivery we do not do, but they did not get it before this landed; they + // got a raise that killed the enclosing function. And per + // `frame-key-script-delivery.md` §3.2 being enabled genuinely is separable from having a + // handler, so the flag is a real piece of the model, not a placeholder for one. + m.set( + "EnableKeyboard", + lua.create_function(|lua, (this, enable): (Table, bool)| { + let h = frame_handle_of(lua, &this)?; + lua.app_data_mut::() + .expect("model") + .arena + .set_keyboard_enabled(h, enable); + Ok(()) + })?, + )?; + m.set( + "IsKeyboardEnabled", + lua.create_function(|lua, this: Table| { + let h = frame_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model"); + Ok(model.arena.is_keyboard_enabled(h)) + })?, + )?; // `EnableMouseWheel(flag)` / `IsMouseWheelEnabled()` — the wheel's own gate, a separate flag // from `EnableMouse` in the reference and separate here (decision 1198). // diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index 412eec6a..19da427b 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -22,6 +22,46 @@ pub(super) fn region_handle_of(lua: &Lua, this: &Table) -> mlua::Result`, `outline=` — any +/// subset of which may be absent. +/// +/// **This is deliberately not the `SetFont` binding, and that separation is the point.** The +/// reference applies XML font attributes in C++ (`LoadXML`), never through the Lua method, and its +/// `SetFont` therefore *requires* both a path and a height, raising +/// `Usage: %s:SetFont("font", fontHeight [, flags])` (`0x87c69c`) without them. Our loader used to +/// call the binding with three `Option`s — `SetFont(nil, nil, "OUTLINE")` for an outline-only +/// `` — which is why that binding could not be made faithful: one name was doing two +/// jobs, and the more lenient job won. Splitting them lets `SetFont` be the reference's `SetFont` +/// and lets the loader keep the partial application XML actually needs. +/// +/// Each part supplied is an **explicit** set (`FontExplicit`), so it survives a later mutation of +/// the font object this region inherits. An empty `font=` is treated as absent — it keeps the +/// inherited face, rather than being the binding's load-failure edge. +pub(crate) fn apply_font_parts( + lua: &Lua, + this: &Table, + path: Option, + height: Option, + flags: Option, +) -> mlua::Result<()> { + 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(); + 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 = super::Outline::flags(&f); + d.font_explicit.outline = true; + } + Ok(()) +} + /// Get-or-create the wrapper table for a region id (distinct metatable — the region "tag"). pub(super) fn region_wrapper(lua: &Lua, id: u32) -> mlua::Result { let wrappers: Table = lua.named_registry_value(REG_WRAPPERS)?; diff --git a/crates/benilla-ui/src/script/region/text.rs b/crates/benilla-ui/src/script/region/text.rs index a7606750..18f2e883 100644 --- a/crates/benilla-ui/src/script/region/text.rs +++ b/crates/benilla-ui/src/script/region/text.rs @@ -1,13 +1,24 @@ -//! 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. +//! Region method-table cluster: **text** — what a `FontString` carries that other text-bearing +//! widgets do not. 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. +//! **This file is now the FontString-only half.** The ten names it used to hand-write a second copy +//! of — `Set/GetFontObject`, `Set/GetFont`, `Set/GetTextColor`, `Set/GetShadowColor`, +//! `Set/GetShadowOffset` — are not FontString-specific at all: in the binary each is a thin +//! type-guard shim tail-calling one shared implementation, so they come from +//! [`crate::script::font_block`] via one `install` at the bottom of this file, exactly as the +//! EditBox's table gets them. The justify law likewise lives once in [`crate::justify`]. +//! +//! What legitimately stays here is the surface a FontString alone has: the string itself +//! (`SetText`/`SetFormattedText`/`GetText`), the measured extents, `Set/GetJustifyH`/`V`, +//! `SetNonSpaceWrap`/`CanNonSpaceWrap`, and `SetTextHeight`. +//! +//! The per-property override a region has over its inherited font object is unaffected — that is +//! the severance mask (`FontExplicit`), which the shared block writes the same way this file did. use mlua::{Lua, Table, Value}; -use crate::script::{JustifyH, JustifyV, Model, Outline}; +use crate::justify; +use crate::script::Model; /// Resolve `self` (a region wrapper) to its live [`RegionHandle`]. use super::{measured_wh, region_handle_of}; @@ -107,19 +118,30 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.1))?, )?; - // SetJustifyH("LEFT"|"CENTER"|"RIGHT") — a FontString's horizontal justification (XML `justifyH`). + // SetJustifyH("LEFT"|"CENTER"|"RIGHT") — a FontString's horizontal justification (XML + // `justifyH`). The token table, the whole-string match, the raise on a miss and the cross-axis + // clear are all [`crate::justify`]'s, shared with the `` object's identical pair + // so the two cannot drift apart again. 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 parsed = justify::parse_h(&j); + if parsed == justify::Set::NoMatch { + return Err(justify::usage_h("FontString")); + } let mut model = lua.app_data_mut::().expect("model"); let d = model.region_data.entry(rh).or_default(); - d.justify_h = jh; + match parsed { + justify::Set::To(jh) => d.justify.set_h(jh), + // A cross-axis token erases the axis. `GetJustifyH()` then answers "UNKNOWN" + // while the glyphs draw centred — `0x44d420`'s pre-set `1`. + justify::Set::Clears => d.justify.clear_h(), + justify::Set::NoMatch => unreachable!("returned above"), + } + // Severance is UNCONDITIONAL on a successful parse — the FontString's own setter + // `0x79e6b0` stores the per-axis mask `+0x124` at `0x79e780`, *before* the `je`, so + // the erasing path severs too. Only a failed parse escapes, and that raised already. d.font_explicit.justify_h = true; Ok(()) })?, @@ -130,81 +152,46 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { "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 parsed = justify::parse_v(&j); + if parsed == justify::Set::NoMatch { + return Err(justify::usage_v("FontString")); + } let mut model = lua.app_data_mut::().expect("model"); let d = model.region_data.entry(rh).or_default(); - d.justify_v = jv; + match parsed { + justify::Set::To(jv) => d.justify.set_v(jv), + // 13 corpus sites reach this arm: `SetJustifyV("CENTER")` meaning "middle". + justify::Set::Clears => d.justify.clear_v(), + justify::Set::NoMatch => unreachable!("returned above"), + } 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). + // GetJustifyH()/GetJustifyV() → **1 string**. Real entries on the reference's FontString table + // (`0x79e5f0` / `0x79e7f0`, the FontString column of wow-re's two-column accessor table) that + // this side had simply never grown: a FontString could set its justification and not read it + // back, while the `` object — the same law, transcribed separately — could do both. // - // 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). + // An untouched FontString answers CENTER/MIDDLE, which is `RegionData`'s default *and* the + // client's ctor default `0x212` (`CENTER | MIDDLE | 0x200`) read through each axis mask. + fn justify_of(lua: &Lua, this: &Table) -> mlua::Result { + let rh = region_handle_of(lua, this)?; + let model = lua.app_data_ref::().expect("model"); + Ok(model + .region_data + .get(&rh) + .map(|d| d.justify) + .unwrap_or_default()) + } 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(()) - })?, + "GetJustifyH", + lua.create_function(|lua, this: Table| Ok(justify_of(lua, &this)?.name_h()))?, )?; - - // 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), - } - })?, + "GetJustifyV", + lua.create_function(|lua, this: Table| Ok(justify_of(lua, &this)?.name_v()))?, )?; // SetNonSpaceWrap(enable) / CanNonSpaceWrap() — FontString only (`0x79e9f0` / `0x79ead0`). @@ -244,114 +231,6 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { })?, )?; - // ── 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 @@ -371,58 +250,16 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { })?, )?; - // 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(()) + // ── the shared font block ─────────────────────────────────────────────────────────────── + // + // `SetFontObject · GetFontObject · SetFont · GetFont · Set/GetTextColor · Set/GetShadowColor · + // Set/GetShadowOffset` are **not** FontString-specific. In the binary each is a thin type-guard + // shim that tail-calls one shared implementation (`0x79f210` SetFont, `0x79f3b0` GetFont, …), + // which is what [`super::super::font_block`] models — so a FontString and an EditBox are two + // entry points to one routine, not two routines that happen to agree. + // + // They lived here as a hand-written second copy because `region.rs` was over the file-size + // budget when the block was written; that split has since landed, and `font_block`'s own doc + // named this collapse as the follow-on. 226 lines of duplicate go with it. + super::super::font_block::install(lua, m, region_handle_of, "FontString") } diff --git a/crates/benilla-ui/src/script/tests/font_object.rs b/crates/benilla-ui/src/script/tests/font_object.rs index 29dadabc..cbc06627 100644 --- a/crates/benilla-ui/src/script/tests/font_object.rs +++ b/crates/benilla-ui/src/script/tests/font_object.rs @@ -6,7 +6,7 @@ //! almost nobody writes (6 corpus sites against 3,180). use super::common::script; -use crate::script::{FontObject, JustifyH, Outline, QuadContent, UiScript}; +use crate::script::{FontObject, JustifyH, JustifyV, Outline, QuadContent, UiScript}; /// Load a FrameXML fragment through the real loader, asserting it reported no errors. fn load(s: &UiScript, xml: &str) { @@ -714,3 +714,268 @@ fn set_font_returns_one_or_nil() { ); assert!(s.eval::("return f:SetFont('', 12) == nil").unwrap()); } + +/// The justify law is **one** table in the binary (`.rdata 0x811ad0`) and must be one +/// transcription here: a `FontString` and a `` object have to answer identically, token for +/// token and raise for raise. +/// +/// They did not. Both sides were written separately from the same law and each drifted its own +/// way — the FontString had no `GetJustifyH`/`GetJustifyV` at all (real entries `0x79e5f0` / +/// `0x79e7f0`), the Font object silently `.trim()`ed its argument where `SStrCmpI` compares the +/// whole string, and both quietly answered CENTER for a string the reference raises on. Every +/// test on both sides passed throughout. This is the net that makes the next drift fail. +#[test] +fn both_tables_speak_one_justify_law() { + let s = script(); + s.run( + "seam = CreateFrame('Frame', 'SeamFrame')\n\ + fs = seam:CreateFontString()\n\ + fo = CreateFont('SeamFont')", + ) + .unwrap(); + + for obj in ["fs", "fo"] { + // The client's ctor default `0x212` = CENTER | MIDDLE | 0x200, read through each mask. + let h = s + .eval::(&format!("return {obj}:GetJustifyH()")) + .unwrap(); + let v = s + .eval::(&format!("return {obj}:GetJustifyV()")) + .unwrap(); + assert_eq!((obj, h.as_str()), (obj, "CENTER")); + assert_eq!((obj, v.as_str()), (obj, "MIDDLE")); + + // Every token round trips, and the match is case-insensitive. + for (set, get, tokens) in [ + ("SetJustifyH", "GetJustifyH", ["LEFT", "CENTER", "RIGHT"]), + ("SetJustifyV", "GetJustifyV", ["TOP", "MIDDLE", "BOTTOM"]), + ] { + for t in tokens { + s.run(&format!("{obj}:{set}('{}')", t.to_lowercase())) + .unwrap(); + let got = s.eval::(&format!("return {obj}:{get}()")).unwrap(); + assert_eq!(got, t, "{obj}:{set} then {get}"); + } + } + + // A non-token raises the reference's own usage string rather than coercing to CENTER. + for verb in ["SetJustifyH", "SetJustifyV"] { + let err = s + .run(&format!("{obj}:{verb}('MIDDLE_LEFT')")) + .expect_err("a string outside the six-entry table must raise"); + let err = format!("{err}"); + assert!( + err.contains("Usage:") && err.contains(verb), + "{obj}:{verb} raised {err:?}" + ); + } + + // Whole-string: a trailing space is a miss. The Font object used to trim it away. + assert!( + s.run(&format!("{obj}:SetJustifyH('LEFT ')")).is_err(), + "{obj}:SetJustifyH must not trim its argument" + ); + + // A cross-axis token matches the table, so it is accepted with no error — the one place + // we knowingly stop short of the law (the reference clears the axis; see `script::justify`). + s.run(&format!("{obj}:SetJustifyH('TOP')")).unwrap(); + s.run(&format!("{obj}:SetJustifyV('LEFT')")).unwrap(); + } +} + +/// `SetFont` is one routine (`0x79f210`) with three entry points — Font `0x7a0270`, FontString +/// `0x79d4f0`, EditBox `0x797210` — so all three must answer identically, and none of them did. +/// +/// The FontString's hand-written copy answered the boolean `true` for *everything*, including the +/// empty-path load failure that `!OmniCC/main.lua:41`'s `if not f:SetFont(saved, size)` probes; +/// the `` object's took `Option` arguments and so answered **nil** for `SetFont()` where the +/// reference raises `Usage: %s:SetFont("font", fontHeight [, flags])` (`0x87c69c`). A behavioural +/// differential across both tables is what surfaced it — arity alone had matched. +#[test] +fn set_font_is_one_routine_on_both_tables() { + let s = script(); + s.run("seam = CreateFrame('Frame', 'SetFontSeamFrame')\nfs = seam:CreateFontString()\nfo = CreateFont('SetFontSeamFont')") + .unwrap(); + + for obj in ["fs", "fo"] { + // Success is the NUMBER 1 — never a boolean, and never zero values. + let n = s + .eval::(&format!( + "return {obj}:SetFont('Fonts\\\\FRIZQT__.TTF', 12)" + )) + .unwrap(); + assert_eq!(n, 1.0, "{obj}:SetFont success"); + assert!( + s.eval::(&format!( + "return {obj}:SetFont('Fonts\\\\FRIZQT__.TTF', 12) == 1" + )) + .unwrap(), + "{obj}:SetFont must answer the number 1, not true" + ); + + // An empty path is the LOAD FAILURE edge: nil, falsey, and nothing raised. + assert!( + s.eval::(&format!("return {obj}:SetFont('', 12) == nil")) + .unwrap(), + "{obj}:SetFont('') must answer nil" + ); + + // A missing or non-string path, or a missing height, is an ARGUMENT error — it raises, + // which is a different thing from the nil above and used to be conflated with it. + for bad in ["", "'Fonts\\\\FRIZQT__.TTF'", "nil, 12", "{}, 12"] { + let err = s + .run(&format!("{obj}:SetFont({bad})")) + .expect_err("a bad SetFont argument must raise, not answer nil"); + assert!( + format!("{err}").contains("Usage:") && format!("{err}").contains("SetFont"), + "{obj}:SetFont({bad}) raised {err}" + ); + } + + // Both `lua_isstring` and `lua_isnumber` coerce, so a numeric string is accepted for either. + assert!( + s.eval::(&format!( + "return {obj}:SetFont('Fonts\\\\FRIZQT__.TTF', '14') == 1" + )) + .unwrap(), + "{obj}:SetFont must accept a numeric string height" + ); + } +} + +/// A cross-axis token **erases** its axis, and the two readers then disagree — faithfully. +/// +/// `SetJustifyH("TOP")` parses (`0x08`), contributes nothing to mask `0x07`, and raises nothing. +/// `GetJustifyH()` afterwards answers the literal `"UNKNOWN"` (`0x6f1a00` → `.data 0x838044`), +/// while the glyphs keep drawing **centred**: the ui→gx translator `0x44d420` is a priority ladder +/// whose per-axis register is pre-set to `1` between the `test` and the `jcc`, so an all-clear axis +/// exits with CENTER still in it. Reading the getter's answer into the draw path — or mapping the +/// bitmask onto the gx enum with a `0` default — inverts the axis to LEFT. +/// +/// Reached by 13 corpus sites (`FonzAppraiser` ×12, `Roid-Macros`) writing +/// `SetJustifyV("CENTER")` when they mean MIDDLE. +#[test] +fn a_cross_axis_token_erases_the_axis_but_still_draws_centred() { + let mut s = script(); + s.run( + "f = CreateFrame('Frame', 'ClearAxisFrame')\n\ + f:SetWidth(200) f:SetHeight(40) f:SetPoint('CENTER')\n\ + fs = f:CreateFontString()\n\ + fs:SetAllPoints(f)\n\ + fs:SetText('erased')", + ) + .unwrap(); + + // A real token lands, so the erase below is visibly an erase and not a no-op. + s.run("fs:SetJustifyH('LEFT') fs:SetJustifyV('TOP')") + .unwrap(); + assert_eq!(s.eval::("return fs:GetJustifyH()").unwrap(), "LEFT"); + assert_eq!(s.eval::("return fs:GetJustifyV()").unwrap(), "TOP"); + + // The cross-axis pair: each erases the OTHER axis it was aimed at, raising nothing. + s.run("fs:SetJustifyH('TOP')").unwrap(); + s.run("fs:SetJustifyV('CENTER')").unwrap(); + assert_eq!( + s.eval::("return fs:GetJustifyH()").unwrap(), + "UNKNOWN", + "a cleared axis reads UNKNOWN, not the value it used to hold" + ); + assert_eq!( + s.eval::("return fs:GetJustifyV()").unwrap(), + "UNKNOWN" + ); + + // ...and the draw path answers CENTER/MIDDLE for that same state. + s.resolve(); + let (h, v) = s + .extract() + .into_iter() + .find_map(|q| match q.content { + QuadContent::Text { + text: Some(ref t), + justify_h, + justify_v, + .. + } if t == "erased" => Some((justify_h, justify_v)), + _ => None, + }) + .expect("the erased string draws"); + assert_eq!( + (h, v), + (JustifyH::Center, JustifyV::Middle), + "a cleared axis draws CENTER/MIDDLE — the gx translator's pre-set 1, NOT LEFT/TOP" + ); +} + +/// The font block is a **per-table membership fact**, and wow-re's registrar carve names the six: +/// *"Exposed on: FontString, Font object, EditBox, MessageFrame, ScrollingMessageFrame, +/// SimpleHTML. NOT on Button."* We shipped it on two; these are the third and fourth. +/// +/// `BigWigs/Plugins/Messages.lua:212` — `self.msgframe:SetFontObject(GameFontNormalLarge)` on a +/// frame it has just given `SetInsertMode("TOP")` — died there every session. +/// +/// The second assertion is the trap: a message frame's lines fall back to **LEFT** when it has no +/// declared ``, while a freshly created `RegionData` defaults to the FontString's own +/// CENTER. Creating the style region on demand must not silently re-justify the frame. +#[test] +fn the_font_block_reaches_both_message_frame_tables() { + let mut s = script(); + s.run( + "fo = CreateFont('MsgBlockFont')\n\ + fo:SetFont('Fonts\\\\FRIZQT__.TTF', 14)\n\ + mf = CreateFrame('MessageFrame', 'MsgBlockPlain')\n\ + mf:SetWidth(300) mf:SetHeight(80) mf:SetPoint('CENTER')\n\ + smf = CreateFrame('ScrollingMessageFrame', 'MsgBlockScroll')\n\ + smf:SetWidth(300) smf:SetHeight(80) smf:SetPoint('TOPLEFT')", + ) + .unwrap(); + + for obj in ["mf", "smf"] { + s.run(&format!("{obj}:SetFontObject(fo)")).unwrap(); + assert_eq!( + s.eval::(&format!("return {obj}:GetFontObject():GetName()")) + .unwrap(), + "MsgBlockFont", + "{obj}:GetFontObject must answer the OBJECT it was given" + ); + // The shared `0x79f210` contract, same as every other table that carries it. + assert!( + s.eval::(&format!( + "return {obj}:SetFont('Fonts\\\\MORPHEUS.TTF', 16) == 1" + )) + .unwrap(), + "{obj}:SetFont answers the number 1" + ); + let (_, height, _) = s + .eval::<(mlua::Value, f32, String)>(&format!("return {obj}:GetFont()")) + .unwrap(); + assert_eq!(height, 16.0, "{obj}:GetFont reads back what SetFont wrote"); + // The four-value getters the carve pins at 4 for every one of the six tables. + assert_eq!( + s.eval::(&format!("return select('#', {obj}:GetShadowColor())")) + .unwrap(), + 4 + ); + } + + // Styling the frame must not move its text: the lines still run flush LEFT. + s.run("mf:AddMessage('flush left please')").unwrap(); + s.resolve(); + let j = s + .extract() + .into_iter() + .find_map(|q| match q.content { + QuadContent::Text { + text: Some(ref t), + justify_h, + .. + } if t == "flush left please" => Some(justify_h), + _ => None, + }) + .expect("the message draws"); + assert_eq!( + j, + JustifyH::Left, + "creating the style region must not re-justify the frame to the FontString default" + ); +} diff --git a/crates/benilla-ui/src/script/tests/toplevel.rs b/crates/benilla-ui/src/script/tests/toplevel.rs index 0ae98bb8..061e094b 100644 --- a/crates/benilla-ui/src/script/tests/toplevel.rs +++ b/crates/benilla-ui/src/script/tests/toplevel.rs @@ -91,10 +91,13 @@ fn the_flag_round_trips_and_defaults_off() { /// this law existed, which left every window authored toplevel (thirteen of our own frames, 82 /// corpus addons) unable to come to the front while `SetToplevel` did not exist at all. /// -/// `enableKeyboard` is deliberately still warned: implementing one clause is not a licence to go -/// quiet about the other. +/// `enableKeyboard` used to be warned here on the same principle — implementing one clause is not +/// a licence to go quiet about the other. It now lands on a real `EnableKeyboard`, so the warning +/// is gone and this asserts the flag instead. What is still NOT built is key *delivery*, which the +/// method's own doc states; the flag and the delivery are separable in the reference too +/// (`frame-key-script-delivery.md` §3.2). #[test] -fn the_xml_toplevel_attribute_reaches_the_method_and_enable_keyboard_still_warns() { +fn the_xml_toplevel_and_enable_keyboard_attributes_both_reach_their_methods() { let s = script(); let doc = crate::framexml::parse( r#" @@ -114,11 +117,23 @@ fn the_xml_toplevel_attribute_reaches_the_method_and_enable_keyboard_still_warns report.warnings ); assert!( - report.warnings.iter().any(|w| w.contains("EnableKeyboard")), - "the clause we did NOT build stays loud: {:?}", + !report.warnings.iter().any(|w| w.contains("EnableKeyboard")), + "enableKeyboard is built now, not warned: {:?}", report.warnings ); assert!(s.eval::("return XmlTop:IsToplevel()").unwrap()); + assert!( + s.eval::("return XmlTop:IsKeyboardEnabled()").unwrap(), + "the XML attribute must reach the flag" + ); + // Default false, and the setter round-trips both ways — a frame that never asked is not in the + // bucket (`0x76af00` is reached from the attribute or an explicit call, never a ctor). + s.run("Plain = CreateFrame('Frame', 'PlainKbd')").unwrap(); + assert!(!s.eval::("return Plain:IsKeyboardEnabled()").unwrap()); + s.run("Plain:EnableKeyboard(true)").unwrap(); + assert!(s.eval::("return Plain:IsKeyboardEnabled()").unwrap()); + s.run("Plain:EnableKeyboard(false)").unwrap(); + assert!(!s.eval::("return Plain:IsKeyboardEnabled()").unwrap()); } // ── The raise on Show ─────────────────────────────────────────────────────────────────────────── diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index bce9dcb5..3173ca35 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -182,7 +182,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> { // Left-justified like the template's line stack — invisible while the rect hugs // the measured text, load-bearing once the wrap consumer pins a region to the // wrap column. - d.justify_h = super::JustifyH::Left; + d.justify.set_h(super::JustifyH::Left); d.anchors = vec![match prev_left { None => Anchor::new( Point::TopLeft, @@ -227,7 +227,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> { d }; d.hidden = true; - d.justify_h = super::JustifyH::Right; + d.justify.set_h(super::JustifyH::Right); d.anchors = vec![Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)]; model.region_data.insert(right, d); model.touch_layout(); // a line row entered the layout graph (decision 0740) diff --git a/crates/benilla-ui/src/script/tooltip/verbs.rs b/crates/benilla-ui/src/script/tooltip/verbs.rs index ce29e64c..956cdec0 100644 --- a/crates/benilla-ui/src/script/tooltip/verbs.rs +++ b/crates/benilla-ui/src/script/tooltip/verbs.rs @@ -259,13 +259,13 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { { let d = model.region_data.entry(lh).or_default(); d.hidden = true; - d.justify_h = crate::script::JustifyH::Left; + d.justify.set_h(crate::script::JustifyH::Left); d.anchors = vec![anchor]; } { let d = model.region_data.entry(rh).or_default(); d.hidden = true; - d.justify_h = crate::script::JustifyH::Right; + d.justify.set_h(crate::script::JustifyH::Right); d.anchors = vec![Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)]; } model.touch_layout(); // two line rows entered the layout graph (decision 0740) diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index 4419b461..e40c6356 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -443,7 +443,10 @@ pub(crate) struct RegionData { /// non-empty ⇒ resolved in [`UiScript::resolve`], any edge the anchors leave unset inherited /// from the owner frame's rect. pub(crate) anchors: Vec, - /// FontString horizontal justification (`SetJustifyH`/XML `justifyH`); default CENTER. + /// `Texture:SetDesaturated(flag)` — the shader desaturation state (`0x79c1e0`, verified in + /// wow-re's ledger). **Stored; the renderer does not yet honour it**, which is exactly why the + /// binding answers "shader unsupported" — see `region.rs`'s `SetDesaturated`. + pub(crate) desaturated: bool, /// `SetNonSpaceWrap` / `CanNonSpaceWrap` — FontString only (`0x79e9f0`/`0x79ead0`, wow-re's /// widget-method batch). **State only here.** The real client's gx flag `0x40` feeds a /// mid-word wrap carry and the fit-count terminator whose one consumer is the ellipsis @@ -452,14 +455,13 @@ pub(crate) struct RegionData { /// which is the honest position and is stated rather than left to be discovered. /// /// Default is ON (a no-arg `SetNonSpaceWrap()` also enables), so `None` reads as enabled. - /// `Texture:SetDesaturated(flag)` — the shader desaturation state (`0x79c1e0`, verified in - /// wow-re's ledger). **Stored; the renderer does not yet honour it**, which is exactly why the - /// binding answers "shader unsupported" — see `region.rs`'s `SetDesaturated`. - pub(crate) desaturated: bool, pub(crate) non_space_wrap: Option, - pub(crate) justify_h: JustifyH, - /// FontString vertical justification (`SetJustifyV`/XML `justifyV`); default MIDDLE. - pub(crate) justify_v: JustifyV, + /// FontString justification, as the client's own **dword** rather than a pair of resolved + /// enums — `CSimpleFontString+0x120`, bits 0–2 horizontal and 3–5 vertical, defaulting to the + /// ctor's `0x212` (CENTER|MIDDLE). See [`crate::justify::Justify`]: an axis can be *cleared*, + /// which no resolved enum can hold, and the Lua getter and the draw path then answer that + /// state differently (`"UNKNOWN"` vs centred) — both faithfully. + pub(crate) justify: crate::justify::Justify, /// The ``/`SetTexCoord` UV mapping ([`TexCoords`]: the 4-edge crop, or the 8-arg /// affine quad). `None` = the full texture. Slices the quadrant/atlas art (decision 0084). pub(crate) tex_coords: Option, diff --git a/crates/benilla-ui/src/widget/kinds/editbox.rs b/crates/benilla-ui/src/widget/kinds/editbox.rs index 737171da..6813a50c 100644 --- a/crates/benilla-ui/src/widget/kinds/editbox.rs +++ b/crates/benilla-ui/src/widget/kinds/editbox.rs @@ -192,52 +192,33 @@ pub struct EditBoxState { pub justify: u32, } -/// The justify enum, `.rdata 0x811ad0` — `{bits, token}`, in the reference's own table order, which -/// is the order [`EditBoxState::justify_token`] scans (the getter returns the **first** set bit's -/// token). -const JUSTIFY_TOKENS: [(u32, &str); 6] = [ - (0x01, "LEFT"), - (0x02, "CENTER"), - (0x04, "RIGHT"), - (0x08, "TOP"), - (0x10, "MIDDLE"), - (0x20, "BOTTOM"), -]; - impl EditBoxState { /// The horizontal axis mask (bits 0–2) — `SetJustifyH`'s `(cur & ~7) | (parsed & 7)`. - pub const JUSTIFY_H_MASK: u32 = 0x07; + pub const JUSTIFY_H_MASK: u32 = crate::justify::H_MASK; /// The vertical axis mask (bits 3–5) — `SetJustifyV`'s. - pub const JUSTIFY_V_MASK: u32 = 0x38; + pub const JUSTIFY_V_MASK: u32 = crate::justify::V_MASK; - /// Parse a justify token to its bit, case-insensitively and whole-string (`0x6f1990`, a linear - /// `SStrCmpI` scan of all six). `None` = no match, which is what makes the caller raise - /// `Usage: %s:SetJustifyH("justify")` — the reference checks this one, unlike `SetTextColor`. + /// Parse a justify token to its bit — [`crate::justify::parse_bits`]. `None` = no match, which + /// is what makes the caller raise `Usage: %s:SetJustifyH("justify")`; the reference checks this + /// one, unlike `SetTextColor`. pub fn justify_bit(token: &str) -> Option { - JUSTIFY_TOKENS - .iter() - .find(|(_, name)| token.eq_ignore_ascii_case(name)) - .map(|(bits, _)| *bits) + crate::justify::parse_bits(token) } - /// Replace one axis's bits with `parsed`'s, masked to that axis (`0x79fc5d`: - /// `(cur ^ parsed) & mask ^ cur`, i.e. `(cur & !mask) | (parsed & mask)`). + /// Replace one axis's bits with `parsed`'s — [`crate::justify::set_axis`]. /// /// **The verified trap this reproduces:** `SetJustifyH("TOP")` *parses* (0x08) but /// `0x08 & 0x07 == 0`, so it **clears** justifyH and a later `GetJustifyH()` answers /// `"UNKNOWN"` — no error is raised. A plausible implementation that maps unknown-to-CENTER - /// silently answers something the client never would. + /// silently answers something the client never would; the FontString and `` tables both + /// did exactly that until this law was lifted out of here and shared with them. pub fn set_justify_axis(&mut self, mask: u32, parsed: u32) { - self.justify = (self.justify & !mask) | (parsed & mask); + self.justify = crate::justify::set_axis(self.justify, mask, parsed); } - /// The token for one axis: the **first** entry in table order whose bit is set, else the - /// literal `"UNKNOWN"` (`0x6f1a00`, string `.data 0x838044`). + /// The token for one axis — [`crate::justify::name_of`]. pub fn justify_token(&self, mask: u32) -> &'static str { - JUSTIFY_TOKENS - .iter() - .find(|(bits, _)| self.justify & mask & bits != 0) - .map_or("UNKNOWN", |(_, name)| *name) + crate::justify::name_of(self.justify, mask) } } diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index 8083dd1c..357a8a80 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -236,6 +236,20 @@ pub struct Frame { /// window behind it while keeping its handler installed — a frame with a handler it has not /// enabled is deliberately transparent to the wheel. pub mouse_wheel_enabled: bool, + /// Whether the frame is **keyboard-enabled** (`EnableKeyboard` `0x776f90` / XML + /// `enableKeyboard`) — the kind-0/kind-1 bucket membership `0x76af00` writes as + /// `[frame+0xcc] |= 1< bool { + self.frame(h).is_some_and(|f| f.keyboard_enabled) + } + /// `EnableMouseWheel` — the wheel's own gate, separate from the mouse's (decision 1198). pub fn set_mouse_wheel_enabled(&mut self, h: FrameHandle, enabled: bool) { if let Some(f) = self.frame_mut(h) {