Files
benilla-octo/crates/benilla-ui/src/script/editbox.rs
T
SamandClaude Fable 5 993adf79b9 an idle frame skips the quiet UI, and a lighting hunt closes clean
- The UI stopped paying to discover that nothing changed. Layout used to hash
  ~1,900 frames' worth of inputs every frame just to decide "quiet"; it now
  tracks a mutation epoch and answers with one integer compare, and a settled
  frame reuses last frame's rendered quads instead of re-rasterizing every
  glyph. ~0.9 ms off every idle frame — with a verify mode, forced on across
  all 489 UI tests, that re-runs every skipped resolve in full and asserts it
  would have produced identical results.
- Across this run of snapshots, the one number everything is graded by: CPU
  work per frame at the crowded dungeon spot went from ~29 ms to ~16.5 ms.
- A lighting question got the full treatment and an honest answer. Units
  briefly took a different day/night lighting law; it read wrong on screen the
  same evening, and a build-by-build hunt a full week back confirmed the
  world's look never regressed in the first place — the change is reverted,
  nothing about the look changes in this snapshot, and the question stays
  parked until the one remaining unknown in the reference client is pinned.
- The character pane's model booth grew a headless eye — a dump of exactly
  what it draws, no live session needed — built for that hunt, kept for the
  next one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:56:26 +07:00

522 lines
26 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. `autoFocus` does NOT
//! focus on show — it only lets a box self-acquire on the first keyboard/char event while nothing is
//! focused (verified by absence: no OnShow/OnLoad `SetFocus` caller). A LeftButton click focuses
//! UNCONDITIONALLY.
//! - **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};
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.
insert(lua, h, text, 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);
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
}
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 {
// ignoreArrows: consumed but inert (guard `0x77b18e`) — arrows are the only chord
// source of Char moves, and the ref's Ctrl bypass maps to Word, which passes.
EditUnit::Char => {
if !with_eb(lua, h, |eb| eb.ignore_arrows).unwrap_or(false) {
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) — `ignoreArrows` does not gate them: the ref chat box ships BOTH ignoreArrows and
// historyLines, so arrows-as-history is exactly what that combination leaves.
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.
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");
}
/// `ClearFocus` (`0x77e410`): only if this box holds focus; fire `OnEditFocusLost`.
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);
fire_script(lua, id, "OnTextChanged");
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");
fire_script(lua, id, "OnTextChanged");
}
}
/// 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);
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
}
}
/// 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);
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
}
}
/// 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);
}
}
/// `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 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,
};
if existing.is_some() {
return existing;
}
let rh = model
.arena
.create_region(h, RegionKind::FontString, DrawLayer::Overlay, 0)?;
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);
model.region_data.insert(rh, 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_h = crate::script::JustifyH::Left;
data.justify_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")
.errors
.push(e.to_string());
}
}
// 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;