Comments in benilla-ui: one line of fact each, no history, no record numbers (#143)

Part of #41: the comments of `benilla-ui` (the Lua UI engine, its FrameXML loader and its widgets) are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 43,140 comment lines become 15,161.

- **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal.
- **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them:
  - `0x64a480` is the case-sensitive compare (a byte `repz cmpsb`), and `0x64a4c0` the case-insensitive one.
  - A frame's id sits at `+0xb0`, not `+0xb4`. `EnableKeyboard`/`IsKeyboardEnabled` are `0x776ec0`/`0x776f90`.
  - A region's unpinned edge has no fallback to its owner's rect: the owner supplies only the scale.
  - `$parent` expands to the nearest named ancestor, or "Top" when none is named (`0x76c5b0`), not simply to the parent.
  - A StatusBar fills from its internal normalised fraction, not from `GetValue()`.
  - The addresses of `StartDuel` and `StartDuelUnit` were swapped.
  - Many "untraced" notes now give the traced reference fact: the drag threshold, the per-grip sizing edges, the empty-`autoFocus` skip, and `SetTexCoordModifiesRect`'s default.
  - Stock FrameXML line citations are checked against the stock 1.12 files and corrected. `ref-` file names become the stock file names.
- **Deviation labels.** "Deviation:" now marks only a choice with a reason. A surface that exists only for the retired Era addon target says so plainly: it is not 1.12.
- **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Nine second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference.

Behaviour differences the rewrite surfaced are filed as their own issues (#131–#142), and more are being validated. The comments here state what the code does. String literals are untouched; they are #92.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-25 01:58:49 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 8505187668
commit fd386e75ae
233 changed files with 14108 additions and 42094 deletions
@@ -1,20 +1,4 @@
//! The EditBox mouse/selection interaction law — click→char-index, drag-select, the clipboard
//! pair, and the caret blink — a child module over the mother's focus/editing primitives.
//!
//! Byte law:
//!
//! - **Click `0x77b800`**: on a hit — blink reset, click→index (`0x77d0d0`) places the cursor and
//! collapses the selection, the drag flag raises (`E+0x364 = 1`), then `SetFocus`
//! UNCONDITIONALLY. No shift+click branch exists in the handler (INFERRED by absence — 1.12
//! predates extend-on-click).
//! - **Drag `0x77a860`** (mouse move while `+0x364`): map x→index, extend the selection there via
//! the same helper Shift+arrow uses (`0x77cd10`), cursor following.
//! - **Release** (`0x77afc0` case 1): `+0x364 = 0`.
//! - **Clipboard**: Ctrl+C/X act only with a non-empty selection (`0x77e1d0`); X then
//! deletes = cut. A password box copies a FIXED placeholder (`0x882748`, runtime literal
//! unresolved — flagged live-capture), never the real text; benilla stands in the mask run.
//! - **Blink `0x77a790`**: the focused box's accumulator (`E+0x374`) advances by dt; crossing the
//! period (`E+0x370`, ctor default 0.5 s) toggles the caret and zeroes the accumulator.
//! EditBox mouse and clipboard interaction: click, drag-select, copy, cut and the caret blink.
use mlua::Lua;
@@ -22,13 +6,10 @@ use super::{set_focus_handle, sync_text_region, with_eb};
use crate::script::Model;
use crate::widget::{FrameHandle, FrameKind, KindState};
/// Map a screen point (the engine's y-up UI space — the same space resolved rects and the
/// pointer API live in) to the box's TEXT byte index: x relative to the text region's left edge,
/// shifted into advance space by the scroll window's origin, nearest char boundary (`0x77d0d0`;
/// the rounding is INFERRED nearest). A multiline box also reads y — px down from the region's
/// top pick the wrapped row ([`crate::widget::EditBoxState::index_at_pos`]). Ends up at
/// end-of-text while the advance table is unanswered — the pre-seam behavior, degrading
/// gracefully.
/// A UI-space point (y up) to the box's text byte index at the nearest cursor stop (`0x77d0d0`,
/// whose own rounding is untraced): x from the text region's left edge plus the scroll origin, and
/// in a multi-line box y from its top picks the wrapped row. The end of the text until the advance
/// table is answered.
fn index_at_screen_pos(lua: &Lua, h: FrameHandle, x: f32, y: f32) -> usize {
let corner = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
@@ -36,8 +17,7 @@ fn index_at_screen_pos(lua: &Lua, h: FrameHandle, x: f32, y: f32) -> usize {
Some(KindState::EditBox(eb)) => eb.text_region,
_ => None,
};
// The text region's resolved corner; an unanchored region (no insets) draws over its
// owner, so the frame's own edges stand in.
// An unanchored text region draws over its owner, so the frame's own corner stands in.
rh.and_then(|rh| model.region_resolved.get(&rh))
.or_else(|| model.resolved.get(&h))
.map(|r| (r.left, r.top))
@@ -53,8 +33,8 @@ fn index_at_screen_pos(lua: &Lua, h: FrameHandle, x: f32, y: f32) -> usize {
.unwrap_or(0)
}
/// A LeftButton press landed on frame `id` (`0x77b800`): if it is an EditBox — blink reset, the
/// cursor to the clicked char (collapsing any selection), drag begins, focus UNCONDITIONALLY.
/// A LeftButton press on EditBox `id` (`0x77b800`) places the cursor at the clicked char,
/// collapsing the selection, starts a drag and takes focus unconditionally; no shift+click branch.
pub(in crate::script) fn click(lua: &Lua, id: u32, x: f32, y: f32) {
let Some(h) = editbox_of(lua, id) else { return };
let idx = index_at_screen_pos(lua, h, x, y);
@@ -65,8 +45,8 @@ pub(in crate::script) fn click(lua: &Lua, id: u32, x: f32, y: f32) {
set_focus_handle(lua, h);
}
/// Mouse move while the press is held (`0x77a860`): the focused, drag-active box extends its
/// selection to the hovered index; the cursor follows.
/// Mouse move with the button held (`0x77a860`): a dragging focused box extends its selection to
/// the hovered index through the Shift+arrow helper (`0x77cd10`).
pub(in crate::script) fn drag_update(lua: &Lua, x: f32, y: f32) {
let Some(h) = focused(lua) else { return };
if with_eb(lua, h, |eb| eb.drag_active) != Some(true) {
@@ -80,30 +60,27 @@ pub(in crate::script) fn drag_update(lua: &Lua, x: f32, y: f32) {
});
}
/// The press released (`0x77afc0` case 1): the drag ends. Fires for any release — a box that
/// never started a drag ignores it.
/// Any button release ends the drag (`0x77afc0` case 1).
pub(in crate::script) fn drag_end(lua: &Lua) {
if let Some(h) = focused(lua) {
with_eb(lua, h, |eb| eb.drag_active = false);
}
}
/// Ctrl+LEFT/RIGHT — the word-granular cursor move (the client walks its per-byte class array on
/// the Ctrl branch, `0x41f8f0(1)`; benilla's alnum-run approximation is INFERRED): `shift` extends
/// the selection from the fixed anchor like the char move; else the caret collapses there.
/// Ctrl+Left/Right, the word move: the client walks its per-byte class array (`0x41f8f0(1)`),
/// benilla stops at alphanumeric runs. `shift` extends the selection from its anchor.
pub(in crate::script) fn move_word(lua: &Lua, h: FrameHandle, right: bool, shift: bool) {
with_eb(lua, h, |eb| eb.move_by_word(right, shift));
}
/// Ctrl+C (`0x77e1d0`): the focused box's selected substring, `None` with no selection. A
/// password box yields the mask run — the client copies a fixed placeholder, NEVER the text.
/// Ctrl+C (`0x77e1d0`): the focused box's selection. A password box never yields its text: the
/// client copies the image's empty string (`0x882748`), benilla the mask run.
pub(in crate::script) fn copy_selection(lua: &Lua) -> Option<String> {
let h = focused(lua)?;
with_eb(lua, h, |eb| eb.selected_text()).flatten()
}
/// Ctrl+X: copy, then delete the selection (an ordinary edit — the region syncs and
/// `OnTextChanged` fires).
/// Ctrl+X (`0x77e1d0`): copy, then delete the selection as an ordinary edit.
pub(in crate::script) fn cut_selection(lua: &Lua) -> Option<String> {
let h = focused(lua)?;
let copied = with_eb(lua, h, |eb| eb.cut_selection()).flatten()?;
@@ -112,9 +89,8 @@ pub(in crate::script) fn cut_selection(lua: &Lua) -> Option<String> {
Some(copied)
}
/// The blink tick (`0x77a790`, run from the host's frame tick): the focused box's accumulator
/// advances; crossing the period toggles the caret and resets. Non-positive period never blinks
/// (a solid caret).
/// The caret blink (`0x77a790`), each frame: past the period (0.5 s by default) the focused box's
/// caret toggles and the accumulator resets; a non-positive period keeps the caret solid.
pub(in crate::script) fn tick_blink(lua: &Lua, dt: f32) {
if let Some(h) = focused(lua) {
with_eb(lua, h, |eb| {
@@ -129,14 +105,12 @@ pub(in crate::script) fn tick_blink(lua: &Lua, dt: f32) {
}
}
/// The focused EditBox handle, if any.
fn focused(lua: &Lua) -> Option<FrameHandle> {
lua.app_data_ref::<Model>()
.expect("model app_data")
.focused_editbox
}
/// Resolve a frame id to a live EditBox handle.
fn editbox_of(lua: &Lua, id: u32) -> Option<FrameHandle> {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let h = model.id_to_frame.get(&id).copied()?;
+57 -172
View File
@@ -1,8 +1,5 @@
//! The EditBox Lua method surface — `install` builds the method table
//! (`REG_EDITBOX_METHODS`) the dispatcher consults before the shared frame table for EditBox
//! frames: text/cursor/selection accessors, focus, history, the config setters, and the blink
//! dial. Every method routes through the mother module's primitives (`set_text`,
//! `highlight_text`, …), so the byte-verified law lives exactly once.
//! The EditBox Lua method table (`REG_EDITBOX_METHODS`), consulted before the shared frame
//! table; each method routes through the parent module's primitives.
use mlua::{Lua, Table, Value};
@@ -14,7 +11,6 @@ use super::{
with_eb, REG_EDITBOX_METHODS,
};
/// Run `f` over a frame's EditBox state for a Lua method call; errors if `this` is not a live EditBox.
fn with_editbox<T>(
lua: &Lua,
this: &Table,
@@ -32,11 +28,9 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, (this, s): (Table, Option<mlua::Value>)| {
let s = crate::script::binding_abi::text_arg(lua, s)?;
let h = frame_handle_of(lua, &this)?;
// Programmatic SetText KEEPS a history browse in progress: the chat live parse
// rewrites the box on every recalled slash line ("/s hi" → Say + "hi"), and ending
// the browse there reset every UP to the newest entry — "history only goes back 1"
// (director's report, 2026-07-26). The browse ends on typed edits, AddHistoryLine,
// and focus gain (`set_focus_handle`) — the fresh-session reset.
// SetText keeps a history browse going: the chat parser rewrites each recalled slash
// line, and ending the browse there would send every Up back to the newest entry.
// Typed edits, AddHistoryLine and focus gain end it.
set_text(lua, h, &s.unwrap_or_default());
Ok(())
})?,
@@ -45,30 +39,16 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"GetText",
lua.create_function(|lua, this: Table| with_editbox(lua, &this, |eb| eb.text.clone()))?,
)?;
// SetNumber(v) — **the same function as SetText**. `0x798690` and `0x7984c0` are byte-identical
// (245 bytes each, zero differences after masking rel32 and absolute-VA operands; only the
// usage string differs), so this does no numeric work of its own: it hands the argument to the
// shared `lua_tostring` marshalling and sets the result as text. Decision 1831.
//
// The GATE is `lua_isstring 0x6f3510`, a pure type test over {number, string} — NOT
// `lua_isnumber` and NOT `luaL_checknumber`. So a STRING is accepted and passed through
// VERBATIM, never parsed: `SetNumber("abc")` sets the text "abc" and does not raise. Anything
// else — nil, boolean, table, or an ABSENT argument — raises the usage string and abandons the
// caller's statement.
//
// The live consumer is `MoneyInputFrame.lua:47/52/57`, on the chain since 1751, whose three
// boxes are `numeric="true"`. Our numeric filter already models the reference's wholesale
// abort, which matters here: on such a box `SetNumber(-5)` or `SetNumber(0.8)` leaves the box
// EMPTY rather than partially filled, because the sign or the point fails the digit test after
// the clear-all has already run. The money path only ever passes non-negative integers
// (`floor`/`mod` results), so it never takes that branch.
// SetNumber is SetText: `0x798690` and `0x7984c0` are byte-identical but for the usage
// string. Its gate is `lua_isstring` (`0x6f3510`), so a string is set verbatim, never parsed,
// and nil, a boolean, a table or no argument raises the usage string. On a numeric box `-5` or
// `0.8` leaves it empty: the sign or point fails the digit test after the clear has run.
m.set(
"SetNumber",
lua.create_function(|lua, (this, v): (Table, Value)| {
let text = match &v {
Value::Integer(i) => crate::script::binding_abi::lua_number_text(*i as f64),
Value::Number(n) => crate::script::binding_abi::lua_number_text(*n),
// A string is not parsed — it is the text.
Value::String(s) => s.to_str()?.to_string(),
_ => return Err(mlua::Error::runtime("Usage: EditBox:SetNumber(number)")),
};
@@ -77,7 +57,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
})?,
)?;
// GetNumber: atof of the real text (0 on failure), matching `0x798790`.
// GetNumber: the real text as a number, else 0. The reference (`0x798790`) is atof, which also
// reads the leading number of a text like "12abc".
m.set(
"GetNumber",
lua.create_function(|lua, this: Table| {
@@ -85,19 +66,9 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(text.trim().parse::<f64>().unwrap_or(0.0))
})?,
)?;
// Insert(text) — **a nil is a no-op, not an error.** The reference's C `Insert` reads its
// argument through `lua_tostring`, which answers NULL for a nil and leaves the buffer alone;
// stock FrameXML relies on that. `LootFrame.lua:152` is the plain case:
//
// ChatFrameEditBox:Insert(GetLootSlotLink(this.slot));
//
// with no guard, on a coin row whose `GetLootSlotLink` is nil. Typed as `String`, this raised
// — which the loot window only survived while we owned the file and could add a guard the
// reference does not have. It bit the moment `LootFrame.xml` came off the player's chain
// (1751), and it would bite any addon writing the same unguarded line.
//
// A NUMBER still inserts its digits: `lua_tostring` converts one in place, and mlua's
// `Option<String>` coercion follows it.
// Insert: a nil is a no-op, not an error, since the reference reads the argument through
// `lua_tostring`, which answers NULL for nil; stock `LootFrame.lua:152` inserts a coin row's
// nil link unguarded. A number inserts its digits.
m.set(
"Insert",
lua.create_function(|lua, (this, s): (Table, Option<mlua::Value>)| {
@@ -126,18 +97,11 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
})?,
)?;
// **No `HasFocus`.** 1.12's EditBox table registers the two setters above and no getter at
// all, and an addon that wants the answer keeps its own flag around `SetFocus`/`ClearFocus` —
// `pfQuest/browser.lua:760` documents exactly that and ships the workaround. Ours was an
// unexplained superset a feature-detecting addon would take the wrong branch on (1188, and
// the census that removed it, 2142). Host-side, the focus cell reads back through
// [`crate::script::UiScript::focused_editbox_name`].
// No `HasFocus`: 1.12's EditBox table has these two setters and no getter, and addons keep
// their own flag.
// GetInputLanguage() / ToggleInputLanguage() — `0x799550` / `0x799610`, the edit box's
// **IME** language, not the chat language: `ChatEdit_OnInputLanguageChanged` shows
// `INPUT_<name>` on the box's language button, and the toggle is what a Korean client
// flips between its two input modes. On a client with no IME the answer is the Roman
// alphabet and the toggle moves nothing; benilla has no IME.
// GetInputLanguage/ToggleInputLanguage (`0x799550`/`0x799610`): the IME's input language, not
// the chat language. With no IME, as here, the answer is "ROMAN" and the toggle does nothing.
m.set(
"GetInputLanguage",
lua.create_function(|lua, this: Table| {
@@ -153,7 +117,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// HighlightText([start [, end]]) — defaults (0, -1) = select-all.
// HighlightText([start [, end]]): the defaults (0, -1) select all.
m.set(
"HighlightText",
lua.create_function(
@@ -166,24 +130,16 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
m.set(
// `GetMaxLetters 0x79929f` — the read half, which we were missing. Not published off the
// `strings` hit alone (that is what put `SetUnit` on the wrong widget earlier today):
// `0x79929f` is the GETTER of `[widget+0x340]`, the same field the setter below writes,
// which is table-level evidence rather than a name that happens to be in the image.
//
// `GetMaxLetters` (`0x79929f`) reads the field `SetMaxLetters` writes.
"GetMaxLetters",
lua.create_function(|lua, this: Table| {
with_editbox(lua, &this, |eb| eb.max_letters as i64)
})?,
)?;
m.set(
// `SetMaxBytes 0x798f30` — `SetMaxLetters`'s sibling in every respect but the sentinel:
// the same EXACT count gate (`0x798fbc cmp eax,2`, the second of the four `lua_gettop`
// callers), the same raw coerce of the value, and a field of its own at `+0x33c` whose
// no-limit value is **-1** where `maxLetters`'s is 0 (`0x799012 jle` → `0x799022`; the
// ctor writes -1 at `0x7799df`). So a non-positive argument is unlimited, a positive one
// caps the buffer's BYTES. The stock StaticPopup_Show calls it for any entry carrying
// `maxBytes` — none of the reference's own 76 does, so the reach is an addon's (1960).
// `SetMaxBytes` (`0x798f30`) has `SetMaxLetters`'s exact count gate (`0x798fbc`) and raw
// coercion, but its no-limit value is -1 (`0x799012`; the ctor writes -1 at `0x7799df`):
// a non-positive argument is unlimited, a positive one caps the bytes.
"SetMaxBytes",
lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| {
let args: Vec<Value> = args.into_iter().collect();
@@ -200,30 +156,16 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
m.set(
// The read half: -1 while unlimited, the reference's own stored sentinel.
// -1 while unlimited, the reference's stored sentinel.
"GetMaxBytes",
lua.create_function(|lua, this: Table| {
with_editbox(lua, &this, |eb| eb.max_bytes.map_or(-1, |n| n as i64))
})?,
)?;
m.set(
// `SetMaxLetters 0x799110` — one of only FOUR widget bindings in the whole registrar that
// calls `lua_gettop` (`0x6f3070`), and its gate is EXACT: `cmp eax,2` (`0x79919c`). So the
// count is checked and the type is not, which is the opposite of the usual pairing and the
// reason this needs its own body:
//
// SetMaxLetters() -> RAISES `Usage:` (too few)
// SetMaxLetters(50, 60) -> RAISES `Usage:` (too MANY — an exact gate, not a minimum)
// SetMaxLetters(nil) -> completes, stores 0
// SetMaxLetters("12") -> completes, stores 12 (a numeric string coerces)
// SetMaxLetters({}) -> completes, stores 0
//
// and **0 is "no limit"**, not "no letters": the insert path's trim block is skipped
// whole on zero (`0x77c085 test edi,edi; je`), which is what `max_letters == 0` already
// means here. So `SetMaxLetters(nil)` is `SetMaxLetters(0)` is unlimited — aux-addon's
// `gui/core.lua:288` writes exactly that, and benilla raised on it and killed the addon at
// load. (Its neighbour `SetMaxBytes` uses **-1** for the same idea; two adjacent fields,
// two different sentinels.)
// `SetMaxLetters` (`0x799110`) checks the count exactly, `lua_gettop` (`0x6f3070`) against
// 2 at `0x79919c`, and not the type: no argument or two raise `Usage:`, nil or `{}` store
// 0, `"12"` stores 12. 0 is no limit: the insert skips its trim on zero (`0x77c085`).
"SetMaxLetters",
lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| {
let args: Vec<Value> = args.into_iter().collect();
@@ -233,14 +175,13 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
));
}
let n = crate::script::binding_abi::coerced_number(lua, args.first().cloned());
// `__ftol` truncates toward zero; a negative stores as itself there, but our field is
// a `usize` and the trim only ever tests `> 0`, so the two agree on every value that
// can change behaviour.
// Truncated toward zero like `__ftol`; the reference stores a negative as is, but its
// trim only acts above 0, so the clamp changes nothing.
with_editbox(lua, &this, |eb| eb.max_letters = (n as i64).max(0) as usize)
})?,
)?;
// The submitted-line history (`historyLines`): FrameXML pushes each sent line
// (ChatEdit_AddHistory), UP/DOWN recall it (see `key_input`).
// The sent-line history (`historyLines`): `ChatEdit_AddHistory` pushes each line, Up and Down
// recall them.
m.set(
"AddHistoryLine",
lua.create_function(|lua, (this, line): (Table, Option<String>)| {
@@ -267,7 +208,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
with_editbox(lua, &this, |eb| eb.history_max as i64)
})?,
)?;
// SetBlinkSpeed/GetBlinkSpeed — the caret half-period (`E+0x370`, XML `blinkSpeed`; ctor 0.5).
// SetBlinkSpeed/GetBlinkSpeed: the caret half-period (XML `blinkSpeed`, 0.5 s by default).
m.set(
"SetBlinkSpeed",
lua.create_function(|lua, (this, s): (Table, f32)| {
@@ -309,9 +250,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
Ok((ins[0], ins[1], ins[2], ins[3]))
})?,
)?;
// GetNumLetters: the LETTER count — `0x7992c0` walks the class array (`0x77bc80`), which
// counts classes 2, 3 and 6 only, so escapes are free and a 43-byte item link reports 9
// (decision 1077). Not bytes, and not chars either.
// GetNumLetters (`0x7992c0`): the class array's letters (`0x77bc80`), classes 2, 3 and 6 only,
// so escapes are free and a 43-byte item link counts 9.
m.set(
"GetNumLetters",
lua.create_function(|lua, this: Table| {
@@ -321,33 +261,17 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// The config flags. All four mirror the live API. The fifth flag — bit4, the XML's
// `ignoreArrows` — is NOT here: its Lua surface is `SetAltArrowKeyMode`/`GetAltArrowKeyMode`
// below, which take a different argument coercion and answer a number rather than a boolean,
// so it cannot share this loop. benilla used to register a `SetIgnoreArrows` "convenience"
// for the loader to drive; 5875 has no such method (the 48-entry table
// `[0x87bb68, 0x87bce8)` carries no entry whose name contains "Ignore"), so publishing it was
// exactly the error decision 1189 names — and it stood in for the two real names, which were
// missing. The loader drives the XML attribute through `SetAltArrowKeyMode` now.
// ── SetAltArrowKeyMode / GetAltArrowKeyMode (`0x7996e0` / `0x799790`) ────────────────────
// The XML's fifth flag, `ignoreArrows`, is `SetAltArrowKeyMode` in Lua; 1.12 has no
// `SetIgnoreArrows` (the 48-entry table `[0x87bb68, 0x87bce8)`).
//
// **The setter's argument is `GetBoolOrDefault(L, 2, default = 1)`** (`0x6f1c10`), not Lua
// truthiness and not a plain numeric coercion — `0x7996e0` pushes the default `1` before the
// call, so **an absent argument ENABLES** the mode. `nil` disables; a number goes through
// `__ftol` so `0` and `0.5` are false and `-1` is true; `""` matches nothing in the
// off/disabled/on/enabled chain and falls to the default, so it ENABLES; `"0"` disables.
// [`crate::script::binding_abi::bool_or_default`] already models every arm.
//
// **The getter answers the NUMBER 1 or nil**, never a boolean (`0x799815`: the set arm pushes
// the double 1.0 via `0x6f3810`, the clear arm pushes nil via `0x6f37f0`) — the same idiom as
// `IsShiftKeyDown`. An addon writing `if box:GetAltArrowKeyMode() then` reads either the same;
// one writing `== 1` only reads the number.
// SetAltArrowKeyMode (`0x7996e0`) reads `GetBoolOrDefault` (`0x6f1c10`) with default 1: no
// argument enables, nil disables, a number truncates (0 and 0.5 off, -1 on), `""` takes the
// default and `"0"` disables. GetAltArrowKeyMode (`0x799790`) answers the number 1 or nil,
// never a boolean (`0x799815`).
m.set(
"SetAltArrowKeyMode",
lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| {
// `MultiValue`, not `Value`, because the reference DISTINGUISHES absent from nil here
// and mlua's `Value` cannot: `0x7996e0` pushes the default `1` before the call, so
// `SetAltArrowKeyMode()` enables while `SetAltArrowKeyMode(nil)` disables.
// `MultiValue`, since a `Value` cannot tell no argument (on) from nil (off).
let args: Vec<Value> = args.into_iter().collect();
let on = crate::script::binding_abi::bool_or_default(args.first(), true);
with_editbox(lua, &this, |eb| eb.alt_arrow_key_mode = on)?;
@@ -370,9 +294,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
let on = !matches!(v, Value::Nil | Value::Boolean(false));
with_editbox(lua, &this, |eb| set(eb, on))?;
if refresh_justify {
// The text-anchoring law reads multiLine (TOP vs MIDDLE), and the loader
// wires the declared `<FontString>` BEFORE the editbox flags (LoadXML order
// 5·b vs 5b) — re-seat an already-wired region so `multiLine="true"` lands.
// A multi-line box anchors its text TOP, else MIDDLE, and the loader wires the
// `<FontString>` before the flags, so re-seat it.
super::refresh_text_region_justify(lua, &this)?;
}
Ok(())
@@ -386,43 +309,14 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
}
/// **The font block — entries #0–#15 of the EditBox method table**, and the largest single gap the
/// per-kind widget-method census found in the 218-addon corpus (decision 1229).
///
/// `EditBox` is one of the six text-bearing types, so it re-declares the whole font block in its own
/// flat table (`.data 0x87bb68`, 48 entries, count read from `mov edx,0x30` at `0x799ab5`; there is
/// no `FontInstance` class in the 1.12 Lua chain). We had written every one of these verbs already —
/// on `FontString` and on the `<Font>` object — and never wired them to the kind that wanted them.
/// The census row is `63 EditBox:SetFontObject (on Texture, FontString)`, and its `(on …)` tail is
/// exactly that: *a verb we have, on the wrong kinds*.
///
/// **The 63 is one library, not 63 independent addons.** Every one of those call sites is the same
/// three lines of an embedded `Dewdrop-2.0.lua` —
/// ```lua
/// local editBox = CreateFrame("EditBox", nil, editBoxFrame)
/// editBoxFrame.editBox = editBox
/// editBox:SetFontObject(ChatFontNormal)
/// ```
/// — vendored into 63 addon folders (65 copies of the file: `FuBar` and its ~50 plugins, `BigWigs`,
/// `AtlasLoot`, `oRA2`, …). One library replicated, so 63 chances to hit the *same* next wall
/// (decision 1207).
///
/// Ten of the sixteen are the shared block and come from [`super::super::font_block`], which carries
/// the per-verb byte evidence and the return-shape traps. Two are deliberately absent and four are
/// installed here:
///
/// - **`SetSpacing`/`GetSpacing` (#10–#11) are NOT installed.** Nothing in this client models line
/// spacing, so they could only store a number no renderer reads — the silently-ignored-setter
/// failure of 1203/1205/1211 — while their absence raises a nil-value call that names itself.
/// Corpus demand is zero: `:SetSpacing(`/`:GetSpacing(` appear in **0** of 218 addons.
/// `script::font` withholds the same pair for the same reason.
/// - **The justify four (#12–#15) are installed here**, against the box's own
/// [`EditBoxState::justify`] word rather than its text region — the field's doc has the why (our
/// editbox draw law seats that region LEFT unconditionally) and states the divergence.
/// The font block, entries #0-#15 of the EditBox's own 48-entry method table (`0x87bb68`, count
/// at `0x799ab5`); 1.12 has no `FontInstance` class, so each text-bearing kind declares it. Ten
/// come from [`super::super::font_block`]. `SetSpacing`/`GetSpacing` (#10-#11) are not installed:
/// nothing renders line spacing, and a missing method raises by name. The justify four (#12-#15)
/// act on the box's own [`EditBoxState::justify`] word, not its text region.
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.
// Every font method acts on the box's implicit FontString (`[this+0x324]`, our
// `text_region`), created on demand.
crate::script::font_block::install(
lua,
m,
@@ -433,18 +327,9 @@ fn install_font_block(lua: &Lua, m: &Table) -> mlua::Result<()> {
"EditBox",
)?;
// SetJustifyH("LEFT"|"CENTER"|"RIGHT") / SetJustifyV("TOP"|"MIDDLE"|"BOTTOM") → 0 values.
//
// Two verified traps, both of which a plausible implementation gets wrong. This table was the
// only one that got them right; the FontString and `<Font>` 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")`
// yields 0x08, `0x08 & 0x07 == 0`, so it CLEARS justifyH and `GetJustifyH()` answers
// `"UNKNOWN"`. No error either way.
//
// `AceGUIWidget-Slider.lua:210` (`editbox:SetJustifyH("CENTER")`, three addons) is the demand.
// SetJustifyH/SetJustifyV return nothing. An unknown token raises `Usage:` (`0x87c77c`); a
// token of the other axis parses and masks to nothing, so `SetJustifyH("TOP")` clears justifyH
// and `GetJustifyH()` answers "UNKNOWN".
for (name, mask) in [
("SetJustifyH", EditBoxState::JUSTIFY_H_MASK),
("SetJustifyV", EditBoxState::JUSTIFY_V_MASK),
@@ -459,8 +344,8 @@ fn install_font_block(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
}
// GetJustifyH()/GetJustifyV() → exactly 1 string, the first set bit's token in the reference's
// own table order, or the literal "UNKNOWN".
// GetJustifyH/GetJustifyV: one string, the first set bit's token in the reference's table
// order, else "UNKNOWN".
for (name, mask) in [
("GetJustifyH", EditBoxState::JUSTIFY_H_MASK),
("GetJustifyV", EditBoxState::JUSTIFY_V_MASK),
@@ -475,10 +360,10 @@ fn install_font_block(lua: &Lua, m: &Table) -> mlua::Result<()> {
Ok(())
}
/// A `Set<Flag>` name paired with the EditBoxState mutator it drives (Lua truthiness).
/// A `Set<Flag>` method name and the field write it drives.
type FlagSetter = (&'static str, fn(&mut EditBoxState, bool));
/// The config-flag setters, in one place so the loader and the Lua surface share the list.
/// The four config-flag setters, each taking Lua truthiness.
fn flag_setters() -> [FlagSetter; 4] {
[
("SetAutoFocus", |eb, on| eb.auto_focus = on),
+23 -42
View File
@@ -1,21 +1,17 @@
//! The EditBox text-UI seam — the `impl UiScript` surface the host drives each frame: the
//! advance-table round trip (request → host measures → answer), the caret/selection/scroll
//! geometry derived from it, and the clipboard pair. Split beside its concern per the layout.rs
//! pattern (the measure-seam siblings live there).
//! The EditBox seam the host drives each frame: the advance-table round trip, the caret,
//! selection and scroll geometry derived from it, and the clipboard pair.
use crate::script::{types, UiScript};
use crate::widget::KindState;
impl UiScript {
/// The focused EditBox's advance-table request (the metrics half of the mouse/selection law):
/// `Some` when the box's DISPLAY string needs its per-byte cumulative widths measured (text or
/// font changed since the last answer). Answer via [`Self::set_editbox_advances`] before
/// [`Self::focused_editbox_text_ui`] — an empty display settles engine-side without a trip.
/// The focused EditBox's request to measure its display string's per-byte cumulative widths,
/// when its text, font, scale or wrap width changed; an empty display needs no trip. Answer it
/// with [`Self::set_editbox_advances`] before [`Self::focused_editbox_text_ui`].
pub fn editbox_advances_request(&mut self) -> Option<types::EditBoxAdvanceRequest> {
use std::hash::{Hash, Hasher};
let h = self.model_mut().focused_editbox?;
// Materialize the lazy text region first — its resolved font is the table's identity
// (a just-focused CreateFrame'd box has none until here).
// The lazy text region first: its font keys the table, and a new box has none until now.
let rh = super::ensure_text_region(self.lua(), h)?;
let mut model = self.model_mut();
let (display, multi_line) = match model.arena.frame(h).map(|f| &f.kind_state) {
@@ -26,15 +22,13 @@ impl UiScript {
let font = d.and_then(|d| d.font_path.clone());
let height = d.and_then(|d| d.font_height);
let outline = d.map(|d| d.outline).unwrap_or_default();
// The box's effective_scale rides the request ([`types::EditBoxAdvanceRequest::scale`]):
// the host measures at the drawn raster size so the advances land on the drawn glyphs.
// The host measures at the drawn raster size, so the advances land on the drawn glyphs.
let scale = model
.arena
.frame(h)
.map(|f| f.effective_scale)
.unwrap_or(1.0);
// A multiline box also needs its wrap rows — at the text region's resolved width, the
// same width the draw wraps at. Folded into the cache key: a resize re-wraps.
// A multi-line box wraps at the text region's width, as the draw does; a resize re-keys.
let wrap_width = multi_line
.then(|| {
model
@@ -79,10 +73,8 @@ impl UiScript {
})
}
/// Answer an [`Self::editbox_advances_request`]: the per-byte cumulative laid-out widths of
/// the requested display string (len+1 entries, `[0] = 0`), plus the wrapped-row starts and
/// row pitch (single-line: `rows = [0]`, `cell_h` the line em — only a multiline box reads
/// them, but the answer is uniform).
/// Answer an [`Self::editbox_advances_request`]: the cumulative widths (len+1 entries from 0),
/// the wrapped-row starts and the row pitch, which only a multi-line box reads.
pub fn set_editbox_advances(
&mut self,
id: u32,
@@ -103,13 +95,9 @@ impl UiScript {
}
}
/// The focused EditBox's text-UI geometry for this frame (caret/highlight geometry is left to
/// the host): which Text quad is the box's (`target`), the scroll window to draw
/// (`display_from`), and the caret/selection x-spans within it — all advance-table-derived.
/// Clamps the scroll window against the text region's resolved width first (the
/// scroll-into-view invariant, `0x77da80`). `None` when nothing is focused, the box is
/// off-screen, or the advance table hasn't been answered yet (text still draws; the caret and
/// highlight sit that frame out).
/// The focused EditBox's text geometry this frame: its text quad, the scroll window and the
/// caret and selection spans in it, after scrolling the caret into view (`0x77da80`). `None`
/// until the advance table is answered: the text draws, without caret or highlight.
pub fn focused_editbox_text_ui(&mut self) -> Option<types::EditBoxTextUi> {
let h = {
let model = self.model_mut();
@@ -120,12 +108,10 @@ impl UiScript {
}
h
};
// Focus means "about to type": materialize the lazy text region (the same lazy
// constructor `sync_text_region` uses), so an empty just-focused box has a caret home.
// Materialize the lazy text region, so an empty, just-focused box has a caret home.
let rh = super::ensure_text_region(self.lua(), h)?;
let mut model = self.model_mut();
// The text region's resolved width; an unanchored region (a CreateFrame'd box without
// insets) draws over its owner, so the frame's own rect is its width.
// An unanchored text region draws over its owner, so the frame's own width stands in.
let avail = model
.region_resolved
.get(&rh)
@@ -141,12 +127,11 @@ impl UiScript {
return None;
}
if display.is_empty() {
// Trivial table — no host trip needed for an empty box.
eb.advances = vec![0.0];
eb.rows = vec![0];
}
// Keep the caret inside the window, leaving it a pixel to stand in at the right edge
// (multiline: pins the window to 0 — the box wraps instead).
// Keep the caret inside the window with room at the right edge; a multi-line box wraps
// instead, its window pinned at 0.
eb.clamp_scroll((avail - 2.0).max(0.0));
let from = eb.scroll_start.min(display.len());
let origin = eb.advances[from];
@@ -158,8 +143,7 @@ impl UiScript {
);
let (caret_row, caret_x, selection) = if eb.multi_line {
let (row, x) = eb.caret_row_x(cursor_d);
// The selection as per-row spans: each touched row contributes the intersection of
// its byte range with [da, db), x-measured from the row's own origin.
// One span per touched row, its overlap with [da, db), measured from the row's origin.
let mut spans = Vec::new();
if da != db {
for i in 0..eb.rows.len() {
@@ -197,9 +181,8 @@ impl UiScript {
})
}
/// Ctrl/Cmd+C for the focused EditBox: the selected substring for the OS clipboard (`None` =
/// no selection; a password box yields its mask run, never the real text — the client's fixed
/// placeholder `0x882748`).
/// Ctrl/Cmd+C: the focused EditBox's selection for the OS clipboard; a password box yields its
/// mask run, never the text, where the client copies the empty string (`0x882748`).
pub fn editbox_copy(&mut self) -> Option<String> {
super::copy_selection(self.lua())
}
@@ -209,11 +192,9 @@ impl UiScript {
super::cut_selection(self.lua())
}
/// Push one submitted line into the named box's recall history — the ref's
/// `ChatEdit_AddHistory` slot (the canonical slash line, added by the app's router AFTER it
/// parses the send; decision 0301). By name, not focus: the submit pipeline is asynchronous,
/// so by the time the router runs, the box has already cleared and dropped focus. Returns
/// `false` when `box_name` names no live EditBox.
/// Push a sent line, as its canonical slash line, into the named box's history where the
/// reference calls `ChatEdit_AddHistory`. By name: the router runs after the box has cleared
/// and lost focus. `false` when `box_name` is no live EditBox.
pub fn editbox_add_history(&mut self, box_name: &str, line: &str) -> bool {
let lua = self.lua();
let Ok(t) = lua.globals().get::<mlua::Table>(box_name) else {
+93 -201
View File
@@ -1,8 +1,4 @@
//! Rust-driven tests of the EditBox runtime: focus acquisition + routing, text/cursor/
//! selection editing, the specialized script fires, caps, numeric/password, and text-region sync.
//! Frames are built programmatically via `CreateFrame("EditBox", …)` and driven through the public
//! keyboard API (`char_input`/`key_input`/`editbox_action`/`has_keyboard_focus`) and the Lua
//! method surface.
//! EditBox runtime tests, driven through the public keyboard API and the Lua methods.
use crate::script::{EditAction, EditUnit, QuadContent, UiScript};
@@ -10,7 +6,6 @@ fn script() -> UiScript {
UiScript::new().expect("construct UiScript")
}
/// The display text of the (single) text quad an extract produces, if any.
fn text_quad(s: &UiScript) -> Option<String> {
s.extract().into_iter().find_map(|q| match q.content {
QuadContent::Text { text, .. } => text,
@@ -20,21 +15,16 @@ fn text_quad(s: &UiScript) -> Option<String> {
// ── focus acquisition + routing ─────────────────────────────────────────────────────────────
/// **The self-acquire path**, path 2 of the two: a box that has had no show TRANSITION (created
/// already visible, so `visibility_focus` never runs for it) still takes the keyboard on the first
/// key or char event, and processes that same event. Path 1, focus-on-show, is
/// [`an_autofocus_box_takes_the_keyboard_when_it_is_shown`] below — this test's name used to assert
/// its absence, which stopped being true at decision 1686.
/// A box created visible has had no show transition, yet with autoFocus it takes the keyboard on
/// the first key or char and processes that event.
#[test]
fn an_autofocus_box_self_acquires_on_the_first_event() {
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetAutoFocus(true)"#)
.unwrap();
// No show transition has run for this box, so nothing owns the keyboard yet.
assert!(!s.has_keyboard_focus());
assert_eq!(s.focused_editbox_name(), None);
// The first char self-acquires focus AND processes that same event.
assert!(
s.char_input("a"),
"an autoFocus box consumes the acquiring event"
@@ -44,9 +34,8 @@ fn an_autofocus_box_self_acquires_on_the_first_event() {
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "a");
}
/// **An `autoFocus` box takes the keyboard when it is shown** — the OnShow vtable override
/// (`0x81c910` slot +0x30, `0x77a750`), missing here until decision 1686. Gated on nothing else
/// holding focus, and the gate is the whole of it — no topmost/best choice.
/// The OnShow override (`0x77a750`, vtable `0x81c910` slot +0x30) focuses an autoFocus box when
/// nothing holds focus; there is no topmost or best choice.
#[test]
fn an_autofocus_box_takes_the_keyboard_when_it_is_shown() {
let s = script();
@@ -67,7 +56,7 @@ fn an_autofocus_box_takes_the_keyboard_when_it_is_shown() {
"showing an autoFocus box focuses it",
);
// Hiding the box that holds the keyboard releases it (the mirror override, slot +0x34).
// Hiding the focused box releases the keyboard (the mirror override, slot +0x34).
s.run("E:Hide()").unwrap();
assert!(
!s.has_keyboard_focus(),
@@ -76,9 +65,8 @@ fn an_autofocus_box_takes_the_keyboard_when_it_is_shown() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The two gates, each shown to bite. A box that opted out does not take the keyboard on show; and
/// a box that would have is refused while another still holds it — the reference's
/// `if ([0xcf4dc8] == 0 && (flags & 1))`, both halves.
/// Focus on show takes both halves of `[0xcf4dc8] == 0 && (flags & 1)`: no focus held, and
/// autoFocus.
#[test]
fn the_show_focus_is_refused_without_autofocus_or_with_the_keyboard_taken() {
let s = script();
@@ -97,7 +85,6 @@ fn the_show_focus_is_refused_without_autofocus_or_with_the_keyboard_taken() {
"autoFocus=false is an opt-out that holds on show",
);
// Now give the keyboard away, and show an autoFocus box into an occupied focus.
s.run(
r#"
HOLDER = CreateFrame("EditBox", "Holder")
@@ -114,9 +101,7 @@ fn the_show_focus_is_refused_without_autofocus_or_with_the_keyboard_taken() {
"a shown autoFocus box does not steal a focus that is already held",
);
// And hiding a box that does NOT hold the keyboard leaves it where it is — `0x77e410`'s own
// per-box guard (`cmp ecx,eax; jne ret`), which is what makes the override's unconditional
// tail-jmp harmless.
// Hiding a box without the keyboard leaves focus alone (`0x77e410`'s per-box guard).
s.run("LATE:Hide()").unwrap();
assert_eq!(
s.focused_editbox_name().as_deref(),
@@ -129,9 +114,7 @@ fn the_show_focus_is_refused_without_autofocus_or_with_the_keyboard_taken() {
#[test]
fn non_autofocus_unfocused_box_ignores_input() {
let mut s = script();
// `SetAutoFocus(false)` explicitly: the ctor's `flags = 1` leaves autoFocus **ON** by default
// (`0x779a29`/`0x779a2e`, decision 1686), so a bare `CreateFrame("EditBox")` is an autoFocus
// box and would self-acquire on the first char. This test is about the other kind.
// The ctor leaves autoFocus on (`0x779a29`/`0x779a2e`), so a bare box would self-acquire.
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetAutoFocus(false)"#)
.unwrap();
assert!(!s.char_input("a"), "no focus, no autoFocus → not consumed");
@@ -149,7 +132,6 @@ fn focused_box_consumes_everything() {
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetFocus()"#)
.unwrap();
// Empty text: these do nothing, but a focused box still consumes them.
assert!(s.editbox_action(EditAction::Move {
unit: EditUnit::Char,
back: true,
@@ -210,7 +192,6 @@ fn click_focuses_regardless_of_autofocus_and_transition_order_is_lost_then_gaine
.unwrap();
s.resolve();
// Neither box has autoFocus, yet a click focuses each.
s.mouse_button(50.0, 10.0, "LeftButton", true);
s.mouse_button(50.0, 10.0, "LeftButton", false);
assert_eq!(s.focused_editbox_name().as_deref(), Some("A"));
@@ -226,10 +207,8 @@ fn click_focuses_regardless_of_autofocus_and_transition_order_is_lost_then_gaine
// ── text buffer + editing + OnTextChanged/OnTextSet ─────────────────────────────────────────
/// **`OnTextChanged` is deferred and coalesced** (decision 1831). An edit only raises the
/// `textChanged` dirty bit; the fire belongs to the drain (`0x77d3e0`) that the box's own OnUpdate
/// runs. So three typed characters are three marks on ONE box and produce exactly ONE fire — this
/// test asserted three before the law was checked.
/// An edit only raises the box's `textChanged` bit; `OnTextChanged` fires once, at the drain its
/// OnUpdate runs (`0x77d3e0`).
#[test]
fn typing_coalesces_into_one_deferred_ontextchanged() {
let mut s = script();
@@ -261,15 +240,12 @@ fn typing_coalesces_into_one_deferred_ontextchanged() {
);
assert_eq!(s.eval::<String>("return seen").unwrap(), "abc");
// Nothing is pending now, so a second drain fires nothing.
s.tick(0.0);
assert_eq!(s.eval::<i64>("return changed").unwrap(), 1);
}
/// The two fires part company (decision 1831): **`OnTextSet` is synchronous, inside `SetText`
/// itself** (`0x77be6b`), while `OnTextChanged` waits for the drain. The equality short-circuit
/// still suppresses both. So writing "hi", "hi", "bye" before any drain logs two `set`s and then a
/// SINGLE `changed` carrying only the last value — `A → B` coalesces.
/// `OnTextSet` fires inside `SetText` (`0x77be6b`), `OnTextChanged` at the drain; an unchanged
/// text fires neither.
#[test]
fn set_text_fires_ontextset_at_once_and_ontextchanged_at_the_drain() {
let mut s = script();
@@ -356,14 +332,13 @@ fn paste_inserts_at_the_cursor_and_replaces_the_selection() {
"#,
)
.unwrap();
// Cursor sits at end after SetText → paste appends.
// SetText leaves the cursor at the end, so the paste appends.
assert!(s.paste("CD"), "focused box consumes the paste");
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "abCD");
// Select-all then paste replaces the whole selection.
s.run("E:HighlightText(0, -1)").unwrap();
assert!(s.paste("xyz"));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "xyz");
// Both pastes marked the same box, so the drain owes exactly one fire — not one per paste.
// Both pastes marked the one box, so the drain fires once.
assert_eq!(s.eval::<i64>("return changed").unwrap(), 0);
s.tick(0.0);
assert_eq!(s.eval::<i64>("return changed").unwrap(), 1);
@@ -390,7 +365,6 @@ fn paste_strips_newlines_in_a_single_line_box_but_keeps_them_when_multiline() {
"onetwothree"
);
// Multi-line box: the newline survives (a tab is still a dropped control char).
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetMultiLine(true); E:SetFocus()"#)
.unwrap();
@@ -400,14 +374,12 @@ fn paste_strips_newlines_in_a_single_line_box_but_keeps_them_when_multiline() {
#[test]
fn paste_honors_max_letters_and_numeric() {
// maxLetters trims the paste from the end.
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetMaxLetters(3); E:SetFocus()"#)
.unwrap();
assert!(s.paste("abcdef"));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "abc");
// numeric aborts a paste that carries any non-digit (matching the typed-insert rule).
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetNumeric(true); E:SetFocus()"#)
.unwrap();
@@ -473,7 +445,7 @@ fn enter_escape_tab_space_fire_their_slots() {
)
.unwrap();
assert!(s.key_input("ENTER")); // single-line → OnEnterPressed
assert!(s.key_input("ESCAPE")); // fires, does NOT release focus
assert!(s.key_input("ESCAPE"));
assert_eq!(
s.focused_editbox_name().as_deref(),
Some("E"),
@@ -484,7 +456,7 @@ fn enter_escape_tab_space_fire_their_slots() {
let log: Vec<String> = s.eval("return log").unwrap();
assert_eq!(log, vec!["enter", "escape", "tab", "space"]);
// ENTER on a single-line box must NOT insert; only the space landed.
// Enter on a single-line box inserts nothing; only the space landed.
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), " ");
}
@@ -506,23 +478,10 @@ fn multiline_enter_inserts_a_newline_without_onspacepressed() {
assert_eq!(s.eval::<i64>("return spaces").unwrap(), 0);
}
/// **Alt-arrow mode: the two real verbs, and the flag they share with the XML attribute.**
///
/// The reference settles four things this pins:
///
/// · 5875 has **no `SetIgnoreArrows`** — the 48-entry EditBox method table
/// `[0x87bb68, 0x87bce8)` carries `SetAltArrowKeyMode`/`GetAltArrowKeyMode` at 46/47 and no
/// entry whose name contains "Ignore". benilla published the invented name for two rounds and
/// was missing both real ones.
/// · The XML attribute `ignoreArrows` and the Lua verbs drive **one** flag (`[E+0x318] & 0x10`).
/// · The setter's argument is `GetBoolOrDefault(L, 2, default = 1)` (`0x6f1c10`), not Lua
/// truthiness — an **absent** argument ENABLES, `""` ENABLES, `0` and `"0"` disable.
/// · The getter answers the **number 1 or nil**, never a boolean.
///
/// And the negative that matters: the engine core no longer swallows anything. The gate is on the
/// KEY and lives in the host (`UiKeyboardCapture::arrows_fall_through`), so an `EditAction` that
/// arrives here moves the caret whatever the flag says — it only ever arrives when ALT was held or
/// the key was not an arrow.
/// The method table `[0x87bb68, 0x87bce8)` has `SetAltArrowKeyMode`/`GetAltArrowKeyMode` at 46/47
/// and no `SetIgnoreArrows`; the XML `ignoreArrows` is the same flag (`[E+0x318] & 0x10`). The
/// gate is on the key, in the host (`UiKeyboardCapture::arrows_fall_through`), so an `EditAction`
/// that arrives here acts whatever the flag says.
#[test]
fn alt_arrow_key_mode_is_the_flag_and_the_engine_core_no_longer_swallows_moves() {
let mut s = script();
@@ -535,13 +494,11 @@ fn alt_arrow_key_mode_is_the_flag_and_the_engine_core_no_longer_swallows_moves()
)
.unwrap();
// The invented name is gone.
assert!(
s.eval::<bool>("return E.SetIgnoreArrows == nil").unwrap(),
"5875 has no SetIgnoreArrows — publishing it was decision 1189's error"
);
// The getter: a number or nil, never a boolean.
assert_eq!(
s.eval::<Option<i64>>("return E:GetAltArrowKeyMode()")
.unwrap(),
@@ -559,10 +516,10 @@ fn alt_arrow_key_mode_is_the_flag_and_the_engine_core_no_longer_swallows_moves()
"the set arm pushes the double 1.0 (0x6f3810), not a boolean"
);
// `GetBoolOrDefault(default = 1)`, arm by arm — three of these are backwards under Lua
// truthiness, which is why the coercion has its own helper.
// `GetBoolOrDefault` (`0x6f1c10`, default 1), arm by arm; three are backwards under Lua
// truthiness.
for (arg, want) in [
("", Some(1)), // ABSENT -> the default 1 -> enabled
("", Some(1)), // absent -> the default 1 -> enabled
("nil", None), // nil -> 0
("0", None), // __ftol(0) -> false
("-1", Some(1)), // __ftol(-1) != 0 -> true
@@ -579,8 +536,8 @@ fn alt_arrow_key_mode_is_the_flag_and_the_engine_core_no_longer_swallows_moves()
);
}
// The engine core does NOT swallow a Char move any more — the gate is on the key, upstream.
// `SetText` leaves the caret at the end, so one back-Char move puts it between 'b' and 'c'.
// A flagged box still acts on a Char move. `SetText` leaves the caret at the end, so one back
// move puts it between 'b' and 'c'.
s.run(r#"E:SetAltArrowKeyMode(1) E:SetText("abc")"#)
.unwrap();
assert!(s.editbox_action(EditAction::Move {
@@ -599,8 +556,8 @@ fn alt_arrow_key_mode_is_the_flag_and_the_engine_core_no_longer_swallows_moves()
);
}
/// The XML attribute is the same flag under its other name (`ignoreArrows` occurs once in the
/// image, at `0x879b78`, with one xref: the attribute push at `0x77a13a`).
/// The XML `ignoreArrows` is the same flag: its string (`0x879b78`) has one use, the attribute
/// read at `0x77a13a`.
#[test]
fn the_ignore_arrows_xml_attribute_is_alt_arrow_key_mode() {
let s = script();
@@ -640,20 +597,17 @@ fn get_number_parses_the_text() {
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetText("12.5")"#)
.unwrap();
assert_eq!(s.eval::<f64>("return E:GetNumber()").unwrap(), 12.5);
// Non-numeric text → 0.
s.run(r#"E:SetText("hello")"#).unwrap();
assert_eq!(s.eval::<f64>("return E:GetNumber()").unwrap(), 0.0);
}
// ── the EditBox override never fires generic OnChar/OnKeyDown ─────────────────────────────────
// ── the generic OnChar and OnKeyDown ─────────────────────────────────────────────────────────
#[test]
fn typing_fires_the_generic_on_char_with_what_was_inserted() {
// **The half of the old law that was wrong** (corrected 2026-08-29; decision 1686).
// `CSimpleEditBox` has its own input vtable which does not chain to the base — but Insert
// itself fires the generic `OnChar` slot (`+0x180`) at `0x77c13c`, through the **varargs**
// firer `0x7026f0` with fmt `"%s"` and the spliced string as the argument; the family's
// other member is the fixed-arity firer `0x702690`.
// The box's input vtable does not chain to the base, but Insert fires the generic `OnChar`
// (`+0x180`) at `0x77c13c`, through the varargs firer `0x7026f0` (not the fixed-arity
// `0x702690`) with the inserted string.
let mut s = script();
s.run(
r#"
@@ -673,7 +627,7 @@ fn typing_fires_the_generic_on_char_with_what_was_inserted() {
"OnChar fires once, with the inserted string",
);
// `SetText` is NOT an insert path and fires nothing — the seam the reference has too.
// `SetText` is not an insert and fires no `OnChar`, as in the reference.
s.run(r#"E:SetText("zzz")"#).unwrap();
assert_eq!(
s.eval::<String>("return table.concat(got, ',')").unwrap(),
@@ -685,10 +639,8 @@ fn typing_fires_the_generic_on_char_with_what_was_inserted() {
#[test]
fn key_paths_never_fire_the_generic_on_key_down() {
// The surviving half: the box's own key-down vtable (`0x77b160`) handles the event and never
// chains to the base, so a focused box's typing does not also run a generic `OnKeyDown` bound
// on that same box. Only the `OnChar` half of the original claim was corrected — this one
// stands.
// The box's key-down handler (`0x77b160`) never chains to the base, so a generic `OnKeyDown`
// on the focused box does not fire.
let mut s = script();
s.run(
r#"
@@ -710,7 +662,7 @@ fn key_paths_never_fire_the_generic_on_key_down() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
// ── history recall (`historyLines` / AddHistoryLine — decision 0288 P2) ──────────────────────
// ── history recall (`historyLines`, AddHistoryLine) ──────────────────────────────────────────
#[test]
fn history_recall_walks_up_and_down_and_restores_the_draft() {
@@ -758,8 +710,8 @@ fn typing_ends_the_history_browse() {
.unwrap();
s.editbox_action(EditAction::HistoryPrev);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "newer");
// A typed char turns the recalled line into an ordinary draft: the next UP starts a FRESH
// browse from the newest entry (stashing the edited line as the new draft).
// A typed char turns the recalled line into a draft: the next UP starts a fresh browse from
// the newest entry, stashing the edited line as the draft.
s.char_input("!");
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "newer!");
s.editbox_action(EditAction::HistoryPrev);
@@ -781,9 +733,8 @@ fn programmatic_set_text_keeps_the_browse_and_focus_gain_resets_it() {
"#,
)
.unwrap();
// Recall the newest, then rewrite the box the way the chat live parse does on a slash
// recall ("/g two" → Guild + "two"). The browse walk must survive: the next UP lands on
// the OLDER entry, not back on the newest (the "history only goes back 1" bug).
// Rewrite the recalled line as the chat parser does ("/g two" → Guild + "two"); the next UP
// must reach the older entry, not the newest again.
s.editbox_action(EditAction::HistoryPrev);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "/g two");
s.run(r#"E:SetText("two")"#).unwrap();
@@ -824,14 +775,14 @@ fn history_off_by_default_and_up_is_still_consumed() {
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetText("t"); E:SetFocus()"#)
.unwrap();
// No historyLines: AddHistoryLine is a no-op, UP consumed but inert (a focused box eats every
// key — both handlers `return 1` past the guard, `0x77a900`/`0x77b160`).
// No historyLines: AddHistoryLine does nothing, and UP is consumed but inert, since a focused
// box eats every key (both handlers return 1, `0x77a900`/`0x77b160`).
s.run(r#"E:AddHistoryLine("x")"#).unwrap();
assert!(s.editbox_action(EditAction::HistoryPrev));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "t");
}
// ── SetTextInsets (0288 P2 — the header-driven text-rect shrink) ─────────────────────────────
// ── SetTextInsets ────────────────────────────────────────────────────────────────────────────
#[test]
fn text_insets_shrink_the_text_region_rect() {
@@ -869,16 +820,13 @@ fn text_insets_shrink_the_text_region_rect() {
(rect.bottom - 53.0).abs() < 0.01,
"bottom inset 3: {rect:?}"
);
// The getter echoes.
let (l, r_, t, b) = s
.eval::<(f32, f32, f32, f32)>("return E:GetTextInsets()")
.unwrap();
assert_eq!((l, r_, t, b), (15.0, 13.0, 2.0, 3.0));
}
// The FontInstance half an EditBox inherits in the client: SetTextColor tints the box's text
// region (`ChatEdit_UpdateHeader` colors the typed text this way — its absence killed the chat
// header chunk mid-run and left the /w insets stale, the caret-in-the-header bug).
// SetTextColor tints the box's text region, as `ChatEdit_UpdateHeader` colours the typed text.
#[test]
fn set_text_color_tints_the_text_region() {
let mut s = script();
@@ -912,8 +860,8 @@ fn set_text_color_tints_the_text_region() {
// ── the host text-UI seam: advances, caret/selection geometry, mouse, clipboard ─────────────
/// Build a focused 200×32 box at BOTTOMLEFT(100,50), resolve it, type `text`, and answer its
/// advance table with a synthetic monospace 7 px/byte — the standard rig for the geometry tests.
/// A focused 200×32 box at BOTTOMLEFT(100, 50) holding `text`, its advances answered at 7 px per
/// byte.
fn seam_rig(s: &mut UiScript, text: &str) {
s.set_screen_size(800.0, 600.0);
s.run(
@@ -940,8 +888,7 @@ fn answer_advances(s: &mut UiScript) {
}
}
/// The seam reports caret geometry from the advance table, and vanishes with focus or
/// visibility — the old prefix-string seam's laws, now in pixels.
/// Caret geometry comes from the advance table, and there is none without focus or visibility.
#[test]
fn text_ui_reports_caret_geometry_and_focus() {
let mut s = script();
@@ -953,7 +900,6 @@ fn text_ui_reports_caret_geometry_and_focus() {
assert_eq!(ui.caret_x, 0.0);
assert_eq!(ui.display_from, 0);
assert!(ui.selection.is_empty());
// The empty box still emits its Text quad (Some("")) — the caret's ride-along.
assert!(
s.extract().iter().any(|q| matches!(
(&q.target, &q.content),
@@ -996,11 +942,10 @@ fn click_places_cursor_and_drag_selects() {
let ui = s.focused_editbox_text_ui().unwrap();
assert_eq!(ui.selection, vec![(0, 21.0, 49.0)]);
assert_eq!(ui.caret_x, 49.0);
// Drag back LEFT past the anchor to x=107 (byte 1): the selection flips to 1..3.
// Drag back left past the anchor to x=107 (byte 1): the selection flips to 1..3.
s.mouse_move(107.0, 60.0);
let ui = s.focused_editbox_text_ui().unwrap();
assert_eq!(ui.selection, vec![(0, 7.0, 21.0)]);
// Release ends the drag: further moves change nothing.
s.mouse_button(107.0, 60.0, "LeftButton", false);
s.mouse_move(170.0, 60.0);
assert_eq!(
@@ -1065,8 +1010,8 @@ fn ctrl_arrows_jump_words() {
);
}
/// Copy needs a selection; cut copies then deletes; the password box yields the mask run, never
/// the real text (the client's fixed placeholder `0x882748`, mask stand-in).
/// Copy needs a selection, cut copies then deletes, and a password box copies its mask run, never
/// the text (the client copies the empty string, `0x882748`).
#[test]
fn copy_cut_and_the_password_placeholder() {
let mut s = script();
@@ -1090,15 +1035,13 @@ fn copy_cut_and_the_password_placeholder() {
);
}
/// The OS-native delete family (no 1.12 counterpart — the host keymap's Option/Cmd/Ctrl
/// Backspace-Delete chords): a word delete takes the adjacent alnum run (+ the separators toward
/// it), an edge delete clears to the line end — and both collapse to "delete the selection" when
/// one exists, like every deletion gesture.
/// Word and edge deletes, the host's OS-native chords with no 1.12 counterpart: a word delete
/// takes the adjacent alphanumeric run and the separators toward it, an edge delete clears to the
/// line's end, and either deletes a live selection instead.
#[test]
fn word_and_edge_deletes() {
let mut s = script();
seam_rig(&mut s, "abc def ghi");
// Word-delete back from the end: "ghi" goes (boundary at 8).
s.editbox_action(EditAction::Delete {
unit: EditUnit::Word,
back: true,
@@ -1122,7 +1065,6 @@ fn word_and_edge_deletes() {
});
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), " ");
// Selection-first: with a live selection, a word delete removes exactly it.
s.run(r#"E:SetText("hello world"); E:HighlightText(2, 5)"#)
.unwrap();
s.editbox_action(EditAction::Delete {
@@ -1131,7 +1073,7 @@ fn word_and_edge_deletes() {
});
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "he world");
// Edge-delete back (macOS Cmd+Backspace): clears to the start — the whole line from the end.
// Edge-delete back (macOS Cmd+Backspace) from the end clears the whole line.
s.run(r#"E:SetText("clear me")"#).unwrap();
s.editbox_action(EditAction::Delete {
unit: EditUnit::Edge,
@@ -1148,8 +1090,8 @@ fn word_and_edge_deletes() {
assert_eq!(s.eval::<i64>("return n").unwrap(), 0);
}
/// SelectAll (the ref's Ctrl+A → `HighlightText(0, -1)` law): whole text selected, caret to the
/// end; typing replaces the lot.
/// SelectAll, the reference's Ctrl+A (`HighlightText(0, -1)`): the whole text selected, the caret
/// at the end, and typing replaces it all.
#[test]
fn select_all_action() {
let mut s = script();
@@ -1166,9 +1108,8 @@ fn select_all_action() {
);
}
/// The scroll window follows the cursor: typing past the box edge advances `display_from`
/// (whole chars); HOME snaps it back (`0x77da80`'s stay-visible invariant, the char-granular
/// `E+0x348` window).
/// The scroll window keeps the cursor visible (`0x77da80`) in whole chars: typing past the edge
/// advances `display_from`, HOME snaps it back.
#[test]
fn scroll_window_keeps_the_cursor_visible() {
let mut s = script();
@@ -1182,7 +1123,6 @@ fn scroll_window_keeps_the_cursor_visible() {
(40 - ui.display_from) as f32 * 7.0,
"caret x measures from the window origin"
);
// HOME: the window snaps back to 0.
s.editbox_action(EditAction::Move {
unit: EditUnit::Edge,
back: true,
@@ -1193,7 +1133,7 @@ fn scroll_window_keeps_the_cursor_visible() {
assert_eq!(ui.caret_x, 0.0);
}
/// The blink law (`0x77a790`): 0.5 s half-period toggle, reset shown by every edit.
/// The caret blink (`0x77a790`): a 0.5 s half-period, reset to shown by every edit.
#[test]
fn caret_blinks_on_tick_and_resets_on_edit() {
let mut s = script();
@@ -1205,19 +1145,15 @@ fn caret_blinks_on_tick_and_resets_on_edit() {
assert!(s.focused_editbox_text_ui().unwrap().caret_on);
s.tick(0.6);
assert!(!s.focused_editbox_text_ui().unwrap().caret_on);
// An edit shows the caret immediately (blink reset).
s.char_input("b");
answer_advances(&mut s);
assert!(s.focused_editbox_text_ui().unwrap().caret_on);
}
// ── A hyperlink is ONE keypress (`AdvanceTokens 0x77bb30`, decision 1077) ─────────────────────
// ── a hyperlink is one keypress (`AdvanceTokens 0x77bb30`) ─────────────────────
//
// The engine-level law lives in `markup`; these drive it the way a player does — through the
// focused box's public keyboard API — because that is the level the reported defect lived at:
// backspacing a shift-clicked item link chewed it one invisible byte at a time, and the byte that
// went first was the `r` of its trailing `|r`, which turned everything typed afterwards the item's
// colour.
// Driven through the keyboard API: a Backspace that took only the link's trailing `|r` would
// colour everything typed after it.
/// An epic link exactly as the client's own builder formats one (`0x52adb0`).
const LINK: &str = "|cffa335ee|Hitem:11684:0:0:0|h[Ironfoe]|h|r";
@@ -1243,8 +1179,7 @@ fn one_backspace_deletes_a_whole_item_link() {
}));
assert_eq!(text_of(&s), "", "colour prefix and trailing |r go with it");
// And from the far side of following text: the link survives until the text is gone, then goes
// whole — never half-eaten, which is what left a dangling `|c` colouring the rest of the line.
// Behind following text: the text goes first, then the whole link, never leaving a `|c`.
let mut s = box_with_link("ab");
for expected in ["|cffa335ee|Hitem:11684:0:0:0|h[Ironfoe]|h|ra", LINK, ""] {
s.editbox_action(EditAction::Delete {
@@ -1271,8 +1206,7 @@ fn one_arrow_crosses_a_whole_item_link() {
});
};
cursor(&mut s);
// One RIGHT from the start lands past the entire link — so typing there appends, and does not
// land between the `]` and its `|h`.
// One RIGHT from the start passes the whole link, so typing there appends.
s.char_input("x");
assert_eq!(text_of(&s), format!("{LINK}x"));
}
@@ -1305,8 +1239,7 @@ fn typing_strictly_inside_a_link_is_refused() {
LINK,
"the client swallows it rather than splitting the link"
);
// The same keystroke at the link's leading edge is allowed — the guard tests the PREVIOUS
// token too, which is what keeps the boundary usable.
// At the link's leading edge the keystroke lands: the guard also tests the previous token.
s.run("E:HighlightText(0, 0)").unwrap();
s.char_input("x");
assert_eq!(text_of(&s), format!("x{LINK}"));
@@ -1332,20 +1265,13 @@ fn max_letters_counts_visible_letters_not_escape_bytes() {
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetFocus(); E:SetMaxLetters(20)"#)
.unwrap();
s.run(&format!("E:Insert(\"{LINK}\")")).unwrap();
// 43 bytes, but "[Ironfoe]" is 9 letters — comfortably under a 20-letter cap that a raw char
// count would have blown through, trimming the link's own tail off.
// 43 bytes, but "[Ironfoe]" is 9 letters, under the 20-letter cap.
assert_eq!(text_of(&s), LINK);
assert_eq!(s.eval::<i64>("return E:GetNumLetters()").unwrap(), 9);
}
/// **Creating a box is not showing it** — and that is what keeps the on-show self-focus safe now
/// that autoFocus defaults ON (decision 1686). A frame born visible is not an effective-visibility
/// *transition*, so it never runs the OnShow override; only a real `Show()` does. Without this,
/// loading the shipped chain would hand the keyboard to whichever edit box happened to be
/// constructed first, and typing would go into it instead of to the game.
///
/// Pinned because it is load-bearing by *absence*: nothing in the on-show path mentions creation,
/// so the day frame construction starts firing OnShow, this is the test that says what it costs.
/// A box born visible has had no show transition, so the OnShow focus never runs for it; if it
/// did, loading the UI would hand the keyboard to the first edit box built.
#[test]
fn creating_a_box_does_not_focus_it_the_way_showing_one_does() {
let s = script();
@@ -1358,16 +1284,8 @@ fn creating_a_box_does_not_focus_it_the_way_showing_one_does() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **`SetMaxLetters` gates the COUNT and not the type** — one of four widget bindings in the whole
/// registrar that calls `lua_gettop` (`0x6f3070`), and its gate is exact (`cmp eax,2`), while the
/// value goes through a bare `lua_tonumber` (`0x6f3620`) with no `isnumber` guard.
///
/// That pairing is the opposite of the usual one, which is why it earns a test: benilla typed the
/// argument `i64` and so raised on `SetMaxLetters(nil)` — aux-addon's `gui/core.lua:288` writes
/// exactly that, and died at load on it — while accepting the wrong *number* of arguments
/// silently.
///
/// And `0` is **no limit**, not "no letters".
/// `SetMaxLetters` gates the exact count (`lua_gettop`, `0x6f3070`) and reads the value with a bare
/// `lua_tonumber` (`0x6f3620`); 0 is no limit.
#[test]
fn set_max_letters_gates_the_argument_count_and_coerces_the_value() {
let s = script();
@@ -1378,7 +1296,7 @@ fn set_max_letters_gates_the_argument_count_and_coerces_the_value() {
s.run("E:SetMaxLetters(12)").unwrap();
assert_eq!(max(&s), 12);
// nil is 0 is UNLIMITED — the call completes, which is the whole point.
// nil stores 0, which is unlimited.
s.run("E:SetMaxLetters(nil)").unwrap();
assert_eq!(max(&s), 0);
s.run(r#"E:SetMaxLetters(5) E:SetText("abcdefgh")"#)
@@ -1398,7 +1316,6 @@ fn set_max_letters_gates_the_argument_count_and_coerces_the_value() {
s.run("E:SetMaxLetters({})").unwrap();
assert_eq!(max(&s), 0);
// The COUNT is exact — too few AND too many both raise.
assert!(s.run("E:SetMaxLetters()").is_err(), "too few raises");
assert!(
s.run("E:SetMaxLetters(50, 60)").is_err(),
@@ -1406,17 +1323,9 @@ fn set_max_letters_gates_the_argument_count_and_coerces_the_value() {
);
}
/// **A `CSimpleEditBox` is born with FIVE regions, and `GetRegions` hands them to Lua before any
/// authored one.** The ctor builds the text FontString (`E+0x328`, `0x779bee`), three
/// selection-highlight `CSimpleTexture`s (`E+0x350/0x354/0x358`, loop `0x779c41`–`0x779c72`) and
/// the caret (`E+0x368`, `0x779c86`) — in that order. `GetRegions 0x773f60` walks
/// `[frame+0x1b8]`, one flat creation-ordered list, oldest first, no filter, and insertion is at
/// the TAIL. So the authored `<Layers>` regions start at index 6.
///
/// The report is pfUI's `skins/blizzard/friends.lua` l.379 —
/// `local _,_,_,_,_,left,right = GuildControlPopupFrameEditBox:GetRegions()` — which skips exactly
/// those five and takes the box's two border textures. It died on a nil `left` for as long as
/// benilla returned only the authored regions.
/// The ctor builds five regions in order, the text FontString (`0x779bee`), three selection
/// textures (`0x779c41`-`0x779c72`) and the caret (`0x779c86`), and `GetRegions` (`0x773f60`)
/// walks one creation-ordered list, so authored regions start at index 6.
#[test]
fn an_editbox_is_born_with_the_ctors_five_regions_ahead_of_its_authored_ones() {
let s = script();
@@ -1439,9 +1348,7 @@ fn an_editbox_is_born_with_the_ctors_five_regions_ahead_of_its_authored_ones() {
let report = crate::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
// Five engine regions, then the two authored textures — and NOT a sixth from the embedded
// `<FontString>`, which declares the ctor's object rather than adding one (the reference's
// EditBox LoadXML, `0x779fb0`).
// The embedded `<FontString>` declares the ctor's text region, not a sixth (`0x779fb0`).
assert_eq!(
s.eval::<i64>("return Box:GetNumRegions()").unwrap(),
7,
@@ -1453,7 +1360,6 @@ fn an_editbox_is_born_with_the_ctors_five_regions_ahead_of_its_authored_ones() {
"GetNumRegions is exactly the length GetRegions enumerates"
);
// pfUI's own read, verbatim in shape: skip five, take the authored pair.
let (left, right) = s
.eval::<(String, String)>(
"local _,_,_,_,_,l,r = Box:GetRegions() return l:GetTexture(), r:GetTexture()",
@@ -1465,7 +1371,6 @@ fn an_editbox_is_born_with_the_ctors_five_regions_ahead_of_its_authored_ones() {
);
assert_eq!(right, r"Interface\Right", "region 7 is the second");
// The first is the text FontString the box actually types into.
s.run(r#"Box:SetText("typed")"#).unwrap();
assert_eq!(
s.eval::<String>("local t = Box:GetRegions() return t:GetText()")
@@ -1473,8 +1378,7 @@ fn an_editbox_is_born_with_the_ctors_five_regions_ahead_of_its_authored_ones() {
"typed",
"region 1 is the ctor's embedded text FontString"
);
// 2..5 are the three selection quads and the caret: real regions, carrying no art of their own
// (benilla paints both host-side — the gap named on EditBoxState).
// 2..5, the selection quads and the caret, carry no art: benilla paints both host-side.
assert!(
s.eval::<bool>(
"local a,b,c,d,e = Box:GetRegions() \
@@ -1486,9 +1390,8 @@ fn an_editbox_is_born_with_the_ctors_five_regions_ahead_of_its_authored_ones() {
);
}
/// `SetNumber` is `SetText` with Lua's own `%.14g` in front of it — the two bindings are
/// byte-identical in the reference (`0x798690` / `0x7984c0`), so the numeric verb does no numeric
/// work of its own. Decision 1831; the expected strings are the reference's executed output.
/// `SetNumber` is `SetText` (`0x798690` and `0x7984c0` are byte-identical), so a number arrives
/// through Lua's `%.14g`; the expected strings are the reference's own output.
#[test]
fn set_number_formats_like_the_references_printf() {
let s = UiScript::new().unwrap();
@@ -1519,12 +1422,11 @@ fn set_number_formats_like_the_references_printf() {
);
}
// A STRING is accepted and passed through verbatim — the gate is `lua_isstring`, so the
// argument is never parsed as a number.
// A string passes verbatim: the gate is `lua_isstring`, and nothing parses it.
s.run(r#"box:SetNumber("abc")"#).unwrap();
assert_eq!(s.eval::<String>("return box:GetText()").unwrap(), "abc");
// Everything else raises, including an ABSENT argument. `luaL_error` does not return.
// Anything else raises, no argument included.
for bad in [
"box:SetNumber()",
"box:SetNumber(nil)",
@@ -1534,10 +1436,8 @@ fn set_number_formats_like_the_references_printf() {
}
}
/// A `numeric` box takes the reference's wholesale abort: the clear-all has already run when the
/// insert fails the digit test, so the box is left EMPTY rather than partly filled. The money
/// boxes (`MoneyInputFrame.xml`) are numeric, which is why this matters — though the money path
/// itself only ever passes non-negative integers.
/// A numeric box aborts the whole insert after the clear has run, so text that fails the digit
/// test leaves it empty, not partly filled.
#[test]
fn set_number_on_a_numeric_box_empties_it_when_the_text_is_not_all_digits() {
let s = UiScript::new().unwrap();
@@ -1547,7 +1447,6 @@ fn set_number_on_a_numeric_box_empties_it_when_the_text_is_not_all_digits() {
s.run("box:SetNumber(1234)").unwrap();
assert_eq!(s.eval::<String>("return box:GetText()").unwrap(), "1234");
// The minus sign fails the digit test, and the abort is wholesale.
s.run("box:SetNumber(-5)").unwrap();
assert_eq!(
s.eval::<String>("return box:GetText()").unwrap(),
@@ -1556,8 +1455,8 @@ fn set_number_on_a_numeric_box_empties_it_when_the_text_is_not_all_digits() {
);
}
/// `SetMaxBytes` (1960): the exact count gate and raw coerce of its sibling, a BYTE cap on the
/// buffer, and -1 as the no-limit sentinel where `SetMaxLetters` reads 0.
/// `SetMaxBytes`: `SetMaxLetters`'s exact count gate and raw coercion, a byte cap, and -1 as the
/// no-limit sentinel.
#[test]
fn set_max_bytes_caps_the_buffer_in_bytes_with_minus_one_unlimited() {
let s = script();
@@ -1585,14 +1484,10 @@ fn set_max_bytes_caps_the_buffer_in_bytes_with_minus_one_unlimited() {
assert!(s.run("E:SetMaxBytes(1, 2)").is_err(), "too many raises");
}
/// **`OnCursorChanged(x, y, w, h)` fires when the caret moves** — the edge the shipped
/// `MailFrame.xml` and `HelpFrame.xml` wire `ScrollingEdit_OnCursorChanged` to, and the reason a
/// multiline box follows its caret as you type past the bottom (decision 2141).
///
/// The four args are the reference's (`0x77dd5f`, each scaled by `f`): `x` the caret's
/// advance along its line, `y` **negative-downward** by row (which is what
/// `ScrollingEdit_OnCursorChanged`'s `cursorOffset = y` then `-this.cursorOffset` reads back as a
/// positive distance), `w` the constant `4.0`, `h` the line height.
/// `OnCursorChanged(x, y, w, h)` fires when the caret moves, with the reference's args
/// (`0x77dd5f`, each scaled): `x` the advance along the line, `y` negative downward by row, `w`
/// the constant 4, `h` the line height. Stock `ScrollingEdit_OnCursorChanged` reads `-y` as the
/// distance down.
#[test]
fn the_caret_flush_fires_on_cursor_changed_with_the_references_four_args() {
let mut s = script();
@@ -1612,14 +1507,13 @@ fn the_caret_flush_fires_on_cursor_changed_with_the_references_four_args() {
)
.unwrap();
s.resolve();
// Three bytes on row 0, then a wrap onto row 1 — the host answers the rows and the pitch.
// Three bytes on row 0, then a wrap onto row 1; the host answers the rows and the pitch.
for ch in "abcdef".chars() {
s.char_input(&ch.to_string());
}
s.resolve();
if let Some(req) = s.editbox_advances_request() {
// A plain monotonic 7 px/byte table (the rig's own), wrapped into two rows at byte 3 —
// `caret_row_x` subtracts the row start's cumulative advance, which is the whole point.
// 7 px per byte, wrapped into two rows at byte 3: `x` must count from the row's start.
let cum: Vec<f32> = (0..=req.text.len()).map(|i| i as f32 * 7.0).collect();
s.set_editbox_advances(req.id, req.key, cum, vec![0, 3], 12.0);
}
@@ -1643,9 +1537,8 @@ fn the_caret_flush_fires_on_cursor_changed_with_the_references_four_args() {
assert_eq!(w, 4.0, "w is the reference's constant, not a measurement");
assert_eq!(h, 12.0, "h is the line height");
// **The edge is a CHANGE.** A tick that moves nothing fires nothing — which is what makes
// `ScrollingEdit_OnUpdate`'s `if (this.cursorOffset)` loop terminate instead of re-scrolling
// every frame.
// It fires on a change only, which lets `ScrollingEdit_OnUpdate`'s `if (this.cursorOffset)`
// settle instead of scrolling every frame.
let before: i64 = s.eval("return table.getn(fires)").unwrap();
s.tick(0.016);
s.tick(0.016);
@@ -1655,7 +1548,6 @@ fn the_caret_flush_fires_on_cursor_changed_with_the_references_four_args() {
"two quiet ticks fire nothing"
);
// …and a caret move fires again.
s.editbox_action(EditAction::Move {
unit: EditUnit::Char,
back: true,