- 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>
757 lines
39 KiB
Rust
757 lines
39 KiB
Rust
//! 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");
|
||
}
|
||
}
|