dialogs come to the front, and the red line names what it needs

- dialogs carry the reference's toplevel bit, so the stack-split window opens above Bagnon instead of behind it
- the failure line fills its blank — "Requires Blacksmith Hammer", not "Requires" — and Opening casts stop printing a label the spell itself marks "no text"
- the pet bar's Attack/Follow/Stay tooltips name their keybinding, and typed text reaches the focused frame through the client's own delivery walk
- AtlasLoot parses again: our Lua keeps 5.0's compatibility semicolon

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 19:38:18 +07:00
co-authored by Claude Fable 5
parent 606a72bde5
commit 2c1ffca2ca
57 changed files with 2163 additions and 275 deletions
+32 -7
View File
@@ -459,13 +459,38 @@ fn get_number_parses_the_text() {
#[test]
fn key_and_char_paths_never_fire_generic_handlers() {
// OnKeyDown/OnChar aren't even in SCRIPT_KINDS, so SetScript rejects them — proving the runtime
// has no generic keyboard slots to fire (the C++ override replaced them).
let s = script();
let err = s
.run(r#"CreateFrame("EditBox", "E"):SetScript("OnChar", function() end)"#)
.unwrap_err();
assert!(err.to_string().contains("OnChar"), "{err}");
// The law (wow-re `frame-key-script-delivery.md` §1): `CSimpleEditBox` has its OWN input
// vtable (`0x77a900` char / `0x77b160` key-down) which handles the event and **never chains to
// the base implementation** — so a focused box's typing does not also run a generic `OnChar`
// bound on that same box.
//
// This used to be proved by `SetScript("OnChar", …)` *raising*, which it no longer does
// (decision 1319 made the three key names real). The invariant is unchanged and is now
// asserted directly: bind the generic handler, type into the focused box, and watch the text
// arrive with the handler never called.
let mut s = script();
s.run(
r#"
fired = 0
E = CreateFrame("EditBox", "E")
E:EnableKeyboard(true)
E:SetScript("OnChar", function() fired = fired + 1 end)
E:SetFocus()
"#,
)
.unwrap();
assert!(s.char_input("k"), "the focused box consumed the character");
assert_eq!(
s.eval::<String>("return E:GetText()").unwrap(),
"k",
"…by inserting it through its own path"
);
assert_eq!(
s.eval::<i64>("return fired").unwrap(),
0,
"the box's own vtable never chains to the generic OnChar"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
// ── history recall (`historyLines` / AddHistoryLine — decision 0288 P2) ──────────────────────
+17
View File
@@ -162,6 +162,23 @@ pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec<FrameHandle>) {
}
}
/// Is a handler bound under `script` for this frame id? — the **existence** question, asked
/// without firing anything.
///
/// It exists for the keyboard walk ([`super::keyboard`]), where the reference's consumption gate is
/// the presence of the slot rather than anything the handler does: `0x76b7d0` reads `[+0x188]`/
/// `[+0x190]`, consumes on either, and fires only the first. A lookup failure reads as "absent" —
/// the gate must never be able to raise out of the middle of a walk.
pub(super) fn has_widget_handler(lua: &Lua, id: u32, script: &str) -> bool {
let Ok(scripts) = lua.named_registry_value::<Table>(REG_SCRIPTS) else {
return false;
};
match scripts.get::<Value>(id) {
Ok(Value::Table(per)) => matches!(per.get::<Value>(script), Ok(Value::Function(_))),
_ => false,
}
}
/// The one firing path. `event_name` is the `event` global + first modern positional (OnEvent only);
/// `extra` are the `arg1..argN` globals + the trailing modern positionals. Globals are saved before
/// and restored after (even on error) — nesting-safe. Holds no model borrow across the call.
+230
View File
@@ -0,0 +1,230 @@
//! **Frame keyboard delivery** — the walk, the existence gate, and the key-name argument.
//!
//! Until this module, keys went straight to the focused EditBox and a plain frame could not receive
//! a key at all: `OnChar`/`OnKeyDown`/`OnKeyUp` were the three script names
//! [`super::object::events_regions`] deliberately *raised* on, because accepting a name this engine
//! never fires is the silent-drop class 1203/1205/1211 each recorded. This is the machinery that
//! note was waiting on, so those three names are now accepted there.
//!
//! ## The law (wow-re `system/ui/scratch/frame-key-script-delivery.md`, §5 trio — VERIFIED)
//!
//! An OS key event reaches **one dispatcher per channel**, and the dispatcher — not the frame — is
//! what walks: `0x765f10` (key-down, kind-1 buckets, vtable `+0x60`) and `0x765df0` (char, kind-0
//! buckets, `+0x5c`). Each walks **9 strata records, TOOLTIP (8) down to WORLD (0)**, and within a
//! record the frame array **index 0 upward**, which the inserter `0x764aa0` keeps ordered by
//! **level descending, ties oldest-registration-first**. Every frame in that order is *called*
//! until one returns nonzero; that one **consumes** and both loops stop. So several frames may be
//! called, at most one consumes.
//!
//! **The consumption gate is EXISTENCE, not handling** (`0x76b7d0`/`0x76bba0`/`0x76b760`, §3):
//!
//! | slot state | key-down returns | fires |
//! |---|---|---|
//! | `OnKeyDown` set | **consume** | `OnKeyDown` |
//! | only `OnKeyUp` set | **consume** | *nothing* |
//! | neither | decline | nothing |
//!
//! That asymmetry is real and is transcribed below: a frame carrying only an `OnKeyUp` swallows
//! every key-down and runs no script. `OnChar` gates on its own slot alone; `OnKeyUp` on `+0x190`
//! alone (no `OnKeyDown` fallback). **A 1.12 handler cannot signal "handled"** — the fire's return
//! is discarded at all three call sites (§3.1) — so a Lua handler never influences consumption.
//!
//! **Bucket membership is the keyboard-enabled flag**, never the presence of a script (§3.2):
//! `EnableKeyboard(true)` on a script-less frame puts it *in* the walk, where it is called and
//! declines. XML `enableKeyboard` enables both kinds; an XML `<Scripts>` block auto-enables per
//! handler; **Lua `SetScript` auto-enables nothing**, so a runtime-created frame needs an explicit
//! `EnableKeyboard(true)` exactly as in the real client.
//!
//! ## Where the EditBox sits — it is a participant, not a separate stage
//!
//! The reference has no "editbox first" rule: `CSimpleEditBox` is a frame in the same buckets whose
//! own vtable (`0x77b160` key-down / `0x77a900` char) consumes while focused and never chains to
//! the base. Modelling it as a stage before or after the walk would get the order wrong whenever a
//! keyboard frame and the focused box are in different strata — a TOOLTIP-strata frame really does
//! pre-empt a focused HIGH-strata box, and a WORLD-strata one really does not. So [`walk`] visits
//! the focused box **at its own strata/level**, handing it to the RF-0082 routing this crate
//! already had ([`super::editbox`]) and taking a `true` as consumption.
//!
//! What is deliberately NOT re-derived here: the box's own focus acquisition (`autoFocus`
//! self-acquire, click-to-focus, `SetFocus`) stays entirely in `editbox`, and an event no frame
//! consumed still falls through to it — so every existing routing test holds unchanged.
//!
//! ## Not modelled, named
//!
//! The **sticky per-key-code key-up slot** (§2.1: `0x765fd0` routes key-up to `[root+code*4+0x84]`,
//! the last frame that consumed *some* down for that code, and does **not** clear it on delivery)
//! is not built: this engine's host feeds no key-up at all today. `OnKeyUp` is therefore accepted,
//! stored, gated in the table above — and never fired. That is stated rather than hidden, because
//! its one observable consequence is live: a frame with only an `OnKeyUp` still consumes key-downs.
use mlua::Lua;
use super::{event, Model};
use crate::widget::FrameHandle;
/// Which channel a dispatch is on — the two walks the reference registers separately.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Channel {
/// `0x765df0` — kind-0 buckets, vtable `+0x5c`, fires `OnChar` with the literal character.
Char,
/// `0x765f10` — kind-1 buckets, vtable `+0x60`, fires `OnKeyDown` with the key NAME.
KeyDown,
}
/// The walk order (§2): every keyboard-enabled, effectively-visible frame, **strata descending,
/// then level descending, then oldest registration first**.
///
/// Built as a sorted `Vec` per dispatch rather than kept as nine live buckets. The reference
/// maintains the buckets incrementally because it is walking them on every key at 1.12 frame
/// budgets; here a key press is a human-rate event and the candidate set is the *keyboard-enabled*
/// frames only — single digits in any real interface — so an index that must be kept coherent with
/// every Show/Hide/SetFrameLevel/SetFrameStrata would be pure drift surface for no measurable win.
/// The ORDER is the law; the storage is ours.
fn walk_order(model: &Model) -> Vec<FrameHandle> {
let mut candidates: Vec<(FrameHandle, u8, u16, u32)> = model
.arena
.iter_frames()
.filter(|(_, f)| f.effective_visible && f.keyboard_enabled)
.map(|(h, f)| (h, f.strata as u8, f.level, f.insertion_seq))
.collect();
// strata DESC, level DESC, insertion ASC — the inserter's own ordering (`0x764aa0`), where a
// frame goes before the first entry of strictly lower level and equal levels keep registration
// order.
candidates.sort_by(|a, b| b.1.cmp(&a.1).then(b.2.cmp(&a.2)).then(a.3.cmp(&b.3)));
candidates.into_iter().map(|(h, ..)| h).collect()
}
/// Does `frame` have a script bound under `name`?
///
/// The existence test the C++ gate performs on `+0x180`/`+0x188`/`+0x190` — asked of the script
/// registry, which is where this engine keeps those slots.
fn has_script(lua: &Lua, id: u32, name: &str) -> bool {
event::has_widget_handler(lua, id, name)
}
/// The dispatcher for one channel — [`Channel`]'s walk, gate and fire, in the reference's order.
///
/// Returns whether the event was **consumed**, which is what the caller must use to suppress the
/// keybinding system: consumption is decided by the C++ existence gate alone, never by what the
/// Lua handler did or returned (§3.1).
fn walk(lua: &Lua, channel: Channel, arg: &str) -> bool {
let order = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
walk_order(&model)
};
for h in order {
// The focused EditBox consumes through its OWN vtable, never the base one — so it is asked
// in its walk position and its answer is final for this event.
let is_focused_box = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.focused_editbox == Some(h)
};
if is_focused_box {
let consumed = match channel {
Channel::Char => super::editbox::char_input(lua, arg),
Channel::KeyDown => super::editbox::key_input(lua, arg),
};
if consumed {
return true;
}
continue;
}
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.frame_id(h)
};
match channel {
// `0x76b760`: gates on the OnChar slot alone.
Channel::Char => {
if has_script(lua, id, "OnChar") {
fire(lua, id, "OnChar", arg);
return true;
}
}
// `0x76b7d0`: the OR gate — either key slot consumes, only `OnKeyDown` fires.
Channel::KeyDown => {
let down = has_script(lua, id, "OnKeyDown");
if down || has_script(lua, id, "OnKeyUp") {
if down {
fire(lua, id, "OnKeyDown", arg);
}
return true;
}
}
}
}
false
}
/// Fire one key handler with its single string argument (`0x7026f0(frame, &slot, "%s", str)` — the
/// argument is a string on all three channels). Errors land in [`Model::errors`] like every other
/// widget handler; a raising handler must not eat the key press.
fn fire(lua: &Lua, id: u32, script: &str, arg: &str) {
let val = mlua::Value::String(match lua.create_string(arg) {
Ok(s) => s,
Err(e) => {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
return;
}
});
if let Err(e) = event::fire_widget_handler(lua, id, script, vec![val]) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
}
}
/// The char channel (`0x765df0`) — `arg1` is the literal character, UTF-8 (§4.1: the code is
/// UTF-8-encoded with no range guard, so a multi-byte codepoint arrives whole).
pub(super) fn char_input(lua: &Lua, text: &str) -> bool {
walk(lua, Channel::Char, text)
}
/// The key-down channel (`0x765f10`) — `arg1` is the key NAME, with no modifier prefix, from the
/// same table the keybinding chord uses (§4.2). The host speaks those names already
/// (`crate::script::UiScript::key_input`'s contract), so no decode happens here.
pub(super) fn key_input(lua: &Lua, key: &str) -> bool {
walk(lua, Channel::KeyDown, key)
}
/// The same key-down walk, for the keys this engine delivers to a **focused EditBox as a semantic
/// [`crate::script::EditAction`] chord** rather than by name — BACKSPACE, DELETE, the arrows, HOME,
/// END (decision 0301: the host's per-OS keymap owns which chord means what, so the box never sees
/// these as names).
///
/// Those keys still have to reach a keyboard frame — a dialog you type into needs its BACKSPACE —
/// and the ordering question that creates is real: whoever comes FIRST in the walk owns the key.
/// So this runs the identical order and **declines at the focused box** rather than skipping past
/// it: a frame above the box in walk order consumes and fires; a box above the frame ends the walk
/// with `false`, and the caller then dispatches its chord exactly as before. The one thing this
/// must never do is let a frame *below* the focused box steal the box's editing keys.
pub(super) fn frame_key_input(lua: &Lua, key: &str) -> bool {
let order = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
walk_order(&model)
};
for h in order {
{
let model = lua.app_data_ref::<Model>().expect("model app_data");
if model.focused_editbox == Some(h) {
return false; // the box owns this key; its chord path handles it
}
}
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.frame_id(h)
};
let down = has_script(lua, id, "OnKeyDown");
if down || has_script(lua, id, "OnKeyUp") {
if down {
fire(lua, id, "OnKeyDown", key);
}
return true;
}
}
false
}
+31 -3
View File
@@ -72,6 +72,7 @@ mod inspect;
mod item_stats;
mod item_text;
pub mod keybind;
mod keyboard;
mod layout;
mod loot;
mod loot_roll;
@@ -353,7 +354,7 @@ pub const SCREEN: crate::layout::Handle = 0;
/// corpus call sites across 91 addons), but it removes working behaviour from those 30 sites, so it
/// is a change to make deliberately with FrameXML fixed first — not a side effect of widening this
/// list.
const SCRIPT_KINDS: [&str; 32] = [
const SCRIPT_KINDS: [&str; 35] = [
"OnLoad",
"OnEvent",
"OnUpdate",
@@ -404,6 +405,16 @@ const SCRIPT_KINDS: [&str; 32] = [
// [`crate::layout::size_changed`]'s byte-verified epsilon test — see
// [`UiScript::resolve_layout`]. `OnSizeChanged(self, width, height)`.
"OnSizeChanged",
// The three KEY channels, unblocked by [`keyboard`]'s walk (wow-re
// `scratch/frame-key-script-delivery.md`, VERIFIED). They were the standing exception in
// [`object::events_regions`]'s note — accepted only once something fired them, which is that
// module's whole rule. `OnKeyUp` rides in with the other two deliberately: it is *gated* today
// (a frame carrying only an OnKeyUp consumes every key-down and runs nothing — the reference's
// own asymmetry) even though this engine's host feeds no key-up to fire it with, and accepting
// the name is what makes that consumption reachable.
"OnChar",
"OnKeyDown",
"OnKeyUp",
];
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -968,7 +979,11 @@ impl UiScript {
/// focused box, inserts it (numeric/cap/password rules apply) or — for the Ctrl+A control code —
/// selects all. Returns `true` if consumed (a focused box consumes every char).
pub fn char_input(&mut self, text: &str) -> bool {
editbox::char_input(&self.lua, text)
// The frame walk first ([`keyboard`]): the focused box is a PARTICIPANT in it, at its own
// strata/level, so this is not "frames before boxes" — it is the reference's one dispatcher
// in the reference's order. An event no frame consumed still falls through to the box
// routing, which owns focus acquisition (`autoFocus` self-acquire and kin).
keyboard::char_input(&self.lua, text) || editbox::char_input(&self.lua, text)
}
/// Paste text from the host OS clipboard into the focused EditBox. The engine-free runtime can't
@@ -985,7 +1000,20 @@ impl UiScript {
/// owns which chord means what). Routes per §1/§2; a focused box consumes the key even when
/// it does nothing with it. Returns `true` if consumed.
pub fn key_input(&mut self, key: &str) -> bool {
editbox::key_input(&self.lua, key)
// Same two-stage shape as `char_input` — see its note.
keyboard::key_input(&self.lua, key) || editbox::key_input(&self.lua, key)
}
/// A key the host delivers to a focused EditBox as an [`EditAction`] chord rather than by name
/// (BACKSPACE, DELETE, the arrows, HOME, END), offered to the **keyboard frames** first.
///
/// Returns `true` if a frame consumed it, in which case the caller must NOT also dispatch the
/// chord — and the key's binding must not fire either (consumption suppresses it, wow-re §3).
/// A `false` means either nothing wanted it or the focused box owns it; the caller proceeds
/// exactly as it did before this entry point existed. See [`keyboard::frame_key_input`] for
/// why declining at the box is the faithful answer rather than skipping it.
pub fn frame_key_input(&mut self, key: &str) -> bool {
keyboard::frame_key_input(&self.lua, key)
}
/// One semantic text-editing operation on the focused EditBox — the output of the host's
@@ -221,3 +221,65 @@ fn a_nested_long_string_parses_as_lua_50_does() {
.expect("nested long string must parse");
assert_eq!(v, "a [[b]] c");
}
/// **5.0's constructor compat-semicolon, restored** — the third dialect divergence (decision 1315).
///
/// Lua 5.0's `constructor()` opens its field loop with `testnext(ls, ';') /* compatibility
/// only */` — a Lua 4.0 leftover (4.0 separated a constructor's list part from its record part
/// with `;`) that silently eats ONE extra `;` per separator position inside a table constructor.
/// 5.1 deleted the line. AtlasLoot's `ButtonRegistry.lua` writes `Back_Title = AL["Factions"];;`
/// twenty times; the real client loads it (director-verified on the live 1.12.1 client), stock
/// 5.1 answered "unexpected symbol near ';'" and the addon's registry global never existed (B266).
#[test]
fn a_double_semicolon_inside_a_constructor_parses_as_lua_50_does() {
let s = script();
// The exact corpus shape: a record field, its `;` separator, and the compat extra.
let out: String = s
.eval(
r#"
local AL = { Factions = "Factions" }
local reg = {
["REP1"] = { Title = "The Aldor"; Back_Page = "REPMENU"; Back_Title = AL["Factions"];; };
}
return reg["REP1"].Back_Title
"#,
)
.expect("the ButtonRegistry shape must parse");
assert_eq!(out, "Factions");
// The compat skip runs at loop TOP, so a lone `;` before `}` — legal 5.0, an empty record
// part in 4.0 terms — is eaten the same way.
assert_eq!(s.eval::<i64>("local t = {;} return 1").unwrap(), 1);
// List context too: the separator may be `;` and the extra one is still absorbed.
let n: i64 = s.eval("local t = { 1;; 2 } return t[1] + t[2]").unwrap();
assert_eq!(n, 3);
}
/// **The restoration is exactly one semicolon wide, and constructor-only.** Both halves of the
/// boundary are the client's: 5.0 eats ONE extra `;` per position (a third dies in `listfield`),
/// and statement-level `;;` is rejected by 5.0 and 5.1 alike — the half wow-5875-re byte-verified
/// in the client's `luaL_loadbuffer` first (`system/ui/scratch/lua-dialect.md`).
#[test]
fn the_compat_semicolon_is_one_wide_and_statement_level_stays_rejected() {
let s = script();
let err = s
.eval::<i64>("local t = { 1;;; 2 } return t[1]")
.unwrap_err()
.to_string();
assert!(
err.contains("unexpected symbol"),
"a THIRD semicolon must still be a syntax error, as on 5.0: {err}"
);
let err = s
.eval::<i64>("local x = 1;; return x")
.unwrap_err()
.to_string();
assert!(
err.contains("unexpected symbol"),
"statement-level `;;` must stay rejected — that half is byte-verified: {err}"
);
}
+4 -7
View File
@@ -598,13 +598,10 @@ fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() {
s.run(r#"Raiser = CreateFrame("EditBox", "RaiserBox")"#)
.unwrap();
for name in [
// The FLAG exists (`EnableKeyboard`/`IsKeyboardEnabled`, 1252); what is still absent is the
// keyboard INDEX and the strata walk that would deliver a key to a frame carrying it, so
// these three stay unfired (wow-re `scripts-auto-enable.md` kinds 0/1). Being enabled is
// not being a handler — the carve is explicit that the two are separable.
"OnKeyDown",
"OnKeyUp",
"OnChar",
// `OnKeyDown`/`OnKeyUp`/`OnChar` are NOT in this list any more: decision 1319 built the
// delivery walk, so they are fired and therefore accepted — the rule this test enforces,
// applied in the other direction. Their law lives in `script::tests::keyboard`.
//
// Caret geometry is host-side; its four float args would all be zero.
"OnCursorChanged",
// 2.0's secure-frame system — no such slot exists in any 1.12 resolver.
@@ -0,0 +1,186 @@
//! The frame keyboard delivery law (decision 1319; wow-re
//! `system/ui/scratch/frame-key-script-delivery.md`, §5 trio — VERIFIED).
//!
//! Each test pins one clause that a plausible-but-wrong implementation gets backwards.
use super::common::script;
/// The bucket gate (§3.2): membership is the **keyboard-enabled flag**, not the presence of a
/// script. A Lua-created frame auto-enables nothing (the reference's `SetScript` doesn't either),
/// so its handler is unreachable until `EnableKeyboard(true)` puts it in the walk.
#[test]
fn a_key_script_alone_does_not_put_a_frame_in_the_walk() {
let mut s = script();
s.run(
r#"
got = nil
f = CreateFrame("Frame", "KbUnenabled")
f:SetScript("OnChar", function() got = arg1 end)
"#,
)
.unwrap();
assert!(
!s.char_input("7"),
"an unenabled frame is not in the bucket"
);
assert_eq!(s.eval::<Option<String>>("return got").unwrap(), None);
s.run("f:EnableKeyboard(true)").unwrap();
assert!(
s.char_input("7"),
"enabled: now in the walk, and it consumes"
);
assert_eq!(
s.eval::<Option<String>>("return got").unwrap().as_deref(),
Some("7")
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The existence gate's asymmetry (§3): a frame carrying **only** an `OnKeyUp` consumes every
/// key-down and runs nothing. This is the clause an implementation "tidies away" — and doing so
/// silently un-suppresses that frame's keybindings.
#[test]
fn only_an_onkeyup_still_swallows_the_key_down() {
let mut s = script();
s.run(
r#"
downs = 0
f = CreateFrame("Frame", "KbUpOnly")
f:EnableKeyboard(true)
f:SetScript("OnKeyUp", function() downs = downs + 1 end)
"#,
)
.unwrap();
assert!(s.key_input("ESCAPE"), "the OnKeyUp slot alone consumes");
assert_eq!(
s.eval::<i64>("return downs").unwrap(),
0,
"…and fires nothing"
);
// Neither slot: declines outright.
s.run("f:SetScript(\"OnKeyUp\", nil)").unwrap();
assert!(!s.key_input("ESCAPE"), "no key slot at all: declines");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The walk order (§2): **strata descending first**, and only then level. A LOW frame can never
/// take a key from a HIGH one, however high its level — the same "a raise never changes stratum"
/// shape the toplevel law has.
#[test]
fn the_walk_is_strata_then_level_then_registration() {
let mut s = script();
s.run(
r#"
winner = nil
function mk(name, strata, level)
local f = CreateFrame("Frame", name)
f:SetFrameStrata(strata)
f:SetFrameLevel(level)
f:EnableKeyboard(true)
f:SetScript("OnKeyDown", function() winner = name end)
return f
end
-- registered first, but the LOWEST stratum: must lose despite its huge level
mk("KbLow", "LOW", 99)
mk("KbHigh", "HIGH", 1)
"#,
)
.unwrap();
assert!(s.key_input("ESCAPE"));
assert_eq!(
s.eval::<String>("return winner").unwrap(),
"KbHigh",
"strata beats level"
);
// Within one stratum: higher level wins, and equal levels keep registration order.
s.run(
r#"
winner = nil
mk("KbHighTop", "HIGH", 5)
mk("KbHighTie", "HIGH", 5)
"#,
)
.unwrap();
assert!(s.key_input("ESCAPE"));
assert_eq!(
s.eval::<String>("return winner").unwrap(),
"KbHighTop",
"level 5 beats level 1; the earlier of the two 5s wins the tie"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **At most one frame consumes** (§2, "Delivery"): the walk stops at the first consumer, so a
/// second keyboard frame below it never sees the key.
#[test]
fn the_first_consumer_ends_the_walk() {
let mut s = script();
s.run(
r#"
seen = {}
function mk(name, strata)
local f = CreateFrame("Frame", name)
f:SetFrameStrata(strata)
f:EnableKeyboard(true)
f:SetScript("OnChar", function() table.insert(seen, name) end)
end
mk("KbTip", "TOOLTIP")
mk("KbDlg", "DIALOG")
"#,
)
.unwrap();
assert!(s.char_input("x"));
assert_eq!(s.eval::<i64>("return table.getn(seen)").unwrap(), 1);
assert_eq!(s.eval::<String>("return seen[1]").unwrap(), "KbTip");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A hidden frame is in no bucket (the link gate the walk shares with the draw order), so it
/// neither fires nor consumes — and the frame below it gets the key instead.
#[test]
fn a_hidden_frame_is_not_in_the_walk() {
let mut s = script();
s.run(
r#"
winner = nil
function mk(name, strata)
local f = CreateFrame("Frame", name)
f:SetFrameStrata(strata)
f:EnableKeyboard(true)
f:SetScript("OnChar", function() winner = name end)
return f
end
top = mk("KbHidden", "TOOLTIP")
mk("KbBelow", "DIALOG")
top:Hide()
"#,
)
.unwrap();
assert!(s.char_input("q"));
assert_eq!(s.eval::<String>("return winner").unwrap(), "KbBelow");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A raising handler must not eat the key or abort the walk's caller: the error is recorded, the
/// frame still consumed (consumption is the C++ gate's, never the handler's — §3.1).
#[test]
fn a_raising_handler_still_consumes_and_is_recorded() {
let mut s = script();
s.run(
r#"
f = CreateFrame("Frame", "KbBoom")
f:EnableKeyboard(true)
f:SetScript("OnChar", function() error("boom") end)
"#,
)
.unwrap();
assert!(s.char_input("z"), "the gate consumed regardless");
assert!(
s.errors().iter().any(|e| e.contains("boom")),
"the raise is recorded: {:?}",
s.errors()
);
}
@@ -17,6 +17,7 @@ mod font_object;
mod frame_api;
mod generic_for;
mod input;
mod keyboard;
mod layout_gate;
mod measure;
mod minimap;