Files
benilla-octo/crates/benilla-ui/src/script/editbox.rs
T
SamandClaude Opus 5.5 293b8e57fb creatures keep their first corner, and characters never share files
- a creature walking a path no longer cuts its first corner: the path's first waypoint was dropped at decode (2342)
- two characters whose names differ only in accented or non-Latin letters no longer share macros, bindings and SavedVariables; a file that failed to load is not overwritten at logout (2342)
- custom chat channels survive a logout, a collapsed quest header keeps its quests watched, and a trackpad swipe scrolls as far as it adds up to (2342)
- a hearth off a zeppelin no longer lands back on its deck, and an opened lockbox with loot left in it unlocks (2342)
- every snapshot runs on Vulkan, in a container, before it ships (2344)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:19:22 +07:00

757 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The `EditBox` runtime — keyboard focus, the text buffer + cursor + selection, editing, and the
//! key/char dispatch (`CSimpleEditBox`, factory `0x6eec70`).
//!
//! Grounded in wow-5875-re's byte-verified RF-0082 (`rf82-editbox-runtime.md`):
//!
//! - **Focus (§1):** a single class-owned focus owner ([`Model::focused_editbox`], the client's
//! `DAT_00cf4dc8`). `SetFocus` gates on effective-visibility, is a no-op if already focused, and
//! fires `OnEditFocusLost` on the old box then `OnEditFocusGained` on the new. A LeftButton click
//! focuses UNCONDITIONALLY — and **collapses the selection to the clicked byte index** on its way
//! (`0x77b86f call 0x77ccf0`, immediately before `0x77b881 SetFocus`), so a fresh click-focus leaves
//! an EMPTY selection at the click point, never a select-all. `SetFocus` itself writes no selection
//! field, and `0x77e3f6` is the only instruction image-wide that gives a box focus — so *every*
//! focus gain, whatever triggers it, is selection-neutral.
//!
//! **`autoFocus` DOES focus on show** (corrected 2026-08-29, wow-re `editbox-selection-focus-law.md`
//! §6): the OnShow override tail-jumps `SetFocus` when nothing else holds focus, and the OnHide
//! mirror tail-jumps `ClearFocus`. The old "verified by absence" negative came from a `call`-only
//! census that could not see a tail-`jmp`. Both overrides are [`visibility_focus`] here, and the
//! construction default is `true` off the bytes (see
//! [`EditBoxState::auto_focus`](crate::widget::EditBoxState::auto_focus)). The
//! self-acquire-on-first-key half stands beside it.
//! - **Routing (§2):** a focused box processes and CONSUMES every key/char (`return 1` past the
//! guard); an unfocused non-autoFocus box ignores input. The override fires ONLY the specialized
//! scripts (Enter/Escape/Space/Tab/TextChanged/TextSet/focus), never generic `OnKeyDown`/`OnChar`.
//! - **Text/editing (§3/§4):** every insert replaces the selection first; `numeric` aborts an insert
//! wholesale on any non-digit; caps trim from the end (`maxBytes` then `maxLetters`); `SetText`
//! short-circuits when unchanged; `HighlightText(0,-1)` selects all with the client's clamp.
//!
//! The mouse/selection/caret half (click→index `0x77d0d0`, drag `0x77a860`, the clipboard pair,
//! the 0.5 s blink, the char-granular scroll window) lives in [`interact`]/[`seam`] over the
//! host-answered advance table — decision 0298. The OS clipboard itself stays host-side (this
//! crate is engine-free): paste text arrives via [`paste`], copy/cut strings return through
//! `UiScript::editbox_copy`/`editbox_cut`; Ctrl+A arrives as the SOH control char.
//!
//! Editing keys reach the box as semantic [`EditAction`]s ([`action`]) — the *host's* per-OS
//! keymap decides which physical chord means which action (decision 0301), while the effect of
//! each action stays this module's byte-verified law. [`key_input`] keeps only the three
//! box-event keys (ENTER/ESCAPE/TAB).
//!
//! ## Stated divergences / gaps
//! - **`OnCursorChanged`** (the 4-float caret-pos fire) is not fired — no shipped XML consumes it.
//! - Small INFERRED corners (click rounding, word classes, no shift+click, the password-copy
//! placeholder literal) are flagged where they live — decision 0298's list.
//! - **Word/edge deletes** (`Delete{Word,Edge}`) have no 1.12 counterpart at all — they exist for
//! the host's OS-native keymaps (Option/Cmd+Backspace on macOS, Ctrl+Backspace/Delete on
//! Windows/Linux; decision 0301) and reuse the byte-verified word classes + selection-first
//! delete law.
use mlua::{Lua, Table, Value};
use super::object::frame_handle_of;
use super::types::{EditAction, EditUnit};
use super::{event, Model, RegionData};
use crate::layout::{Anchor, Point};
use crate::order::{self, DrawLayer, ZTarget};
use crate::widget::{EditBoxState, FrameHandle, FrameKind, KindState, RegionHandle, RegionKind};
/// Registry key of the EditBox method table (the MAXCSTACK discipline: Lua-side root, named key).
pub(super) const REG_EDITBOX_METHODS: &str = "__benilla_editbox_methods";
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Public entry points (called from UiScript::char_input / key_input / mouse_button)
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// A typed character. Routes per §1/§2; on a focused box, Ctrl+A (delivered as the SOH control char)
/// selects all, other C0 control chars are consumed-but-inert, and printable text is inserted. Always
/// consumes when a box is (or becomes) focused.
pub(super) fn char_input(lua: &Lua, text: &str) -> bool {
let Some(h) = route(lua) else {
return false;
};
if text == "\u{1}" {
// Ctrl+A → select-all (the client's keydown case 0x41/0x61; here via char_input since a
// Ctrl-modified letter arrives as its control code).
highlight_text(lua, h, 0, -1);
} else if text.chars().any(|c| (c as u32) >= 0x20) {
// A printable char (or string); pure control input is consumed but not inserted.
//
// A typed `|` goes in as `||` — OnChar `0x77c200` pushes the literal at `0x879cac`, which
// is `"||"` (decision 1077). That is why real markup can only enter a box through
// `SetText`, `Insert`, a paste or the C++ link-insert path: you cannot type an escape, and
// the doubled form draws as one `|` and counts as one letter.
insert(lua, h, &text.replace('|', "||"), true);
}
true
}
/// Paste an OS-clipboard string into the focused box. The engine-free runtime can't reach the OS
/// clipboard itself (RF-0082's stated gap), so the host reads it and hands the text here. Sanitized
/// the same way a typed char is gated — every C0 control char is dropped, except a newline in a
/// `multiLine` box — then inserted as one edit (selection-replace + numeric/caps rules via [`insert`],
/// no `OnSpacePressed` fire: a paste is not a typed space). Always consumes when a box is focused.
pub(super) fn paste(lua: &Lua, text: &str) -> bool {
let Some(h) = route(lua) else {
return false;
};
// The sanitation + insert is the shared law ([`EditBoxState::paste`]); only the event fire is
// this layer's.
if with_eb(lua, h, |eb| eb.paste(text)).is_some_and(|o| o.text_changed) {
sync_text_region(lua, h);
mark_text_changed(lua, h);
}
true
}
/// A non-character key by name — only the three *box-event* keys act (their FrameXML scripts);
/// every editing key reaches the box as a semantic [`EditAction`] via [`action`] instead. Routes
/// per §1/§2; a focused box consumes the key even when it does nothing with it.
pub(super) fn key_input(lua: &Lua, key: &str) -> bool {
let Some(h) = route(lua) else {
return false;
};
let id = frame_id_of(lua, h);
match key.to_ascii_uppercase().as_str() {
// multiLine: Enter inserts a newline (no OnSpacePressed); else fire OnEnterPressed.
"ENTER" => {
if with_eb(lua, h, |eb| eb.multi_line).unwrap_or(false) {
insert(lua, h, "\n", false);
} else {
fire_script(lua, id, "OnEnterPressed");
}
}
// Escape fires the script but does NOT release focus (FrameXML calls ClearFocus itself).
"ESCAPE" => fire_script(lua, id, "OnEscapePressed"),
"TAB" => fire_script(lua, id, "OnTabPressed"),
// Any other named key: a focused box still consumes it (past the guard), doing nothing.
_ => {}
}
true
}
/// One semantic editing operation — the host's per-OS keymap output (decision 0301). Same
/// routing/consumption law as [`key_input`]: the focused (or self-acquiring per §2) box processes
/// it; no box → not consumed.
pub(super) fn action(lua: &Lua, a: EditAction) -> bool {
let Some(h) = route(lua) else {
return false;
};
match a {
EditAction::Move { unit, back, extend } => match unit {
// No alt-arrow test here: the gate is on the KEY and lives in the host, which declines
// the four arrow codes before the chord is ever built (`editbox_alt_arrow_mode`).
// Anything reaching this arm was Alt-held or was not an arrow, and the reference moves
// the caret for both.
EditUnit::Char => move_horizontal(lua, h, !back, extend),
// Ctrl/Option picks the word-granular cursor helper (RF-0082 §4: "char- vs
// word-granular by the Ctrl check").
EditUnit::Word => interact::move_word(lua, h, !back, extend),
EditUnit::Edge => move_to_edge(lua, h, !back, extend),
},
EditAction::Delete { unit, back } => match unit {
EditUnit::Char => delete_dir(lua, h, !back),
EditUnit::Word => delete_span(lua, h, |eb| eb.word_boundary(!back)),
EditUnit::Edge => delete_span(lua, h, |eb| if back { 0 } else { eb.text.len() }),
},
EditAction::SelectAll => highlight_text(lua, h, 0, -1),
// History recall (the chat box's `historyLines`): prev = older, next = newer, live draft
// restored past the newest. Single-line only (benilla's multiLine box has no vertical
// caret nav — survey gap — a multiLine box consumes the step inert). The recall chords
// are the host keymap's plain Up/Down (rf82's history controller is untraced; decision
// 0301). The alt-arrow gate DOES cover them, upstream: UP and DOWN are two of the four
// codes it declines, so on a flagged box — which the reference's own chat box is —
// history recall is **Alt**+Up/Down and a plain Up/Down turns the camera. This file used
// to say the opposite ("`ignoreArrows` does not gate them"), which followed from reading
// the flag as consume-but-inert; the §5 corrected both halves.
EditAction::HistoryPrev | EditAction::HistoryNext => {
if !with_eb(lua, h, |eb| eb.multi_line).unwrap_or(false) {
history_step_key(lua, h, a == EditAction::HistoryPrev);
}
}
}
true
}
// The mouse/selection interaction law (click→index, drag-select, clipboard, blink) — a child
// module over this file's focus/editing primitives (RF-0082 §1/§4 + the diffed mouse leaves) —
// and the host-facing seam (`impl UiScript`: the advance round trip + text-UI geometry).
mod interact;
mod seam;
pub(super) use interact::{
click, copy_selection, cut_selection, drag_end, drag_update, tick_blink,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Focus model (RF-0082 §1)
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// Resolve the box that should process this key/char event, self-acquiring per §2 if needed.
///
/// - A live, effectively-visible focused box → process it.
/// - A focused box that is hidden (still alive) → `None`: the client's guard returns 0 and the focus
/// global stays set, so no other box self-acquires while it "holds" focus.
/// - A focused handle that went stale → cleared, then fall through to self-acquire.
/// - No focus → the topmost effectively-visible `autoFocus` EditBox self-acquires (firing
/// `OnEditFocusGained`) and processes this same event; otherwise `None` (not consumed).
fn route(lua: &Lua) -> Option<FrameHandle> {
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(h) = model.focused_editbox {
match model.arena.frame(h) {
Some(f) if f.effective_visible && f.kind == FrameKind::EditBox => return Some(h),
Some(_) => return None, // alive but hidden: block self-acquire, don't consume
None => model.focused_editbox = None, // stale: drop and self-acquire below
}
}
}
let h = topmost_autofocus(lua)?;
set_focus_handle(lua, h);
Some(h)
}
/// The topmost-drawn effectively-visible `autoFocus` EditBox (the keyboard self-acquire target), or
/// `None`. Walks the draw order top-down like [`crate::order::hit_test`].
fn topmost_autofocus(lua: &Lua) -> Option<FrameHandle> {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let sorted = order::traversal(&model.arena);
for (target, _) in sorted.iter().rev() {
if let ZTarget::Frame(fh) = *target {
if let Some(KindState::EditBox(eb)) = model.arena.frame(fh).map(|f| &f.kind_state) {
if eb.auto_focus {
return Some(fh);
}
}
}
}
None
}
/// `SetFocus` (`0x77e3d0`): gate on effective-visibility, no-op if already focused; else fire
/// `OnEditFocusLost` on the old box, move focus, fire `OnEditFocusGained` on the new.
///
/// `0x77e3f6` (inside this) is the ONLY instruction image-wide that grants a box the focus, and it
/// writes no selection field — so every focus gain in the client, whatever triggers it, is
/// selection-neutral.
fn set_focus_handle(lua: &Lua, h: FrameHandle) {
let (old_id, new_id) = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let focusable = matches!(
model.arena.frame(h),
Some(f) if f.effective_visible && f.kind == FrameKind::EditBox
);
if !focusable || model.focused_editbox == Some(h) {
return;
}
let old = model.focused_editbox;
model.focused_editbox = Some(h);
// A fresh focus starts a fresh history session: the browse position must not leak from
// the box's previous open (programmatic SetText deliberately preserves it — the chat
// live parse's recall rewrite — so this is where a stale walk gets dropped).
if let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
eb.end_history_browse();
}
(old.map(|o| model.frame_id(o)), model.frame_id(h))
};
if let Some(oid) = old_id {
fire_script(lua, oid, "OnEditFocusLost");
}
fire_script(lua, new_id, "OnEditFocusGained");
}
/// The EditBox's own **OnShow/OnHide vtable overrides** (`0x81c910` slots +0x30/+0x34), run by
/// [`crate::script::event::fire_visibility_changes`] after the frame's Lua handler — the order the
/// reference has, since both overrides call the base notify *first* and only then act.
///
/// - **Show** (`0x77a750`): `if ([0xcf4dc8] == 0 && (flags & 1)) SetFocus(this)` — an `autoFocus`
/// box grabs the keyboard when it appears, **iff nothing else holds it**. Not a
/// "topmost/best" choice: whichever box's show runs first while the focus is free takes it.
/// - **Hide** (`0x77a780`): tail-jumps `ClearFocus`, whose own guard makes it per-box — hiding a
/// box that does not hold the keyboard writes nothing and fires nothing.
///
/// Both were missing until 2026-08-29 (decision 1686). wow-re had published "autoFocus does NOT
/// focus on show — VERIFIED by enumerating callers" off a census written over `call` alone, which
/// cannot see the override's tail-`jmp`; benilla had transcribed the negative.
pub(super) fn visibility_focus(lua: &Lua, h: FrameHandle, visible: bool) {
if !visible {
// The guard lives in `clear_focus_handle`, exactly as it does in `0x77e410` — so this is
// called unconditionally here, like the reference's own unconditional tail-jmp.
clear_focus_handle(lua, h);
return;
}
let wants = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.focused_editbox.is_none()
&& matches!(
model.arena.frame(h).map(|f| &f.kind_state),
Some(KindState::EditBox(eb)) if eb.auto_focus,
)
};
if wants {
// `SetFocus` re-checks effective-visibility itself, which is the reference's gate too.
set_focus_handle(lua, h);
}
}
/// `ClearFocus` (`0x77e410`): only if this box holds focus; fire `OnEditFocusLost`. The guard is
/// `mov eax,ecx; mov ecx,[0xcf4dc8]; cmp ecx,eax; jne ret` — verified per-box, which is what makes
/// the OnHide override's *unconditional* tail-jmp into it harmless.
fn clear_focus_handle(lua: &Lua, h: FrameHandle) {
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.focused_editbox != Some(h) {
return;
}
model.focused_editbox = None;
model.frame_id(h)
};
fire_script(lua, id, "OnEditFocusLost");
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Editing primitives (RF-0082 §3/§4) — mutate state under one borrow, then sync + fire
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// Insert `ins` at the cursor (`0x77bee0`): replace any selection first; `numeric` aborts wholesale on
/// a non-digit; splice + advance; enforce caps; fire `OnTextChanged`, and (when `fire_space`) one
/// `OnSpacePressed` per space in `ins`.
fn insert(lua: &Lua, h: FrameHandle, ins: &str, fire_space: bool) {
let Some(out) = with_eb(lua, h, |eb| eb.insert(ins)) else {
return; // not an EditBox
};
if !out.text_changed {
return; // numeric-aborted
}
sync_text_region(lua, h);
let id = frame_id_of(lua, h);
// **The EditBox DOES fire generic `OnChar`** — with the spliced string as `arg1`, from inside
// Insert itself (`0x77c13c`, the varargs firer `0x7026f0` with fmt `"%s"`), before the dirty
// flush gets round to `OnTextChanged`. RF-0082's "never generic `OnKeyDown`/`OnChar`" was
// scoped to one member of a two-member fire family and missed the varargs half; the
// `OnKeyDown` (`+0x188`) half of that claim stands (wow-re, corrected 2026-08-29). It rides
// the one choke point every insert path goes through — typed char, the `|`→`||` escape, the
// multiLine Enter newline, the Lua `Insert` — and so is absent from `SetText`, which is where
// the reference has it absent too.
let on_char = lua
.create_string(ins)
.and_then(|s| event::fire_widget_handler(lua, id, "OnChar", vec![mlua::Value::String(s)]));
if let Err(e) = on_char {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
}
mark_text_changed(lua, h);
if fire_space {
for _ in 0..out.spaces {
fire_script(lua, id, "OnSpacePressed");
}
}
}
/// `SetText` (`0x77be00`): short-circuit when unchanged (no events); else clear selection, replace,
/// cursor to end, enforce caps, fire `OnTextSet` then `OnTextChanged`.
fn set_text(lua: &Lua, h: FrameHandle, s: &str) {
let changed = with_eb(lua, h, |eb| eb.set_text(s));
if changed == Some(true) {
sync_text_region(lua, h);
let id = frame_id_of(lua, h);
fire_script(lua, id, "OnTextSet");
mark_text_changed(lua, h);
}
}
/// Word/edge deletes (`Delete{Word,Edge}` — no 1.12 counterpart; the OS-native keymap family):
/// the selection first when one exists, else the span from the cursor to `target_of(eb)`; sync +
/// `OnTextChanged` when anything was removed.
fn delete_span(lua: &Lua, h: FrameHandle, target_of: impl FnOnce(&EditBoxState) -> usize) {
let changed = with_eb(lua, h, |eb| {
let t = target_of(eb);
eb.delete_to(t)
});
if changed == Some(true) {
sync_text_region(lua, h);
mark_text_changed(lua, h);
}
}
/// BACKSPACE / DELETE: delete the selection if non-empty, else the char before (`forward=false`) or
/// after (`forward=true`) the cursor; fire `OnTextChanged` if anything was removed.
fn delete_dir(lua: &Lua, h: FrameHandle, forward: bool) {
let changed = with_eb(lua, h, |eb| eb.delete_dir(forward));
if changed == Some(true) {
sync_text_region(lua, h);
mark_text_changed(lua, h);
}
}
/// One UP/DOWN history-recall step: ask the state for the line to show and route it through the
/// internal `set_text` path (region sync + `OnTextSet`/`OnTextChanged` — a recall resets FrameXML
/// tab-complete state exactly like any other text change). `set_text` (internal AND Lua) leaves
/// the browse position intact; typed edits, `AddHistoryLine`, and focus gain end browsing.
fn history_step_key(lua: &Lua, h: FrameHandle, older: bool) {
let recalled = with_eb(lua, h, |eb| eb.history_step(older)).flatten();
if let Some(text) = recalled {
set_text(lua, h, &text);
}
}
/// LEFT/RIGHT one char: `shift` extends the selection from the fixed anchor; otherwise the caret
/// collapses to the selection edge (if any) or moves one char. Fires no script (a cursor/selection
/// move; `OnCursorChanged` geometry is out of scope).
fn move_horizontal(lua: &Lua, h: FrameHandle, right: bool, shift: bool) {
with_eb(lua, h, |eb| eb.move_by_char(right, shift));
}
/// HOME/END: move the caret to 0 / `len`; `shift` extends from the fixed anchor.
fn move_to_edge(lua: &Lua, h: FrameHandle, end: bool, shift: bool) {
with_eb(lua, h, |eb| eb.move_to_edge(end, shift));
}
/// `HighlightText` (`0x77cca0`), the client's exact clamp: `start = clamp(start, 0..=len)`;
/// `end = (end < 0 || end > len) ? len : end`; then `if end < start { end = len }` — so `(0, -1)`
/// selects all. Offsets are bytes, snapped to char boundaries.
fn highlight_text(lua: &Lua, h: FrameHandle, start: i64, end: i64) {
with_eb(lua, h, |eb| eb.highlight_text(start, end));
}
// ── text-region sync (the '*' mask lives here, RF-0082 §3) ───────────────────────────────────
/// Write the *display* string into the text region's [`RegionData::text`], creating the region lazily
/// (the implicit FontString) if none exists. `password` shows one `'*'` per **character** (the mask,
/// never the real text).
fn sync_text_region(lua: &Lua, h: FrameHandle) {
let Some(rh) = ensure_text_region(lua, h) else {
return;
};
let display = with_eb(lua, h, |eb| {
if eb.password {
"*".repeat(eb.text.chars().count())
} else {
eb.text.clone()
}
});
if let Some(display) = display {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.region_data.entry(rh).or_default().text = Some(display);
model.touch_measure(rh);
}
}
/// `SetTextInsets(l, r, t, b)`: store the insets and re-anchor the text region inside the box by
/// them (y-up: the top inset pulls the region's top DOWN, the bottom inset pulls its bottom UP).
/// The chat edit box calls this per header change (`ChatEdit_UpdateHeader`'s
/// `SetTextInsets(15 + headerWidth, 13, 0, 0)`), so typed text starts past the "Say:" header.
fn set_text_insets(lua: &Lua, h: FrameHandle, l: f32, r: f32, t: f32, b: f32) {
if with_eb(lua, h, |eb| eb.text_insets = [l, r, t, b]).is_none() {
return;
}
let Some(rh) = ensure_text_region(lua, h) else {
return;
};
write_inset_anchors(lua, h, rh, [l, r, t, b]);
}
/// Anchor the text region inside its box by the insets — the two corner anchors that pin its rect
/// (y-up: the top inset pulls the region's top DOWN, the bottom inset pulls its bottom UP).
fn write_inset_anchors(lua: &Lua, h: FrameHandle, rh: RegionHandle, [l, r, t, b]: [f32; 4]) {
let owner = frame_id_of(lua, h);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let pair = [
Anchor::new(Point::TopLeft, owner, Point::TopLeft, l, -t),
Anchor::new(Point::BottomRight, owner, Point::BottomRight, -r, b),
];
let data = model.region_data.entry(rh).or_default();
let same = data.anchors.len() == 2
&& data
.anchors
.iter()
.zip(&pair)
.all(|(a, b)| super::object::anchor_bits_eq(a, b));
if !same {
data.anchors = pair.to_vec();
model.touch_layout();
}
}
/// The wrapper for an EditBox's **embedded** text FontString — the region its ctor built at
/// `0x779bee` (`Arena::build_editbox_engine_regions`). `None` when `frame` is not a live EditBox.
///
/// The loader's special-`<FontString>` pass uses this instead of `CreateFontString`: RF-0028 lists
/// `<FontString>` as the EditBox's *embedded* font string (its `bytes` attr writes the box's own
/// `maxBytes`), so the element DECLARES the ctor's object rather than adding a region. Creating a
/// second one would leave an orphan on the frame and push the authored `<Layers>` regions one place
/// down the creation-ordered list `GetRegions` walks.
pub(crate) fn editbox_text_region_wrapper(lua: &Lua, frame: &Table) -> Option<Table> {
let h = frame_handle_of(lua, frame).ok()?;
let rh = ensure_text_region(lua, h)?;
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.region_id(rh)
};
super::region::region_wrapper(lua, id).ok()
}
/// The loader's special-`<FontString>` slot assignment: adopt `region` as `frame`'s text region,
/// anchored by the current insets. The engine ASSIGNS an EditBox's font string at LoadXML — it
/// never searches the region list (a find-first here once grabbed a `<Layers>` FontString, the
/// chat header, so typing overwrote "Say:" and the insets clobbered the header's anchor).
pub(crate) fn adopt_text_region(lua: &Lua, frame: &Table, region: &Table) -> mlua::Result<()> {
let h = frame_handle_of(lua, frame)?;
let rh = super::region::region_handle_of(lua, region)?;
let Some((insets, multi_line)) = with_eb(lua, h, |eb| {
eb.text_region = Some(rh);
(eb.text_insets, eb.multi_line)
}) else {
return Ok(()); // not an EditBox — the loader guards on the tag, but stay safe
};
write_inset_anchors(lua, h, rh, insets);
// A live text region always carries Some(text) — an empty box still emits its Text quad, so
// the host's caret has a styled quad to ride before the first keystroke.
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let data = model.region_data.entry(rh).or_default();
data.text.get_or_insert_with(String::new);
apply_text_region_justify(data, multi_line);
Ok(())
}
/// Get-or-create the implicit text FontString (the EditBox's ButtonText analogue). Returns `None` if
/// `h` is not a live EditBox. An XML-declared `<FontString>` is wired explicitly by the loader's
/// special pass ([`adopt_text_region`]); the lazy create covers CreateFrame'd boxes.
pub(super) fn ensure_text_region(lua: &Lua, h: FrameHandle) -> Option<RegionHandle> {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let (existing, multi_line) = match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::EditBox(eb)) => (eb.text_region, eb.multi_line),
_ => return None,
};
// The ctor already built it (`Arena::build_editbox_engine_regions` — the client's own
// `0x779bee`, the first of the five regions a CSimpleEditBox is born with). The REGION exists
// from birth; its `RegionData` does not, because the arena has no access to it — so seed that
// here, once, on whichever call arrives first.
let rh = match existing {
Some(rh) => rh,
// A box whose ctor pass could not run (a dead handle, or a non-EditBox that slipped the
// guard above) still gets a region rather than silently rendering nothing.
None => model
.arena
.create_region(h, RegionKind::FontString, DrawLayer::Overlay, 0)?,
};
if let std::collections::hash_map::Entry::Vacant(slot) = model.region_data.entry(rh) {
let mut data = RegionData {
// Some("") from birth — the empty box still emits its Text quad for the host caret.
text: Some(String::new()),
..RegionData::default()
};
apply_text_region_justify(&mut data, multi_line);
slot.insert(data);
model.touch_layout(); // a region entered the layout gate's read set (decision 0740)
}
if let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
eb.text_region = Some(rh);
}
Some(rh)
}
/// Re-seat an already-wired text region's justify by the box's CURRENT multiLine flag — the
/// `SetMultiLine` rider: the loader adopts the declared `<FontString>` (LoadXML step 5·b) before
/// it applies the editbox flags (5b), so `multiLine="true"` must re-run the law or the body box
/// keeps the single-line MIDDLE seat.
pub(super) fn refresh_text_region_justify(lua: &Lua, this: &Table) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(KindState::EditBox(eb)) = model.arena.frame(h).map(|f| &f.kind_state) else {
return Ok(());
};
let (Some(rh), multi_line) = (eb.text_region, eb.multi_line) else {
return Ok(());
};
apply_text_region_justify(model.region_data.entry(rh).or_default(), multi_line);
Ok(())
}
/// The EditBox text-anchoring law: the box's text region lays out LEFT-justified (the client's
/// editbox draw `0x77da80` is left-anchored at the insets rect regardless of the font string's
/// declared justification — RF-0082's windowed draw, focused or not), from the TOP for a
/// multiline box, vertically centered for a single-line one. Without this, a bare
/// `<FontString inherits="ChatFontNormal"/>` 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.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 ─────────────────────────────────────────────────────────────────────
/// Run `f` over a frame's EditBox state under one short write borrow; `None` if not a live EditBox.
fn with_eb<T>(lua: &Lua, h: FrameHandle, f: impl FnOnce(&mut EditBoxState) -> T) -> Option<T> {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
match model.arena.frame_mut(h).map(|fr| &mut fr.kind_state) {
Some(KindState::EditBox(eb)) => Some(f(eb)),
_ => None,
}
}
/// The stable id of a frame handle (minting one if needed).
fn frame_id_of(lua: &Lua, h: FrameHandle) -> u32 {
lua.app_data_mut::<Model>()
.expect("model app_data")
.frame_id(h)
}
/// Fire one specialized EditBox script (no args beyond `self`); errors go to [`Model::errors`].
fn fire_script(lua: &Lua, id: u32, name: &str) {
if let Err(e) = event::fire_widget_handler(lua, id, name, Vec::new()) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
}
}
/// **The caret flush's fire — `OnCursorChanged(x, y, w, h)`** (`0x77da80`).
///
/// The reference recomputes the caret's anchor inside `0x77da80` and, having done so, fires this
/// with four floats *"each value scaled by `f`, so all four are in FrameXML UI units"* (wow-re
/// `system/ui/ui.md`, the RF-0085 caret law, VERIFIED):
///
/// * `x` — the caret's advance from its line's start, the same number the paint anchors by
/// ([`EditBoxState::caret_row_x`]).
/// * `y` — `−lineIndex · (snap(H·scale) + snap(spacing·scale))/scale`, i.e. minus the row index
/// times the row pitch. **Negative-downward**, which is what `ScrollingEdit_OnCursorChanged`'s
/// `cursorOffset = y` then `-this.cursorOffset` reads as a positive distance down the page.
/// * `w` — **`≡ 4.0`**, a constant in the reference (the caret texture's own width, read through
/// its layout vtable `+0x1c`).
/// * `h` — the font line height, read through `+0x20`.
///
/// Ours takes the row pitch [`EditBoxState::cell_h`] for both the `y` factor and `h`. The two are
/// the same number whenever the font declares no extra `spacing`, which is every box in the
/// shipped 1.12 UI; a box that *does* set spacing would report an `h` a little tall. Named rather
/// than guessed at — the alternative is carrying a second measured number through the advance
/// answer for a case nothing in the corpus produces.
///
/// **The edge is a CHANGE, not a frame.** `0x77da80` has exactly one caller, `0x77d475`, gated on
/// dirty bit 2, so the handler runs when the caret has actually moved — which is what makes
/// `ScrollingEdit_OnUpdate`'s `if (this.cursorOffset)` idiom terminate. [`EditBoxState::cursor_fired`]
/// is that bit's memory here.
///
/// **Scoped to the focused box**, because the caret geometry rides the advance table and only the
/// focused box asks the host for one ([`super::editbox::seam`]). The reference flushes any dirty
/// box; the difference is invisible for the customer this exists for — you are typing in the box
/// you are looking at — and an unfocused box's caret is never drawn either way.
pub(in crate::script) fn drain_cursor_changed(lua: &Lua) {
let fire = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(h) = model.focused_editbox else {
return;
};
if !model.arena.frame(h).is_some_and(|f| f.effective_visible) {
return;
}
let id = model.frame_id(h);
let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state)
else {
return;
};
// No answered advance table yet ⇒ no caret geometry to report. The next tick after the
// host answers carries it, exactly as the reference's next flush would.
let display = eb.display();
if eb.advances.len() != display.len() + 1 {
return;
}
let cursor_d = eb.text_to_display(eb.cursor).min(display.len());
let (row, x) = if eb.multi_line {
eb.caret_row_x(cursor_d)
} else {
(0, eb.advances[cursor_d])
};
if eb.cursor_fired == Some((row, x)) {
return;
}
eb.cursor_fired = Some((row, x));
let pitch = eb.cell_h;
(id, x, -(row as f32) * pitch, pitch)
};
let (id, x, y, h) = fire;
if let Err(e) = event::fire_widget_handler(
lua,
id,
"OnCursorChanged",
vec![
Value::Number(f64::from(x)),
Value::Number(f64::from(y)),
Value::Number(f64::from(CARET_WIDTH)),
Value::Number(f64::from(h)),
],
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
}
}
/// The caret's width as `OnCursorChanged` reports it — **a constant in the reference**, not a
/// measurement (wow-re: `w ≡ 4.0`).
const CARET_WIDTH: f32 = 4.0;
// The Lua method surface (SetText/GetText/HighlightText/SetFocus/…, consulted before the shared
// frame table only for EditBox frames) — a child module over this file's focus/editing
// primitives, like `interact`/`seam`.
mod methods;
pub(super) use methods::install;
#[cfg(test)]
mod tests;
/// Raise the `textChanged` dirty bit (`[E+0x31c]` bit 0) rather than firing — **`OnTextChanged` is
/// deferred**, and this is the whole of decision 1831's second half.
///
/// The reference never fires it from an edit. `SetText 0x77be00`, `Insert 0x77bee0` and the deletes
/// all only OR the bit; the single fire site is `0x77d498`, inside the dirty-word drain `0x77d3e0`,
/// whose three callers are the box's own `OnUpdate` (`0x77a790`), its `OnKeyDown` (`0x77b160`) and
/// its `OnMouseDown` (`0x77b800`). So a Lua caller gets control back with the handler still
/// unrun, and repeated changes **coalesce**: the box appears once on the pending list however many
/// times it is written, and the one fire carries the final text.
pub(super) fn mark_text_changed(lua: &Lua, h: FrameHandle) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if !model.dirty_editboxes.contains(&h) {
model.dirty_editboxes.push(h);
}
}
/// The dirty-word drain (`0x77d3e0`): fire the pending `OnTextChanged`s.
///
/// Called from the frame tick — the first of the reference's three callers (`0x77a7a1`, inside the
/// box's own `OnUpdate` `0x77a790`, which is also where our caret blink lives). Its other two, the
/// box's `OnKeyDown` and `OnMouseDown`, are **not** wired: they only make a pending fire arrive
/// sooner within a frame, and our key and mouse paths reach the tick anyway. Decision 1831 says so
/// out loud rather than leaving it to be discovered.
///
/// A box that is **not effectively visible stays pending**: `Hide`
/// splices it out of the chain the update walk follows, so its fire waits for a `Show` (verified
/// chain mechanics; the "hidden ⇒ no OnUpdate" step is the RE's own stated inference).
///
/// A handler may itself write to a box — including this one — so the list is taken before any Lua
/// runs and anything re-marked during the sweep lands on the NEXT drain rather than extending this
/// one. That is the reference's shape too: its bit is cleared before its fire.
pub(in crate::script) fn drain_text_changed(lua: &Lua) {
let pending = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.dirty_editboxes.is_empty() {
return;
}
let (ready, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut model.dirty_editboxes)
.into_iter()
.partition(|&h| model.arena.frame(h).is_some_and(|f| f.effective_visible));
// A frame that has been destroyed drops out of both halves.
model.dirty_editboxes = waiting
.into_iter()
.filter(|&h| model.arena.frame(h).is_some())
.collect();
ready
};
for h in pending {
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
}
}