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:
@@ -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) ──────────────────────
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user