From 781bf36ab32ecce40d4ce0124c3f2ad0bee0e734 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:07:34 +0700 Subject: [PATCH] Key input follows the client's walks; stock login takes typing (#1027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key input follows the client's walks: key-down (`0x765f10`), char (`0x765df0`), key-up to the recorded consumer (`0x765fd0`) and repeat (`0x766070`). - EditBoxes are in the walk from creation (`0x779ced`), so the stock login's boxes take typing; only a focused box consumes. A box declines PRINTSCREEN and unaltered arrows on an `ignoreArrows` box, so stack-split steps under a focused chat box. A binding's press records the world frame. - Repeats follow the pressed-key list, so a held key resumes after alt-tab. Chords reach a box only through the walk. Keys the client can't name walk nowhere. - The stock login reads the agreements before its first screen. Cinematics returns to login (no movie player; Deviation). The Realmlist box has its middle border. - Alt+F4 closes the window on Windows and Linux (Deviation: the reference relies on the OS). Fixes #179 Fixes #1003 Fixes #1004 Fixes #1005 Fixes #1019 πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- .../assets/ui/AccountLoginRealmlist.xml | 14 +- crates/benilla-app/src/bindings.rs | 316 ++++++++++- crates/benilla-app/src/bindings/chord.rs | 26 + crates/benilla-app/src/lib.rs | 2 + crates/benilla-app/src/shutdown.rs | 96 ++++ crates/benilla-app/src/textinput/clipboard.rs | 24 + crates/benilla-app/src/ui_script/bag_tests.rs | 2 +- crates/benilla-app/src/ui_script/glue.rs | 26 +- .../src/ui_script/glue_input_tests.rs | 169 ++++++ crates/benilla-app/src/ui_script/glue_mgr.rs | 18 + crates/benilla-app/src/ui_script/input.rs | 217 ++++--- .../src/ui_script/key_walk_tests.rs | 288 ++++++++++ crates/benilla-app/src/ui_script/lifecycle.rs | 55 +- crates/benilla-app/src/ui_script/mod.rs | 14 +- crates/benilla-app/src/ui_script/session.rs | 3 + crates/benilla-ui/src/loader/scripts.rs | 9 +- crates/benilla-ui/src/script/editbox.rs | 71 ++- crates/benilla-ui/src/script/glue_core.rs | 85 ++- crates/benilla-ui/src/script/keyboard.rs | 378 +++++++++---- crates/benilla-ui/src/script/mod.rs | 74 ++- crates/benilla-ui/src/script/model.rs | 8 + crates/benilla-ui/src/script/movieframe.rs | 126 +++++ crates/benilla-ui/src/script/object.rs | 1 + .../src/script/object/events_regions.rs | 5 +- .../src/script/tests/glue_surface.rs | 8 +- .../benilla-ui/src/script/tests/keyboard.rs | 531 ++++++++++++++++++ crates/benilla-ui/src/widget/mod.rs | 37 +- crates/benilla-ui/src/widget/propagation.rs | 43 +- 28 files changed, 2334 insertions(+), 312 deletions(-) create mode 100644 crates/benilla-app/src/ui_script/glue_input_tests.rs create mode 100644 crates/benilla-app/src/ui_script/key_walk_tests.rs create mode 100644 crates/benilla-ui/src/script/movieframe.rs diff --git a/crates/benilla-app/assets/ui/AccountLoginRealmlist.xml b/crates/benilla-app/assets/ui/AccountLoginRealmlist.xml index 3a7c255e..fb7bcac5 100644 --- a/crates/benilla-app/assets/ui/AccountLoginRealmlist.xml +++ b/crates/benilla-app/assets/ui/AccountLoginRealmlist.xml @@ -38,7 +38,9 @@ -- The stock edit box's Enter and Escape call the in-game StaticPopup functions, which the -- glue does not have: while this dialog owns it, they press its buttons, and it is wide - -- enough for a host name. + -- enough for a host name. The stock border is two 75-pixel caps that meet at its own 130 + -- pixels, so the widened box also takes the chat box's stretched middle piece + -- (ChatFrame.xml:41-55) between them. function BenillaRealmlist_TakeEditBox() if ( not BenillaRealmlist_Saved ) then BenillaRealmlist_Saved = { @@ -48,6 +50,15 @@ }; end GlueDialogEditBox:SetWidth(300); + if ( not BenillaRealmlist_Middle ) then + BenillaRealmlist_Middle = GlueDialogEditBox:CreateTexture(nil, "BACKGROUND"); + BenillaRealmlist_Middle:SetTexture("Interface\\ChatFrame\\UI-ChatInputBorder-Right"); + BenillaRealmlist_Middle:SetTexCoord(0, 0.9375, 0, 1.0); + BenillaRealmlist_Middle:SetHeight(32); + BenillaRealmlist_Middle:SetPoint("LEFT", GlueDialogEditBox, "LEFT", 65, 0); + BenillaRealmlist_Middle:SetPoint("RIGHT", GlueDialogEditBox, "RIGHT", -65, 0); + end + BenillaRealmlist_Middle:Show(); GlueDialogEditBox:SetScript("OnEnterPressed", function() GlueDialogButton1:Click(); end); GlueDialogEditBox:SetScript("OnEscapePressed", function() GlueDialogButton2:Click(); end); end @@ -55,6 +66,7 @@ function BenillaRealmlist_ReleaseEditBox() local saved = BenillaRealmlist_Saved; if ( saved ) then + BenillaRealmlist_Middle:Hide(); GlueDialogEditBox:SetWidth(saved.width); GlueDialogEditBox:SetScript("OnEnterPressed", saved.enter); GlueDialogEditBox:SetScript("OnEscapePressed", saved.escape); diff --git a/crates/benilla-app/src/bindings.rs b/crates/benilla-app/src/bindings.rs index ae379df7..062dd916 100644 --- a/crates/benilla-app/src/bindings.rs +++ b/crates/benilla-app/src/bindings.rs @@ -29,7 +29,7 @@ use bevy::input::ButtonState; use bevy::prelude::*; use benilla_ui::script::keybind::KeybindRequest; -use benilla_ui::script::UiScript; +use benilla_ui::script::{KeyUp, KeyWalk, UiScript}; use crate::char_select::InWorldGated; use crate::ui_script::{PlayerUiHover, PointerOverUiPanel, UiKeyboardCapture}; @@ -93,7 +93,46 @@ pub(crate) struct BindingsState { /// Normalized like [`latched`](Self::latched) and reconciled against `ButtonInput` every /// pass, so a window deactivate empties it and a key held across an alt-tab resumes, as in /// the reference. Keyboard only: mouse edges cannot repeat. - down: Vec, + down: KeyDownList, +} + +/// The keys believed down, the reference's own list (`0x4248b3`): a press of a key on it is an +/// auto-repeat, whatever the OS flagged. One instance is the bindings', another the UI key feed's +/// ([`crate::ui_script`]), which decides repeat for the frames by the same rule and reconcile. +#[derive(Default)] +pub(crate) struct KeyDownList(Vec); + +impl KeyDownList { + /// Drop what is no longer physically down, per bevy's button plane: a window deactivate + /// empties it (the reference's `0x424790` wipe), so the first repeat after an alt-tab is a + /// fresh press and re-latches. + pub(crate) fn reconcile(&mut self, keys: &ButtonInput) { + self.0.retain(|&kc| key_physically_down(kc, keys)); + } + + /// Record a press; whether the key was already down, which makes it a repeat. + pub(crate) fn press(&mut self, key: KeyCode) -> bool { + let key = chord::normalize_key(key); + let repeat = self.0.contains(&key); + if !repeat { + self.0.push(key); + } + repeat + } + + pub(crate) fn release(&mut self, key: KeyCode) { + let key = chord::normalize_key(key); + self.0.retain(|&kc| kc != key); + } +} + +/// `ENTER` is down while either physical key is, since [`chord::normalize_key`] folds +/// `NUMPADENTER` into it. +fn key_physically_down(kc: KeyCode, keys: &ButtonInput) -> bool { + match kc { + KeyCode::Enter => keys.pressed(KeyCode::Enter) || keys.pressed(KeyCode::NumpadEnter), + kc => keys.pressed(kc), + } } /// The physical input a latch waits on: a keyboard key by its code, not its name, so its release @@ -392,9 +431,7 @@ fn latch_and_dispatch( // ── The pressed-key reconcile ── against bevy's button planes, before this frame's messages: // a release the window never saw drops off the list, and a window deactivate empties it, the // reference's `0x424790` wipe, so a key held across an alt-tab re-latches on its next repeat. - state - .down - .retain(|&kc| physically_down(Held::Key(kc), &keys, &buttons)); + state.down.reconcile(&keys); // ── Keyboard ── press edges run their command, gated on ownership and the capture arm; // release edges unlatch and run the `runOnUp` up-half. @@ -413,15 +450,17 @@ fn latch_and_dispatch( | KeyCode::ArrowUp | KeyCode::ArrowDown ); + // The box declines PRINTSCREEN before it looks at focus (`0x77b17f`), so the + // walk goes on and the world frame's binding runs while typing. + let arrow_exempt = arrow_exempt + || chord::key_token(ev.key_code, &layout) + == Some(chord::KeyName::Word("PRINTSCREEN")); // A keyboard frame ate this one key (the world map's `OnKeyDown`, a cinematic); // per key, so every other binding is untouched. let eaten = capture.consumed.contains(&ev.key_code); // A repeat is a key we already believe is down, the reference's test. Recorded // before the gates below, so a key held through a focused box stays down. - let repeat = state.down.contains(&key); - if !repeat { - state.down.push(key); - } + let repeat = state.down.press(key); // `sup`: the Super deviation (module doc). if (typing && !arrow_exempt) || eaten || sup || repeat { continue; @@ -441,12 +480,44 @@ fn latch_and_dispatch( key: BindKey::Key(name), }; if let Some(command) = dispatch.resolve(chord, dev_plane) { + // The world frame's key-down ran it and returned that (`0x483b70`), so its + // key-up and repeats are the world frame's, not a stale earlier consumer's. + if let Some(s) = script.as_mut() { + s.note_world_key(&name.to_string()); + } press(&mut state, &mut script, command, Held::Key(key)); } } ButtonState::Released => { - state.down.retain(|&kc| kc != key); + state.down.release(key); release(&mut state, &mut script, Held::Key(key)); + // The release, to the frame that took the press (`0x765fd0`), here and not in the + // key feed so it follows this key's press in event order: a binding's press has + // just made the world frame its consumer. PRINTSCREEN arrives as an up alone on + // Windows (`VK_SNAPSHOT` sends no `WM_KEYDOWN`), and with no consumer on record the + // client runs the key-down walk on it (`0x76603c`-`0x76604d`), the world frame's + // binding last: the up then runs the chord as a press, with no latch since no + // release follows. + let Some(name) = chord::walk_name(ev.key_code, &layout) else { + continue; + }; + let Some(s) = script.as_mut() else { + continue; + }; + if s.key_up(&name) == KeyUp::Walked(KeyWalk::Declined) && !sup { + if let Some(token) = chord::key_token(ev.key_code, &layout) { + let chord = Chord { + alt, + ctrl, + shift, + key: BindKey::Key(token), + }; + if let Some(command) = dispatch.resolve(chord, dev_plane) { + press(&mut state, &mut script, command, Held::Key(key)); + state.latched.retain(|(k, _)| *k != Held::Key(key)); + } + } + } } } } @@ -539,10 +610,7 @@ fn physically_down( buttons: &ButtonInput, ) -> bool { match key { - Held::Key(KeyCode::Enter) => { - keys.pressed(KeyCode::Enter) || keys.pressed(KeyCode::NumpadEnter) - } - Held::Key(kc) => keys.pressed(kc), + Held::Key(kc) => key_physically_down(kc, keys), Held::Mouse(b) => buttons.pressed(b), // A notch is a press and a release in one frame; it is never "held". Held::Wheel => false, @@ -839,6 +907,67 @@ mod tests { ); } + /// The box declines PRINTSCREEN before it looks at focus (`0x77b17f`), so the walk goes on and + /// the binding runs while typing, with every other key still eaten. + #[test] + fn printscreen_runs_its_binding_while_a_box_has_focus() { + let script = with_addon(PROBE_BINDINGS); + script + .run(r#"SetBinding("PRINTSCREEN", "PROBEEDGE"); SetBinding("G", "PROBEHOLD")"#) + .expect("bind"); + let mut app = vm_harness(script); + app.world_mut().resource_mut::().typing = true; + + press_key(&mut app, KeyCode::KeyG); + app.update(); + assert_eq!(lua_count(&app, "PROBE_DOWN"), 0, "G is eaten by the box"); + + press_key(&mut app, KeyCode::PrintScreen); + app.update(); + assert_eq!( + lua_count(&app, "PROBE_EDGE"), + 1, + "PRINTSCREEN is declined, so its binding runs" + ); + } + + /// A press that resolved to a binding is the world frame's (`0x483b70`): the key's up goes to + /// it, not to the hidden frame that took the key's last press. + #[test] + fn a_press_that_runs_a_binding_makes_the_world_frame_its_consumer() { + let script = with_addon(PROBE_BINDINGS); + script + .run( + r#" + SetBinding("G", "PROBEEDGE") + CreateFrame("WorldFrame", "WorldFrame") + ups = 0 + stale = CreateFrame("Frame", "StaleG") + stale:EnableKeyboard(true) + stale:SetScript("OnKeyDown", function() end) + stale:SetScript("OnKeyUp", function() ups = ups + 1 end) + "#, + ) + .expect("bind"); + let mut app = vm_harness(script); + { + let mut s = app.world_mut().non_send_resource_mut::(); + assert!(s.key_input("G"), "the frame takes G first"); + s.run("stale:Hide()").unwrap(); + } + press_key(&mut app, KeyCode::KeyG); + app.update(); + assert_eq!( + lua_count(&app, "PROBE_EDGE"), + 1, + "the binding took the press" + ); + let mut s = app.world_mut().non_send_resource_mut::(); + s.run("stale:Show()").unwrap(); + assert!(s.key_up("G").taken()); + assert_eq!(s.eval::("return ups").unwrap(), 0); + } + /// A box taking focus releases nothing already held: the reference's focused box freezes the /// direction bits rather than clearing them (`0x514490` is the OS window-deactivate clear). #[test] @@ -1002,6 +1131,165 @@ mod tests { ); } + /// The UI key feed beside the dispatch, as the world runs them: the feed first, on the same + /// events, with the world frame in the walk. + fn feed_and_dispatch_harness() -> App { + fn feed( + mut script: NonSendMut, + mut capture: ResMut, + mut events: MessageReader, + keys: Res>, + layout: Res, + mut clipboard: NonSendMut, + mut down: Local, + ) { + capture.consumed.clear(); + crate::ui_script::feed_keys( + &mut script, + &mut capture, + events.read(), + &keys, + &mut down, + false, + crate::textinput::Mods { + shift: false, + ctrl: false, + alt: false, + sup: false, + }, + &layout, + &mut clipboard, + None, + ); + } + let script = core_script(); + script + .run(r#"CreateFrame("WorldFrame", "WorldFrame")"#) + .expect("world frame"); + let mut app = vm_harness(script); + app.insert_non_send_resource(crate::textinput::HostClipboard::holding("")) + .add_systems(Update, feed.before(latch_and_dispatch)); + app + } + + /// A key held across an alt-tab resumes at its next OS repeat even when its binding made the + /// world frame its consumer: the feed decides repeat from the same wiped pressed-key list + /// (`0x424790`), not from the OS flag, so the repeat is a fresh press for both and W runs + /// again (`0x4248b3`). + #[test] + fn a_held_movement_key_resumes_after_an_alt_tab_through_the_feed_too() { + let mut app = feed_and_dispatch_harness(); + press_key(&mut app, KeyCode::KeyW); + app.update(); + assert!(state(&app).pressed(Input::MoveForward)); + + app.world_mut() + .resource_mut::>() + .release_all(); + app.update(); + assert!( + !state(&app).pressed(Input::MoveForward), + "the deactivate releases the direction bits" + ); + + repeat_key(&mut app, KeyCode::KeyW); + app.update(); + assert!( + state(&app).pressed(Input::MoveForward), + "the first repeat after re-activation re-latches" + ); + assert!( + !app.world() + .resource::() + .consumed + .contains(&KeyCode::KeyW), + "…the feed did not hand the repeat to the world frame's stub" + ); + + // A real repeat of the key now held is the stub's, and eaten by nobody's binding. + repeat_key(&mut app, KeyCode::KeyW); + app.update(); + assert!(state(&app).pressed(Input::MoveForward)); + } + + /// A press and its release in one frame's batch reach the dispatch in event order: the release + /// follows the press that made the world frame the consumer, so it is not sent on to the frame + /// that took the key's last press. + #[test] + fn a_press_and_a_release_in_one_batch_go_to_the_world_frame_not_a_stale_consumer() { + let mut app = feed_and_dispatch_harness(); + { + let mut s = app.world_mut().non_send_resource_mut::(); + s.run( + r#" + ups = 0 + stale = CreateFrame("Frame", "StaleBatch") + stale:EnableKeyboard(true) + stale:SetScript("OnKeyDown", function() end) + stale:SetScript("OnKeyUp", function() ups = ups + 1 end) + "#, + ) + .unwrap(); + assert!(s.key_input("W"), "the frame takes W once"); + s.run("stale:EnableKeyboard(false)").unwrap(); + } + press_key(&mut app, KeyCode::KeyW); + release_key(&mut app, KeyCode::KeyW); + app.update(); + let s = app.world().non_send_resource::(); + assert_eq!(s.eval::("return ups").unwrap(), 0); + } + + /// PRINTSCREEN reaches Windows as an up alone (`VK_SNAPSHOT` sends no `WM_KEYDOWN`). With no + /// consumer on record the client runs the key-down walk on the up (`0x76603c`-`0x76604d`), the + /// world frame's binding last, so the binding runs on the release; a frame that takes the walk + /// keeps it from the binding; and a platform that sends both halves runs it once. + #[test] + fn printscreen_as_an_up_alone_runs_its_binding_through_the_key_down_walk() { + let script = with_addon(PROBE_BINDINGS); + script + .run( + r#"SetBinding("PRINTSCREEN", "PROBEEDGE") CreateFrame("WorldFrame", "WorldFrame")"#, + ) + .expect("bind"); + let mut app = vm_harness(script); + release_key(&mut app, KeyCode::PrintScreen); + app.update(); + assert_eq!(lua_count(&app, "PROBE_EDGE"), 1, "the up alone runs it"); + + // A frame that takes the key-down walk keeps it from the binding. + app.world_mut() + .non_send_resource_mut::() + .run( + r#" + got = 0 + cover = CreateFrame("Frame", "PrintCover") + cover:EnableKeyboard(true) + cover:SetScript("OnKeyDown", function() got = got + 1 end) + "#, + ) + .unwrap(); + release_key(&mut app, KeyCode::PrintScreen); + app.update(); + assert_eq!(lua_count(&app, "got"), 1, "the frame took the walk"); + assert_eq!( + lua_count(&app, "PROBE_EDGE"), + 1, + "…so the binding did not run" + ); + + // A platform that sends both halves: the press ran it and made the world frame the + // consumer, so the up is delivered, not walked again. + app.world_mut() + .non_send_resource_mut::() + .run("cover:Hide()") + .unwrap(); + press_key(&mut app, KeyCode::PrintScreen); + release_key(&mut app, KeyCode::PrintScreen); + app.update(); + assert_eq!(lua_count(&app, "PROBE_EDGE"), 2, "…once, not twice"); + } + /// An addon's bindings that call the movement functions: a Start and a Stop alone. const MOVE_BINDINGS: &str = r#" MoveForwardStart() diff --git a/crates/benilla-app/src/bindings/chord.rs b/crates/benilla-app/src/bindings/chord.rs index 2e7f4ffe..8f5e2124 100644 --- a/crates/benilla-app/src/bindings/chord.rs +++ b/crates/benilla-app/src/bindings/chord.rs @@ -113,6 +113,32 @@ pub(crate) fn key_token(k: KeyCode, layout: &LayoutNames) -> Option { } } +/// The name the key-down walk's `arg1` carries for a press (`0x76b7d0`), `None` for a key the +/// client drops before any walk: the modifiers go by their own names (`0`, `1`, `2` of the table, +/// `SHIFT`, `CTRL`, `ALT` at `0x846e80`, `0x846e88`, `0x846e90`), and `UNKNOWN` is the name of the +/// codes the table cannot name, `0x210` and `0x211` (ScrollLock and Pause, and F14 and F15 on a +/// Mac). Every other key without a name never reaches the walk: the Windows key table drops it +/// where `MapVirtualKeyA` answers 0 (`0x42da39`: the Windows and Menu keys, media keys, a key the +/// layout drops), the Mac table `0x5bf320` has -1 for Command and fn. Unlike [`key_token`], which +/// is a binding's, a modifier still gets a down and an up. +pub(crate) fn walk_name(k: KeyCode, layout: &LayoutNames) -> Option { + if let Some(name) = key_token(k, layout) { + return Some(name.to_string()); + } + Some( + match k { + KeyCode::ShiftLeft | KeyCode::ShiftRight => "SHIFT", + KeyCode::ControlLeft | KeyCode::ControlRight => "CTRL", + KeyCode::AltLeft | KeyCode::AltRight => "ALT", + KeyCode::ScrollLock | KeyCode::Pause => "UNKNOWN", + #[cfg(target_os = "macos")] + KeyCode::F14 | KeyCode::F15 => "UNKNOWN", + _ => return None, + } + .to_string(), + ) +} + /// The fixed half of `0x42d800` and the Mac table: every key no layout names /// ([`super::layout::position`]). fn fixed_name(k: KeyCode) -> Option { diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index f2e84cdc..b7e605c7 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -409,6 +409,8 @@ fn launch(build: BuildId, extend: Option>) -> AppExit { // macOS `Cmd+Q` goes straight to `terminate:`, which never runs another frame, so the session // was never written; re-pointed at the window close, which [`shutdown`] sees. .add_plugins(benilla_world::mac_quit::MacQuitPlugin) + // Alt+F4 on Windows and Linux, where a tiling window manager binds nothing to it. + .add_plugins(shutdown::AltF4Plugin) // The engine as one group; `world_plugins.rs` has its load-bearing ordering edges and what it // leaves out (`pipe_warm`). .add_plugins(benilla_world::world_plugins::WorldPlugins) diff --git a/crates/benilla-app/src/shutdown.rs b/crates/benilla-app/src/shutdown.rs index de0cdfe0..a087e62b 100644 --- a/crates/benilla-app/src/shutdown.rs +++ b/crates/benilla-app/src/shutdown.rs @@ -45,6 +45,50 @@ pub(crate) fn on_app_exit(app: &mut App, systems: ScheduleConfigs `0x423ca0` -> `0x41f5d0` -> `0x422030`), +/// with no logout timer, at the login screen and in game alike. A tiling window manager (i3, +/// sway, Hyprland) binds nothing to it, so benilla reads the chord itself and asks for the same +/// close, which the shutdown tail above already serves. Not on macOS: `Cmd+Q` is +/// [`benilla_world::mac_quit`]. +pub(crate) struct AltF4Plugin; + +impl Plugin for AltF4Plugin { + fn build(&self, app: &mut App) { + if cfg!(target_os = "macos") { + return; + } + // Before the loading cover swallows the input plane, which would eat the key. + app.add_systems( + PreUpdate, + close_on_alt_f4 + .after(bevy::input::InputSystems) + .before(crate::loading_screen::CoverInput), + ); + } +} + +/// F4 pressed with Alt held asks the primary window to close, as a click on its close box does. +fn close_on_alt_f4( + mut keyboard: MessageReader, + keys: Res>, + windows: Query>, + mut close: MessageWriter, +) { + let alt = keys.pressed(KeyCode::AltLeft) || keys.pressed(KeyCode::AltRight); + let pressed = keyboard.read().any(|ev| { + ev.key_code == KeyCode::F4 && ev.state == bevy::input::ButtonState::Pressed && !ev.repeat + }); + if pressed && alt { + if let Ok(window) = windows.single() { + close.write(bevy::window::WindowCloseRequested { window }); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -171,4 +215,56 @@ mod tests { assert_eq!(app.world().resource::().0, 2); } + + /// Alt+F4 writes the close request for the primary window; F4 alone and Alt with another key + /// do not. + #[test] + fn alt_f4_asks_the_primary_window_to_close_and_plain_f4_does_not() { + use bevy::input::keyboard::{Key, KeyboardInput}; + use bevy::input::ButtonState; + let run = |alt: bool, key: KeyCode| { + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(bevy::input::InputPlugin) + .add_message::() + .add_systems(PreUpdate, close_on_alt_f4.after(bevy::input::InputSystems)); + let window = app + .world_mut() + .spawn((Window::default(), PrimaryWindow)) + .id(); + if alt { + app.world_mut().write_message(KeyboardInput { + key_code: KeyCode::AltLeft, + logical_key: Key::Alt, + state: ButtonState::Pressed, + text: None, + repeat: false, + window, + }); + } + app.world_mut().write_message(KeyboardInput { + key_code: key, + logical_key: Key::F4, + state: ButtonState::Pressed, + text: None, + repeat: false, + window, + }); + app.update(); + let sent: Vec = app + .world_mut() + .resource_mut::>() + .drain() + .map(|c| c.window) + .collect(); + (sent, window) + }; + let (sent, window) = run(true, KeyCode::F4); + assert_eq!(sent, [window], "Alt+F4 closes the primary window"); + assert!( + run(false, KeyCode::F4).0.is_empty(), + "plain F4 does nothing" + ); + assert!(run(true, KeyCode::F5).0.is_empty(), "Alt+F5 does nothing"); + } } diff --git a/crates/benilla-app/src/textinput/clipboard.rs b/crates/benilla-app/src/textinput/clipboard.rs index cba73877..565177ec 100644 --- a/crates/benilla-app/src/textinput/clipboard.rs +++ b/crates/benilla-app/src/textinput/clipboard.rs @@ -184,3 +184,27 @@ impl HostClipboard { } } } + +/// A clipboard holding `text` in memory, so a test never touches the machine's pasteboard. +#[cfg(test)] +impl HostClipboard { + pub(crate) fn holding(text: &str) -> Self { + struct Memory(String); + impl Pasteboard for Memory { + fn read_text(&mut self) -> Result, String> { + Ok((!self.0.is_empty()).then(|| self.0.clone())) + } + fn write_text(&mut self, text: &str) -> Result<(), String> { + self.0 = text.to_string(); + Ok(()) + } + fn name(&self) -> &'static str { + "memory" + } + } + HostClipboard { + backend: Some(Box::new(Memory(text.to_string()))), + opened: true, + } + } +} diff --git a/crates/benilla-app/src/ui_script/bag_tests.rs b/crates/benilla-app/src/ui_script/bag_tests.rs index 75f0ec4d..b91b7d73 100644 --- a/crates/benilla-app/src/ui_script/bag_tests.rs +++ b/crates/benilla-app/src/ui_script/bag_tests.rs @@ -982,7 +982,7 @@ fn equipped_bag_window_snug_fits_its_row_count() { } /// Open a backpack holding a five-stack in slot 1; returns that slot button's centre. -fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) { +pub(super) fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) { for file in crate::ui_script::test_ui::production_order(&[ BAG_UI, &[ diff --git a/crates/benilla-app/src/ui_script/glue.rs b/crates/benilla-app/src/ui_script/glue.rs index 9c562cfa..52b3b70e 100644 --- a/crates/benilla-app/src/ui_script/glue.rs +++ b/crates/benilla-app/src/ui_script/glue.rs @@ -182,15 +182,6 @@ mod tests { /// `0x87a95c`, and the walk goes on). const INSTALL_WARNINGS: &[&str] = &["Frame TOSText: Unknown element type: H4"]; - /// The movie frame's three handlers, which benilla's `MovieFrame` does not take: it plays no - /// movie. Each is a loader warning, `: SetScript("") unsupported`; the list only - /// shrinks. - const UNBUILT_HANDLERS: &[(&str, &str)] = &[ - ("MovieFrame", "OnMovieFinished"), - ("MovieFrame", "OnMovieHideSubtitle"), - ("MovieFrame", "OnMovieShowSubtitle"), - ]; - /// The frames each stock glue screen and dialog is, which the load must build. const SCREENS: [&str; 8] = [ "GlueParent", @@ -269,7 +260,6 @@ mod tests { let surface = glue_surface(); let mut called = BTreeSet::new(); - let mut handlers = BTreeSet::new(); let other: Vec<&String> = errors .iter() .filter(|e| { @@ -280,19 +270,13 @@ mod tests { if INSTALL_WARNINGS.contains(&e.as_str()) { return false; } - if let Some(h) = UNBUILT_HANDLERS.iter().find(|(frame, handler)| { - e.contains(&format!("{frame}: SetScript(\"{handler}\") unsupported")) - }) { - handlers.insert(*h); - return false; - } true }) .collect(); assert!( other.is_empty(), "the glue load raised {} error(s) or warning(s) that are not a call of an unbuilt \ - glue function on UNBUILT_CALLED or a handler on UNBUILT_HANDLERS:\n{}", + glue function on UNBUILT_CALLED:\n{}", other.len(), other .iter() @@ -306,14 +290,6 @@ mod tests { stale.is_empty(), "UNBUILT_CALLED names no longer called unbuilt; take them off: {stale:?}" ); - let stale: Vec<&(&str, &str)> = UNBUILT_HANDLERS - .iter() - .filter(|h| !handlers.contains(*h)) - .collect(); - assert!( - stale.is_empty(), - "UNBUILT_HANDLERS entries the load no longer warns about; take them off: {stale:?}" - ); for name in UNBUILT_CALLED { assert!( surface.contains(*name), diff --git a/crates/benilla-app/src/ui_script/glue_input_tests.rs b/crates/benilla-app/src/ui_script/glue_input_tests.rs new file mode 100644 index 00000000..fb3c7e46 --- /dev/null +++ b/crates/benilla-app/src/ui_script/glue_input_tests.rs @@ -0,0 +1,169 @@ +//! Keys on the stock glue screens, end to end: the host's key and char events into the stock +//! GlueXML in the glue VM. + +use benilla_ui::script::{GlueNotice, NoticeState, UiScript}; + +use super::glue::{load_glue, set_glue_screen, GlueScreen}; + +/// The stock glue VM on `screen` with both agreements accepted, as a returning player's is. +fn glue_on(screen: GlueScreen) -> UiScript { + let mut s = UiScript::new_glue().unwrap(); + s.set_screen_size(1024.0, 768.0); + let accepted = NoticeState { + accepted: true, + show_notice: false, + }; + for notice in [GlueNotice::Eula, GlueNotice::Tos] { + s.set_glue_notice(notice, accepted); + } + let _ = load_glue(&mut s); + set_glue_screen(&mut s, screen); + s.resolve(); + let _ = s.take_errors(); + s +} + +/// One typed key as the host feeds it (`ui_script::input`): the key-down by name, then its char. +fn type_key(s: &mut UiScript, name: &str, text: &str) { + s.frame_key_input(name); + s.char_input(text); + s.key_up(name); +} + +fn text(s: &UiScript, frame: &str) -> String { + s.eval::(&format!("return {frame}:GetText() or ''")) + .unwrap() +} + +/// The login boxes take the keys typed into them. `AccountLogin` is keyboard-enabled with an +/// `OnChar` and an `OnKeyDown`, and walks ahead of any box that is not itself in the walk; the +/// EditBox ctor enables key input (`0x779ced`, `0x779cf8`) so the focused box answers at its own +/// level above its parent. +#[test] +fn the_stock_login_boxes_take_typed_keys() { + let _ = benilla_formats::wow_data_or_skip!(); + let mut s = glue_on(GlueScreen::Login); + assert!( + s.eval::("return AccountLoginUI:IsShown() == 1") + .unwrap(), + "accepted agreements put the login boxes up" + ); + s.run("AccountLoginAccountEdit:SetFocus()").unwrap(); + for (name, c) in [("P", "p"), ("R", "r"), ("O", "o"), ("B", "b"), ("E", "e")] { + type_key(&mut s, name, c); + } + assert_eq!(text(&s, "AccountLoginAccountEdit"), "probe"); + + // Tab moves to the password box (`AccountLoginAccountEdit`'s `OnTabPressed`), which then types. + assert!(s.key_input("TAB")); + assert_eq!( + s.focused_editbox_name().as_deref(), + Some("AccountLoginPasswordEdit") + ); + for (name, c) in [("S", "s"), ("E", "e"), ("C", "c")] { + type_key(&mut s, name, c); + } + assert_eq!(text(&s, "AccountLoginPasswordEdit"), "sec"); + assert_eq!(text(&s, "AccountLoginAccountEdit"), "probe"); + assert!(s.take_errors().is_empty()); +} + +/// The login screen's Cinematics button shows `MovieFrame`; no movie plays here, so +/// `StartMovie` answers nil and the stock `MovieFrame_PlayMovie` hides the frame, whose `OnHide` +/// puts the login screen back and the pointer with it (`MovieFrame.lua:18-23,40-44`). +#[test] +fn the_cinematics_button_comes_back_to_the_login_screen() { + let _ = benilla_formats::wow_data_or_skip!(); + let mut s = glue_on(GlueScreen::Login); + s.register_cvars([("movieSubtitle", "1"), ("gxResolution", "1024x768")]); + s.run("AccountLoginCinematicsButton:Click()").unwrap(); + s.resolve(); + let (movie, login, cursor_hidden) = ( + s.eval::("return MovieFrame:IsShown() == 1").unwrap(), + s.eval::("return AccountLoginUI:IsVisible() == 1") + .unwrap(), + s.glue_cursor_hidden(), + ); + assert_eq!( + (movie, login, cursor_hidden), + (false, true, false), + "the movie frame is gone, the login boxes are back and the pointer shows" + ); + assert_eq!(s.take_errors(), Vec::::new()); +} + +/// A frame whose only key script is `OnKeyUp` takes the key-down and runs its `OnKeyUp` on the +/// release (`0x76b7d0`, `0x765fd0`, `0x76bba0`): Escape on a playing movie hides the frame +/// (`MovieFrame_OnKeyUp`), which returns to login. +#[test] +fn escape_on_a_playing_movie_returns_to_login_on_the_key_up() { + let _ = benilla_formats::wow_data_or_skip!(); + let mut s = glue_on(GlueScreen::Login); + s.register_cvars([("movieSubtitle", "0"), ("gxResolution", "1024x768")]); + // A movie that starts, as the reference's player would. + s.run("MovieFrame.StartMovie = function() return 1 end SetGlueScreen('movie')") + .unwrap(); + s.resolve(); + assert!(s.eval::("return MovieFrame:IsShown() == 1").unwrap()); + assert!(s.glue_cursor_hidden(), "a playing movie hides the pointer"); + + assert!( + s.frame_key_input("SPACE"), + "the movie swallows the key-down" + ); + assert!( + s.eval::("return MovieFrame:IsShown() == 1").unwrap(), + "…and nothing happens until the key is up" + ); + assert!(s.key_input("ESCAPE")); + assert!(s.eval::("return MovieFrame:IsShown() == 1").unwrap()); + s.key_up("ESCAPE"); + s.resolve(); + assert!(!s.eval::("return MovieFrame:IsShown() == 1").unwrap()); + assert!(s + .eval::("return AccountLoginUI:IsVisible() == 1") + .unwrap()); + assert!(!s.glue_cursor_hidden()); + assert_eq!(s.take_errors(), Vec::::new()); +} + +/// The Realmlist dialog widens the stock edit box, whose border is two 75-pixel caps that meet at +/// its own 130 pixels: while it is wide the stretched middle fills the gap between the caps, as the +/// chat box's does (`ChatFrame.xml:41-55`), and the stock box comes back untouched. +#[test] +fn the_widened_realmlist_box_has_a_border_between_its_caps() { + let _ = benilla_formats::wow_data_or_skip!(); + let mut s = glue_on(GlueScreen::Login); + s.run("BENILLA_REALMLIST = 'logon.example.org' BenillaRealmlistButton:Click()") + .unwrap(); + s.resolve(); + let span = |s: &UiScript| { + s.eval::<(f64, f64, f64, f64)>( + "return GlueDialogEditBox:GetLeft(), GlueDialogEditBox:GetRight(), \ + BenillaRealmlist_Middle:GetLeft(), BenillaRealmlist_Middle:GetRight()", + ) + .unwrap() + }; + let (box_left, box_right, mid_left, mid_right) = span(&s); + assert_eq!(box_right - box_left, 300.0); + assert_eq!( + (mid_left - box_left, box_right - mid_right), + (65.0, 65.0), + "the middle starts where the left cap ends and ends where the right one starts" + ); + assert!(s + .eval::("return BenillaRealmlist_Middle:IsShown() == 1") + .unwrap()); + + s.run("GlueDialogButton2:Click()").unwrap(); + s.resolve(); + assert!(!s + .eval::("return BenillaRealmlist_Middle:IsShown() == 1") + .unwrap()); + assert_eq!( + s.eval::("return GlueDialogEditBox:GetWidth()") + .unwrap(), + 130.0 + ); + assert_eq!(s.take_errors(), Vec::::new()); +} diff --git a/crates/benilla-app/src/ui_script/glue_mgr.rs b/crates/benilla-app/src/ui_script/glue_mgr.rs index c4fc27e6..fcbc1479 100644 --- a/crates/benilla-app/src/ui_script/glue_mgr.rs +++ b/crates/benilla-app/src/ui_script/glue_mgr.rs @@ -485,6 +485,11 @@ pub(crate) struct GlueMgr { } impl GlueMgr { + /// The agreements as read at the startup (`0x46a400`). + pub(super) fn set_notices(&mut self, notices: [NoticeState; 4]) { + self.notices = Some(notices); + } + fn notice(&self, notice: GlueNotice) -> NoticeState { self.notices.map(|n| n[notice as usize]).unwrap_or_default() } @@ -1096,6 +1101,19 @@ fn notice_cvar(notice: GlueNotice) -> &'static str { } } +/// The four agreements' states off the CVar registry: the read `0x46a400` makes at `0x402b36`, +/// before the first glue screen is set (`0x402b65`), so a returning player's login screen never +/// sees them unaccepted. +pub(crate) fn read_agreements(cvars: &crate::cvars::Cvars) -> [NoticeState; 4] { + let mut n = [NoticeState::default(); 4]; + for notice in GlueNotice::ALL { + if let Some(value) = cvars.get(notice_cvar(notice)) { + n[notice as usize] = NoticeState::from_cvar(value); + } + } + n +} + /// The glue manager's frame: the VM's requests, the native login's news, the per-frame arms, then /// the events into the glue VM and its slots seeded. Runs under `WOW_GLUE_STOCK=1` only. #[allow(clippy::too_many_arguments)] diff --git a/crates/benilla-app/src/ui_script/input.rs b/crates/benilla-app/src/ui_script/input.rs index e8fe34f7..ebd1d411 100644 --- a/crates/benilla-app/src/ui_script/input.rs +++ b/crates/benilla-app/src/ui_script/input.rs @@ -7,7 +7,7 @@ use bevy::input::ButtonState; use bevy::prelude::*; use bevy::window::PrimaryWindow; -use benilla_ui::script::UiScript; +use benilla_ui::script::{KeyWalk, UiScript}; use super::{ CursorPayloadHeld, PlayerUiClickConsumed, PlayerUiHover, PlayerUiPressConsumed, @@ -96,10 +96,19 @@ pub(super) fn feed_ui_input( ResMut, NonSendMut, ), + // The keys this feed believes down, a fact about the keyboard and not the VM. + mut pressed: Local, + // Who sends the key-ups: the bindings pass in the world, this feed on the glue screens. + state: Option>>, // The characters the active layout makes, which name a key for a keyboard frame. layout: Res, ) { let (keyboard, keys, capture, clipboard) = (&mut kbd.0, &kbd.1, &mut kbd.2, &mut kbd.3); + // In the world the bindings pass sends the key-ups, in event order with the presses it + // follows; on the glue screens, which have none, this feed does. + let releases = !state + .as_ref() + .is_some_and(|s| *s.get() == crate::char_select::ClientState::InWorld); let world_pick = pointer.world_pick(); // The OS pointer is not ours while a probe drives a gesture through the real pointer path or a // capture pins it: skip the mouse half whole, else-arm included, whose `pointer_left_window` @@ -210,15 +219,20 @@ pub(super) fn feed_ui_input( // and before the keys (an Escape that clears focus still counts as captured this frame). capture.typing = script.has_keyboard_focus(); capture.consumed.clear(); + script.set_alt_down(alt); // ── The alt-arrow exemption ──────────────────────────────────────────────────────────────── // An EditBox in alt-arrow mode (`ignoreArrows`, set on the chat box at `ChatFrame.xml:21`) // passes the arrows on unless ALT is held: its handler returns 0 at `0x77b1c4` and the - // binding runs at `CGWorldFrame`; a focused box swallows any other key (`0x77b35e` returns 1). + // binding runs at `CGWorldFrame`; a focused box swallows any other key (`0x77b35e` returns 1), + // PRINTSCREEN excepted (`0x77b17f`, see `crate::bindings`). capture.arrows_fall_through = !alt && script.editbox_alt_arrow_mode(); - // ── Keyboard β†’ VM ── box-event keys go to `key_input` by name, editing keys through the - // per-OS chord table as an `EditAction` or a clipboard operation (the macOS NSPasteboard - // needs this main-thread system), the rest to `char_input`. Repeats arrive as `Pressed`. + // ── Keyboard β†’ VM ── every press is one key-down walk by name (`0x765f10`), in which a box is + // a frame like the rest; a box the walk reached then takes the key's editing action through + // the per-OS chord table as an `EditAction` or a clipboard operation (the macOS NSPasteboard + // needs this main-thread system), and the characters go to `char_input`, a walk of its own. + // A repeat goes to the frame that took the press instead (`0x766070`), a release likewise + // (`0x765fd0`). let sup = keys.pressed(KeyCode::SuperLeft) || keys.pressed(KeyCode::SuperRight); let mods = keymap::Mods { shift, @@ -226,84 +240,121 @@ pub(super) fn feed_ui_input( alt, sup, }; + feed_keys( + &mut script, + capture, + keyboard.read(), + keys, + &mut pressed, + releases, + mods, + &layout, + clipboard, + wl_display, + ); + + // The payload-held mirror, written last so a same-frame pickup or ESC clear shows: the + // Send-side view the world-click deselect gates on. + payload_held.0 = script.cursor_payload().is_some(); + + for err in script.take_errors() { + warn!("ui_script(input): {err}"); + } +} + +/// The keyboard half of the frame: each event as the key-down or repeat walk and what a box the +/// walk reached does with the key. `down` is this feed's pressed-key list, which decides repeat +/// as the bindings' does (`0x4248b3`), never from the OS flag: after an alt-tab wipes it +/// (`0x424790`, the reconcile against `keys`) the first repeat is a fresh press, and a platform +/// that never sets the flag still repeats right. `releases` says whether the key-up dispatch is +/// this feed's: in the world the bindings pass sends it, in order with the press it follows +/// (`crate::bindings`). +#[allow(clippy::too_many_arguments)] +pub(crate) fn feed_keys<'a>( + script: &mut UiScript, + capture: &mut UiKeyboardCapture, + events: impl Iterator, + keys: &ButtonInput, + down: &mut crate::bindings::KeyDownList, + releases: bool, + mods: keymap::Mods, + layout: &crate::bindings::LayoutNames, + clipboard: &mut HostClipboard, + wl_display: Option<*mut std::ffi::c_void>, +) { + down.reconcile(keys); + let keymap::Mods { + shift: _, + ctrl, + alt, + sup, + } = mods; let mac = cfg!(target_os = "macos"); - for ev in keyboard.read() { + for ev in events { + // What the layout names the key: the clipboard chords' letters, and a keyboard frame's + // `arg1`, which the Key Bindings window also stores, so a key's press dispatches by the + // name it was bound under. + let token = crate::bindings::chord::key_token(ev.key_code, layout); + // `None` is a key the client drops before any walk (a Windows or Command key, a media + // key): no down, no up, and no focus granted to an `autoFocus` box. + let name = crate::bindings::chord::walk_name(ev.key_code, layout); if ev.state != ButtonState::Pressed { + down.release(ev.key_code); + if let Some(name) = name.as_deref().filter(|_| releases) { + script.key_up(name); + } continue; } - let named = match ev.key_code { - KeyCode::Enter | KeyCode::NumpadEnter => Some("ENTER"), - KeyCode::Escape => Some("ESCAPE"), - KeyCode::Tab => Some("TAB"), - _ => None, + let repeat = down.press(ev.key_code); + let named = matches!( + ev.key_code, + KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Escape | KeyCode::Tab + ); + let walk = match name.as_deref() { + Some(name) if repeat => script.key_repeat(name), + Some(name) => script.key_down(name), + None => KeyWalk::Declined, }; - // What the layout names the key: the clipboard chords' letters, and a keyboard frame's - // `arg1` below. - let token = crate::bindings::chord::key_token(ev.key_code, &layout); - // Dispatched unconditionally: unfocused, they fall through to the camera and turn keys. + // The gate is existence, not handling: a shown keyboard frame with an `OnKeyDown` swallows + // the key whatever its script does (`0x76b7d0`, `0x76ba25`). Consumption suppresses only + // the key's binding: `OnChar` is a separate dispatcher (`0x765df0`), so the stack-split + // spinner still gets a digit its `OnKeyDown` ate, and it is not a focus change, so it + // releases nothing held. A box that took the key counts too: it returned 1 (`0x77b1db` + // -> `0x77b35e`) and the world frame, last in the walk, never saw it, so an F-key that + // gave an `autoFocus` box the focus runs no binding. An unconsumed press is acted on in + // the binding dispatch (`crate::bindings`), which runs after this pass and reads the + // capture gate. + if walk != KeyWalk::Declined { + capture.consumed.push(ev.key_code); + } + let editing_key = matches!( + ev.key_code, + KeyCode::Backspace + | KeyCode::Delete + | KeyCode::ArrowLeft + | KeyCode::ArrowRight + | KeyCode::ArrowUp + | KeyCode::ArrowDown + | KeyCode::Home + | KeyCode::End + ); + if walk == KeyWalk::Frame && editing_key { + // Not `typing`: a frame eating one key is not a box taking focus, and treating it as + // one would stop a held movement key. + continue; + } + if named { + continue; + } + // The box's own key-down switch (`0x77b160`) does the editing, copy, cut, paste and + // select-all, so only a box the walk reached acts; a frame that took the key, or a gated + // arrow the box declined, leaves it alone. let chord = keymap::chord(ev.key_code, token, mods, mac); - // A keyboard frame gets these by name before their chord runs (a dialog needs BACKSPACE); - // `frame_key_input` declines at a focused box, so no frame steals its editing keys. `true` - // suppresses the chord and the key's binding (the reference's existence gate, `0x76b7d0`). - let frame_named = match ev.key_code { - KeyCode::Backspace => Some("BACKSPACE"), - KeyCode::Delete => Some("DELETE"), - KeyCode::ArrowLeft => Some("LEFT"), - KeyCode::ArrowRight => Some("RIGHT"), - KeyCode::ArrowUp => Some("UP"), - KeyCode::ArrowDown => Some("DOWN"), - KeyCode::Home => Some("HOME"), - KeyCode::End => Some("END"), - _ => None, - }; - if let Some(name) = frame_named { - if script.frame_key_input(name) { - // Not `typing`: a frame eating one key is not a box taking focus, and treating it - // as one would stop a held movement key. - capture.consumed.push(ev.key_code); - continue; - } - } - // Every other key reaches a keyboard frame by name too: the reference's key-down walk - // takes its `arg1` from the table the binding chord uses (`0x4b66b0`), `chord::key_token` - // under the active layout here, so the Key Bindings window stores the name the key's - // press dispatches by. The gate is existence, not handling: a shown keyboard frame with - // an `OnKeyDown` swallows the key whatever its script does (`0x76b7d0`, `0x76ba25`). - // Consumption suppresses only the key's binding: `OnChar` is a separate dispatcher - // (`0x765df0`), so the stack-split spinner still gets a digit its `OnKeyDown` ate, and it - // is not a focus change, so it releases nothing held. - else if named.is_none() { - if let Some(token) = token { - if script.frame_key_input(&token.to_string()) { - capture.consumed.push(ev.key_code); - } - } - } - if let Some(name) = named { - // An unconsumed press is not acted on here: ESCAPE's close ladder, TAB's targeting and - // ENTER's chat open live in the binding dispatch (`crate::bindings`), which runs after - // this pass and reads the capture gate. - if script.key_input(name) { - capture.consumed.push(ev.key_code); - } - } else if let Some(chord) = chord { - // The gate is on the key, not the action: a gated arrow never reaches the box (the - // reference's `return 0`), while HOME/END, which also make a `Move`, do. - let gated_arrow = capture.arrows_fall_through - && matches!( - ev.key_code, - KeyCode::ArrowLeft - | KeyCode::ArrowRight - | KeyCode::ArrowUp - | KeyCode::ArrowDown - ); + if let Some(chord) = chord.filter(|_| walk == KeyWalk::Box) { match chord { - keymap::Chord::Edit(action) if !gated_arrow => { + keymap::Chord::Edit(action) => { script.editbox_action(action); } - // Declined: the key falls through to its binding, which `bindings.rs` allows - // because `arrows_fall_through` exempts exactly these four from the typing gate. - keymap::Chord::Edit(_) => {} // Copy and cut need a selection (`0x77e1d0`), and a password box yields its mask // run, never the real text; paste sanitizes and inserts. keymap::Chord::Copy => { @@ -322,26 +373,16 @@ pub(super) fn feed_ui_input( } } } - } else if !(sup || (ctrl && !alt)) { + } else if chord.is_none() && !(sup || (ctrl && !alt)) { // Command-modified chars never insert (Cmd+L must not type "l"), but Ctrl+Alt passes: // that is AltGr, and macOS Option+letter arrives with only `alt`. + // The char walk gates no binding (`0x765df0` reaches no keybinding), so whether a + // frame took the character is not recorded: the key-down already decided that. if let Some(text) = &ev.text { - // Same suppression as the named keys: the number row is a binding, so a digit typed - // into a keyboard frame (the stack-split spinner) must not also fire an action. - if script.char_input(text) { - capture.consumed.push(ev.key_code); - } + script.char_input(text); } } } - - // The payload-held mirror, written last so a same-frame pickup or ESC clear shows: the - // Send-side view the world-click deselect gates on. - payload_held.0 = script.cursor_payload().is_some(); - - for err in script.take_errors() { - warn!("ui_script(input): {err}"); - } } #[cfg(test)] diff --git a/crates/benilla-app/src/ui_script/key_walk_tests.rs b/crates/benilla-app/src/ui_script/key_walk_tests.rs new file mode 100644 index 00000000..ea1cefef --- /dev/null +++ b/crates/benilla-app/src/ui_script/key_walk_tests.rs @@ -0,0 +1,288 @@ +//! The host's keyboard feed ([`super::input::feed_keys`]) against the reference's four dispatchers: +//! key-down (`0x765f10`), char (`0x765df0`), key-up (`0x765fd0`) and auto-repeat (`0x766070`). + +use bevy::input::keyboard::{Key, KeyboardInput}; +use bevy::input::ButtonState; +use bevy::prelude::*; + +use benilla_ui::script::UiScript; + +use super::bag_tests::open_backpack_with_a_five_stack; +use super::input::feed_keys; +use super::UiKeyboardCapture; +use crate::bindings::{KeyDownList, LayoutNames}; +use crate::textinput::{keymap::Mods, HostClipboard}; + +/// One keyboard event as winit hands it over. +fn ev(code: KeyCode, state: ButtonState, repeat: bool, text: Option<&str>) -> KeyboardInput { + KeyboardInput { + key_code: code, + logical_key: Key::Character(text.unwrap_or("?").into()), + state, + text: text.map(Into::into), + repeat, + window: Entity::PLACEHOLDER, + } +} + +fn down(code: KeyCode, text: Option<&str>) -> KeyboardInput { + ev(code, ButtonState::Pressed, false, text) +} + +fn repeat(code: KeyCode) -> KeyboardInput { + ev(code, ButtonState::Pressed, true, None) +} + +fn up(code: KeyCode) -> KeyboardInput { + ev(code, ButtonState::Released, false, None) +} + +/// A harness for one script: the capture the bindings read, a clipboard holding `paste`. +struct Feed { + capture: UiKeyboardCapture, + clipboard: HostClipboard, + mods: Mods, + keys: ButtonInput, + down: KeyDownList, +} + +impl Feed { + fn new(paste: &str) -> Self { + Feed { + capture: UiKeyboardCapture::default(), + clipboard: HostClipboard::holding(paste), + mods: Mods { + shift: false, + ctrl: false, + alt: false, + sup: false, + }, + keys: ButtonInput::default(), + down: KeyDownList::default(), + } + } + + /// The events as the frame brings them, `ButtonInput` following them as bevy's does. + fn send(&mut self, script: &mut UiScript, events: &[KeyboardInput]) { + script.set_alt_down(self.mods.alt); + self.keys.clear(); + for ev in events { + match ev.state { + ButtonState::Pressed => self.keys.press(ev.key_code), + ButtonState::Released => self.keys.release(ev.key_code), + } + } + feed_keys( + script, + &mut self.capture, + events.iter(), + &self.keys, + &mut self.down, + true, + self.mods, + &LayoutNames::default(), + &mut self.clipboard, + None, + ); + } +} + +/// A chat-like box in alt-arrow mode at DIALOG, focused, over the real split dialog: the arrows +/// are the box's to decline (`0x77b18e`-`0x77b1c4`), so the dialog steps with them, and holding +/// RIGHT steps it once (`0x766070` hands the repeat to the dialog's stub). +#[test] +fn the_split_dialog_steps_with_the_arrows_under_a_focused_chat_box() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + let (x, y) = open_backpack_with_a_five_stack(&mut s); + s.set_modifiers(true, false, false); + s.mouse_button(x, y, "LeftButton", true); + s.mouse_button(x, y, "LeftButton", false); + s.set_modifiers(false, false, false); + s.run( + r#" + ChatLike = CreateFrame("EditBox", "ChatLike") + ChatLike:SetAutoFocus(false) + ChatLike:SetAltArrowKeyMode(1) + ChatLike:SetFrameStrata("DIALOG") + ChatLike:SetFrameLevel(StackSplitFrame:GetFrameLevel() + 1) + ChatLike:SetFocus() + "#, + ) + .unwrap(); + s.resolve(); + assert_eq!(s.eval::("return StackSplitFrame.split").unwrap(), 1); + + let mut feed = Feed::new(""); + feed.send(&mut s, &[down(KeyCode::ArrowRight, None)]); + assert_eq!( + s.eval::("return StackSplitFrame.split").unwrap(), + 2, + "the dialog took the arrow the box declined" + ); + assert!(feed.capture.consumed.contains(&KeyCode::ArrowRight)); + feed.send( + &mut s, + &[repeat(KeyCode::ArrowRight), repeat(KeyCode::ArrowRight)], + ); + assert_eq!( + s.eval::("return StackSplitFrame.split").unwrap(), + 2, + "holding RIGHT steps once" + ); + feed.send(&mut s, &[up(KeyCode::ArrowRight)]); + feed.send(&mut s, &[down(KeyCode::ArrowRight, None)]); + assert_eq!(s.eval::("return StackSplitFrame.split").unwrap(), 3); + assert_eq!( + s.eval::("return ChatLike:GetText()").unwrap(), + "", + "and the box saw nothing" + ); + + // With ALT the box keeps its arrows. + feed.mods.alt = true; + feed.send(&mut s, &[down(KeyCode::ArrowRight, None)]); + assert_eq!(s.eval::("return StackSplitFrame.split").unwrap(), 3); + assert!(s.take_errors().is_empty()); +} + +/// The paste chord with a keyboard frame above a focused box: the frame took the key-down, and the paste +/// is the box's own key-down arm (`0x77b160`), which the walk never reached. +#[test] +fn a_frame_that_took_the_key_keeps_the_paste_from_the_box() { + let mut s = UiScript::new().unwrap(); + s.run( + r#" + got = {} + box = CreateFrame("EditBox", "PasteBox") + box:SetAutoFocus(false) + box:SetFrameStrata("DIALOG") + box:SetFocus() + map = CreateFrame("Frame", "PasteMap") + map:SetFrameStrata("FULLSCREEN_DIALOG") + map:EnableKeyboard(true) + map:SetScript("OnKeyDown", function() table.insert(got, arg1) end) + "#, + ) + .unwrap(); + let mut feed = Feed::new("pasted"); + // The paste chord of the platform: Cmd+V on a Mac, Ctrl+V elsewhere. + feed.mods.ctrl = !cfg!(target_os = "macos"); + feed.mods.sup = cfg!(target_os = "macos"); + feed.send(&mut s, &[down(KeyCode::KeyV, Some("\u{16}"))]); + assert_eq!(s.eval::("return box:GetText()").unwrap(), ""); + assert_eq!( + s.eval::("return table.concat(got, ',')").unwrap(), + "V" + ); + + // The map closes: the walk reaches the box, which pastes. + s.run("map:Hide()").unwrap(); + feed.send(&mut s, &[up(KeyCode::KeyV)]); + feed.send(&mut s, &[down(KeyCode::KeyV, Some("\u{16}"))]); + assert_eq!(s.eval::("return box:GetText()").unwrap(), "pasted"); + assert!(s.take_errors().is_empty()); +} + +/// The modifiers go through the walk as key-downs named `SHIFT`, `CTRL` and `ALT` (`0x76b7d0`'s +/// table), the codes it cannot name (ScrollLock) as `UNKNOWN`, each with its key-up; a key the +/// client drops (Windows, Menu, fn, Command) walks as nothing. +#[test] +fn modifiers_and_unnamed_keys_walk_as_keys_with_their_ups() { + let mut s = UiScript::new().unwrap(); + s.run( + r#" + log = {} + f = CreateFrame("Frame", "ModWatch") + f:EnableKeyboard(true) + f:SetScript("OnKeyDown", function() table.insert(log, "v" .. arg1) end) + f:SetScript("OnKeyUp", function() table.insert(log, "^" .. arg1) end) + "#, + ) + .unwrap(); + let mut feed = Feed::new(""); + for code in [ + KeyCode::ShiftLeft, + KeyCode::ControlRight, + KeyCode::AltLeft, + KeyCode::ScrollLock, + // The client drops these before any walk (`0x42da39`; the Mac table's -1): no down, no up. + KeyCode::Fn, + KeyCode::SuperLeft, + KeyCode::ContextMenu, + ] { + feed.send(&mut s, &[down(code, None), up(code)]); + } + assert_eq!( + s.eval::("return table.concat(log, ',')").unwrap(), + "vSHIFT,^SHIFT,vCTRL,^CTRL,vALT,^ALT,vUNKNOWN,^UNKNOWN" + ); + assert!(feed.capture.consumed.contains(&KeyCode::ShiftLeft)); + assert!(s.take_errors().is_empty()); +} + +/// A box the walk reached ate the key (`0x77b1db` -> `0x77b35e`; the world frame never sees it), so +/// an F-key that gave an `autoFocus` box the focus runs no binding; the character walk gates +/// nothing, so a frame that took a character leaves its key's binding alone. +#[test] +fn a_box_that_took_the_key_eats_its_binding_and_a_char_gates_none() { + let mut s = UiScript::new().unwrap(); + s.run( + r#" + box = CreateFrame("EditBox", "EatBox") + box:SetAutoFocus(true) + box:Hide() box:Show() + box:ClearFocus() + chars = CreateFrame("Frame", "CharsOnly") + chars:SetFrameStrata("FULLSCREEN_DIALOG") + chars:EnableKeyboard(true) + chars:SetScript("OnChar", function() end) + "#, + ) + .unwrap(); + let mut feed = Feed::new(""); + feed.send(&mut s, &[down(KeyCode::F5, None)]); + assert_eq!(s.focused_editbox_name().as_deref(), Some("EatBox")); + assert!( + feed.capture.consumed.contains(&KeyCode::F5), + "the box ate F5, so its binding does not run" + ); + + s.run("box:ClearFocus() box:Hide()").unwrap(); + feed.send(&mut s, &[up(KeyCode::F5)]); + feed.capture.consumed.clear(); + feed.send(&mut s, &[down(KeyCode::KeyQ, Some("q"))]); + assert!( + feed.capture.consumed.is_empty(), + "OnChar took the character, which is no reason to eat Q's binding" + ); +} + +/// A key the client drops before any walk (Windows key, Command, fn) gives an `autoFocus` box no +/// focus and no key-up; the Windows key is `SuperLeft` here. +#[test] +fn a_dropped_key_walks_nowhere_and_grants_no_focus() { + let mut s = UiScript::new().unwrap(); + s.run( + r#" + box = CreateFrame("EditBox", "DropBox") + box:SetAutoFocus(true) + box:Hide() box:Show() + box:ClearFocus() + "#, + ) + .unwrap(); + let mut feed = Feed::new(""); + feed.send( + &mut s, + &[down(KeyCode::SuperLeft, None), up(KeyCode::SuperLeft)], + ); + assert_eq!( + s.focused_editbox_name(), + None, + "Cmd+Tab gives no box the focus" + ); + feed.send(&mut s, &[down(KeyCode::KeyA, Some("a"))]); + assert_eq!(s.focused_editbox_name().as_deref(), Some("DropBox")); +} diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index f7f121af..ecd34454 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -7,7 +7,7 @@ use bevy::prelude::*; use benilla_assets::LockRecover; -use benilla_ui::script::UiScript; +use benilla_ui::script::{GlueNotice, UiScript}; use super::manifest::silenced_ui_load; use super::{load_ingame_ui, CursorPayloadHeld, PlayerUiHover, UiClock, UiKeyboardCapture}; @@ -73,6 +73,7 @@ fn install_glue_vm(world: &mut World, screen: super::glue::GlueScreen) -> bool { return false; }; seat_raster_seam_for_load(world, &mut script); + seed_agreements(world, &mut script); let start = std::time::Instant::now(); let failures = super::glue::load_glue(&mut script); super::glue::finish_glue_load_log(&mut script); @@ -94,6 +95,22 @@ fn install_glue_vm(world: &mut World, screen: super::glue::GlueScreen) -> bool { charselect } +/// Read the four agreement CVars into the glue VM and the manager before any screen is set, as +/// the reference does (`0x46a400` at `0x402b36`, then `SET_GLUE_SCREEN` at `0x402b65`): the login +/// screen's `OnShow` asks them at once, and nothing asks again. +fn seed_agreements(world: &mut World, script: &mut UiScript) { + let Some(cvars) = world.get_resource::() else { + return; + }; + let notices = super::glue_mgr::read_agreements(cvars); + for notice in GlueNotice::ALL { + script.set_glue_notice(notice, notices[notice as usize]); + } + if let Some(mut mgr) = world.get_resource_mut::() { + mgr.set_notices(notices); + } +} + /// A fresh VM from `make` on the process clock with the addon asset probes wired, or `None` (the /// world's VM removed) when the VM cannot start. fn new_vm( @@ -1089,6 +1106,42 @@ mod tests { } } + /// The agreements are read from their CVars before the first screen is set (`0x46a400` at + /// `0x402b36`, `SET_GLUE_SCREEN` at `0x402b65`): a returning player's login shows the boxes, + /// not the EULA, and a first launch still shows the EULA. + #[test] + fn the_login_screen_comes_up_on_the_accepted_agreements() { + use crate::char_select::ClientState; + let _ = benilla_formats::wow_data_or_skip!(); + let screens = |cvars: crate::cvars::Cvars| { + let mut world = World::new(); + world.insert_resource(super::super::glue::GlueStock(true)); + world.insert_resource(State::new(ClientState::Login)); + world.insert_resource(cvars); + world.init_resource::(); + setup_script(&mut world); + world + .non_send_resource::() + .eval::<(bool, bool, bool)>( + "return TOSFrame:IsShown() == 1, AccountLoginUI:IsShown() == 1, \ + AccountLoginAccountEdit:IsVisible() == 1", + ) + .unwrap() + }; + let mut accepted = crate::cvars::Cvars::with_value("readEULA", "1"); + accepted.set("readTOS", "1"); + assert_eq!( + screens(accepted), + (false, true, true), + "accepted agreements go straight to the login boxes" + ); + assert_eq!( + screens(crate::cvars::Cvars::default()), + (true, false, false), + "a first launch is asked for the EULA" + ); + } + /// Under `WOW_GLUE_STOCK=1` the world holds the glue VM where it holds a boot VM, at startup /// on the login screen (`0x402b65`) and back from the world on the screen the exit enters /// (`0x402090`); a `ReloadUI()` never builds the glue. diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 7ed8274f..6a54c181 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -22,6 +22,8 @@ pub(crate) mod glue; pub(crate) mod glue_mgr; mod input; #[cfg(test)] +pub(crate) use input::feed_keys; +#[cfg(test)] mod layer_tests; mod load_log; mod manifest; @@ -315,7 +317,13 @@ impl Plugin for UiScriptPlugin { .init_resource::() .init_resource::() // After `AssetSet::Open`: the VM's first load is the chain's `GlobalStrings.lua`. - .add_systems(Startup, setup_script.after(benilla_assets::AssetSet::Open)) + .add_systems( + Startup, + // After the CVars load: the glue screens read the agreements as they come up. + setup_script + .after(benilla_assets::AssetSet::Open) + .after(crate::cvars::CvarLoad), + ) // The in-game UI loads on world entry, as the reference's does, once the loading cover // has presented, so its ~0.5 s never stalls the cover's first frame. .add_systems( @@ -721,6 +729,10 @@ pub(crate) fn load_ui_for_test(script: &benilla_ui::script::UiScript, entry: &st #[cfg(test)] mod cinematic_tests; +#[cfg(test)] +mod glue_input_tests; +#[cfg(test)] +mod key_walk_tests; #[cfg(test)] mod cast_tests; diff --git a/crates/benilla-app/src/ui_script/session.rs b/crates/benilla-app/src/ui_script/session.rs index 26ef9918..910e71df 100644 --- a/crates/benilla-app/src/ui_script/session.rs +++ b/crates/benilla-app/src/ui_script/session.rs @@ -178,6 +178,9 @@ mod tests { ("cursor.rs", "last_ptr"), // The wheel's fraction toward its next notch, a fact about the device, not the VM. ("bindings.rs", "notches"), + // The keys believed down, a fact about the keyboard; an alt-tab wipes it by reconcile. + ("bindings.rs", "down"), + ("ui_script/input.rs", "pressed"), ]; use crate::test_support::{fn_parameter_lists, rust_files}; diff --git a/crates/benilla-ui/src/loader/scripts.rs b/crates/benilla-ui/src/loader/scripts.rs index 2993d897..919d4eed 100644 --- a/crates/benilla-ui/src/loader/scripts.rs +++ b/crates/benilla-ui/src/loader/scripts.rs @@ -62,11 +62,10 @@ impl Loader<'_> { if name.eq_ignore_ascii_case("OnMouseWheel") { self.call(wrapper, "EnableMouseWheel", true, dbg); } - // The key handlers, the same rule (`OnChar` kind 0, `OnKeyDown`/`OnKeyUp` kind 1); - // here any of them sets the one flag both key walks read. - const KEY_KIND: [&str; 3] = ["OnChar", "OnKeyDown", "OnKeyUp"]; - if KEY_KIND.iter().any(|k| name.eq_ignore_ascii_case(k)) { - self.call(wrapper, "EnableKeyboard", true, dbg); + // The key handlers, the same rule, one bit each: `OnChar` the char bit, + // `OnKeyDown` and `OnKeyUp` the key bit (`0x769f6b`, `0x769fa4`). + if let Err(e) = crate::script::enable_for_script(self.lua, wrapper, &name) { + self.report.fault(format!("{dbg}: {name}: {e}")); } if name.eq_ignore_ascii_case("OnLoad") { // A cleared `OnLoad` also unsets a template's, or the caller would fire it. diff --git a/crates/benilla-ui/src/script/editbox.rs b/crates/benilla-ui/src/script/editbox.rs index bd84e0b7..6b7a0f65 100644 --- a/crates/benilla-ui/src/script/editbox.rs +++ b/crates/benilla-ui/src/script/editbox.rs @@ -4,10 +4,11 @@ //! - Focus: one owner ([`Model::focused_editbox`], the client's `DAT_00cf4dc8`). A left click //! focuses and collapses the selection to the clicked byte (`0x77b86f call 0x77ccf0`, before //! `SetFocus` at `0x77b881`), so it never selects all. An `autoFocus` box takes the focus on show -//! when none is held and on the first key, and a hidden box drops it ([`visibility_focus`]). -//! - Routing: a focused box consumes every key and char and fires only its specialized scripts -//! (Enter, Escape, Space, Tab, TextChanged, TextSet, focus), never a generic `OnKeyDown`; an -//! unfocused box that is not `autoFocus` ignores input. +//! when none is held and on the first key the walk offers it, and a hidden box drops it ([`visibility_focus`]). +//! - Routing: the key and char walks reach a box at its own place ([`super::keyboard`]); the focused +//! one consumes every key and char and fires only its specialized scripts (Enter, Escape, Space, +//! Tab, TextChanged, TextSet, focus), never a generic `OnKeyDown`; an unfocused box that is not +//! `autoFocus` ignores input. //! - Editing: an insert deletes the selection, then `numeric` aborts a whole insert on any //! non-digit; caps trim from the end (`maxBytes`, then `maxLetters`). //! - Events: an edit, a caret move, a focus change or a re-seat of the text only raises the box's @@ -27,7 +28,7 @@ use mlua::{Lua, Table}; use super::object::frame_handle_of; use super::types::{EditAction, EditUnit}; use super::{event, Model, RegionData}; -use crate::order::{self, DrawLayer, ZTarget}; +use crate::order::DrawLayer; use crate::widget::{EditBoxState, FrameHandle, FrameKind, KindState, RegionHandle, RegionKind}; /// Registry key of the EditBox method table (the MAXCSTACK discipline: Lua-side root, named key). @@ -153,39 +154,49 @@ pub(in crate::script) use relayout::reseat_resized; // Focus model // ───────────────────────────────────────────────────────────────────────────────────────────── -/// The box that processes a key or char. A hidden focused box blocks self-acquire (the client's -/// guard returns 0 with the focus still set); with no focus, the topmost visible `autoFocus` box -/// takes it and processes this same event. +/// The box that processes a key or char: the focused one, taken at the walk (`takes_event`). A +/// hidden focused box blocks (the client's guard returns 0 with the focus still set); with none +/// focused nothing processes, since an `autoFocus` box takes the focus only where the walk +/// reaches it. fn route(lua: &Lua) -> Option { - { - let mut model = lua.app_data_mut::().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 mut model = lua.app_data_mut::().expect("model app_data"); + let h = model.focused_editbox?; + match model.arena.frame(h) { + Some(f) if f.effective_visible && f.kind == FrameKind::EditBox => Some(h), + Some(_) => None, // alive but hidden: don't consume + None => { + model.focused_editbox = None; // stale + None } } - let h = topmost_autofocus(lua)?; - set_focus_handle(lua, h); - Some(h) } -/// The topmost-drawn visible `autoFocus` EditBox, the keyboard self-acquire target. -fn topmost_autofocus(lua: &Lua) -> Option { - let model = lua.app_data_ref::().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); - } +/// Whether the EditBox `h`, reached by the key or char walk, takes the event (its vtable's +/// `+0x5c`/`+0x60`/`+0x64`, `0x77a900`/`0x77b160`/`0x77b7c0`): with a box focused (`[0xcf4dc8]`, +/// even a hidden one) only that box does; with none, an `autoFocus` box takes the focus through +/// `SetFocus` at its own walk position and the event with it; any other box declines. +pub(super) fn takes_event(lua: &Lua, h: FrameHandle) -> bool { + { + let mut model = lua.app_data_mut::().expect("model app_data"); + if let Some(f) = model.focused_editbox { + if model.arena.frame(f).is_some() { + return f == h; } + model.focused_editbox = None; // stale: no box holds the focus + } + let auto = matches!( + model.arena.frame(h).map(|f| &f.kind_state), + Some(KindState::EditBox(eb)) if eb.auto_focus + ); + if !auto { + return false; } } - None + set_focus_handle(lua, h); + lua.app_data_ref::() + .expect("model app_data") + .focused_editbox + == Some(h) } /// `SetFocus` (`0x77e3d0`): needs effective visibility, a no-op when already focused; fires diff --git a/crates/benilla-ui/src/script/glue_core.rs b/crates/benilla-ui/src/script/glue_core.rs index a578483d..348898c6 100644 --- a/crates/benilla-ui/src/script/glue_core.rs +++ b/crates/benilla-ui/src/script/glue_core.rs @@ -1,6 +1,6 @@ //! The glue core's login-screen verbs (registrar `0x46ddf0`, table `0x8373b8`): the login, the //! status dialog's button, the saved account, the four user agreements, the build, the locale, -//! the server, the screen name, the glue music and the quit. Each reads a slot the host seeds or +//! the server, the screen name, the glue music, the movie frame's settings and the quit. Each reads a slot the host seeds or //! queues a [`GlueRequest`] the host's glue manager drains; the screen state machine they drive //! (`CGlueMgr::Update`, `0x46b930`) is the host's. @@ -147,6 +147,10 @@ pub(crate) struct GlueCore { /// The current glue screen's name (`[0xb41478]`), which `SetCurrentScreen` (`0x46ce60` β†’ /// `0x46b860`) copies into a 0x40-byte buffer. pub(super) screen: String, + /// `HideCursor` / `ShowCursor` (`0x46db70`/`0x46db60`, the device's `0x58b590`): whether the + /// glue has the pointer hidden. Nothing in the host reads it yet: the one caller, the movie + /// frame, hides the pointer for a movie that plays here for no frame at all. + cursor_hidden: bool, /// The realm list the realm verbs read ([`super::glue_realm`]). pub(super) realms: super::glue_realm::GlueRealmList, /// The character roster and the select verbs' cells ([`super::glue_select`]). @@ -187,6 +191,11 @@ impl super::UiScript { self.model_mut().glue.server = server; } + /// Whether the glue has hidden the pointer (`HideCursor`) and not shown it again. + pub fn glue_cursor_hidden(&self) -> bool { + self.model_ref().glue.cursor_hidden + } + /// The current glue screen's name, as `SetCurrentScreen` last set it. pub fn glue_screen(&self) -> String { self.model_ref().glue.screen.clone() @@ -430,6 +439,47 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { Ok(()) })?, )?; + + // One value (`0x46dbc0`): 1 when the `movieSubtitle` CVar's integer (`[0x882640]+0x28`) is + // nonzero, else nil. + g.set( + "GetMovieSubtitles", + lua.create_function(|lua, _: MultiValue| { + let model = lua.app_data_ref::().expect("model app_data"); + let on = model + .cvars + .get("moviesubtitle") + .is_some_and(|c| leading_int(&c.value) != 0); + Ok(flag(on)) + })?, + )?; + // One number (`0x46cf10`): the width of the `gxResolution` CVar's string, scanned as `"%dx%d"` + // (`0x837c9c`). A string with no leading integer leaves the scan's stack slot unwritten in the + // reference; 0 here. + g.set( + "GetMovieResolution", + lua.create_function(|lua, _: MultiValue| { + let model = lua.app_data_ref::().expect("model app_data"); + let width = model + .cvars + .get("gxresolution") + .map_or(0, |c| leading_int(&c.value)); + Ok(width as f64) + })?, + )?; + // The device's pointer off (`0x46db70` β†’ `0x58b590(0)`) and on (`0x46db60` β†’ `0x58b590(1)`). + for (name, hidden) in [("HideCursor", true), ("ShowCursor", false)] { + g.set( + name, + lua.create_function(move |lua, _: MultiValue| { + lua.app_data_mut::() + .expect("model app_data") + .glue + .cursor_hidden = hidden; + Ok(()) + })?, + )?; + } Ok(()) } @@ -566,4 +616,37 @@ mod tests { assert_eq!(s.glue_screen().len(), 0x3f); assert!(s.run("SetCurrentScreen()").is_err()); } + /// `GetMovieSubtitles` is 1 or nil off the `movieSubtitle` CVar's integer (`0x46dbc0`), + /// `GetMovieResolution` the width of `gxResolution` (`0x46cf10`), and the cursor pair moves + /// the hidden flag (`0x46db70`, `0x46db60`). + #[test] + fn the_movie_verbs_read_their_cvars_and_move_the_pointer_flag() { + let mut s = UiScript::new_glue().unwrap(); + s.register_cvars([("movieSubtitle", "0"), ("gxResolution", "1024x768")]); + assert!(s.eval::("return GetMovieSubtitles() == nil").unwrap()); + s.set_cvar_host("movieSubtitle", "1"); + assert_eq!( + s.eval::>("return GetMovieSubtitles()").unwrap(), + Some(1) + ); + s.set_cvar_host("movieSubtitle", "0"); + assert!(s.eval::("return GetMovieSubtitles() == nil").unwrap()); + + assert_eq!( + s.eval::("return GetMovieResolution()").unwrap(), + 1024.0 + ); + s.set_cvar_host("gxResolution", "800x600"); + assert_eq!(s.eval::("return GetMovieResolution()").unwrap(), 800.0); + assert!(s + .eval::("return table.getn({GetMovieResolution()}) == 1") + .unwrap()); + + assert!(!s.glue_cursor_hidden()); + s.run("HideCursor()").unwrap(); + assert!(s.glue_cursor_hidden()); + s.run("ShowCursor()").unwrap(); + assert!(!s.glue_cursor_hidden()); + assert!(s.take_errors().is_empty()); + } } diff --git a/crates/benilla-ui/src/script/keyboard.rs b/crates/benilla-ui/src/script/keyboard.rs index ef434453..65d40dd6 100644 --- a/crates/benilla-ui/src/script/keyboard.rs +++ b/crates/benilla-ui/src/script/keyboard.rs @@ -1,23 +1,41 @@ -//! Frame keyboard delivery. One dispatcher per [`Channel`] calls the keyboard-enabled frames in -//! [`walk_order`] until one consumes. Consumption is the existence of a script slot, not what the -//! handler does: a key-down consumes on `OnKeyDown` or `OnKeyUp` and fires only `OnKeyDown`, so a -//! frame with only `OnKeyUp` swallows key-downs and runs nothing; a char consumes on `OnChar`. -//! Membership is the keyboard-enabled flag (`0x76af00`), never a script: XML `enableKeyboard` and -//! an XML `` handler set it, Lua `SetScript` does not. +//! Frame keyboard delivery. One dispatcher per [`Channel`] calls the frames in that channel's +//! bucket in [`walk_order`] until one consumes. Consumption is the existence of a script slot, not +//! what the handler does: a key-down consumes on `OnKeyDown` or `OnKeyUp` and fires only +//! `OnKeyDown`, so a frame with only `OnKeyUp` swallows key-downs and runs nothing; a char +//! consumes on `OnChar`. Membership is the frame's char or key bit (`0x76af00`), never a script: +//! the EditBox ctor, XML `enableKeyboard` and an XML `` handler set it, Lua `SetScript` +//! does not. //! -//! The focused EditBox is a frame in the same walk, consuming through its own vtable, so a -//! keyboard frame in a higher stratum pre-empts it and one in a lower stratum does not. A key no -//! frame consumes falls through to the editbox routing, focus acquisition included. +//! An EditBox is a frame in the same walk, answering through its own vtable (`0x77a900`, +//! `0x77b160`): the focused box consumes, an unfocused one declines, and with no box focused an +//! `autoFocus` one takes the focus and the event at its own position. So a keyboard frame in a +//! higher stratum or level pre-empts the box, and one below it never sees the key. //! -//! `OnKeyUp` is stored and gates key-downs but never fires: the host feeds no key-up, so the -//! reference's key-up gate (`0x76bba0`, `OnKeyUp` alone) and its sticky per-code target -//! (`0x765fd0`, `[root+code*4+0x84]`, the last frame to consume that code's down) are not built. +//! The key-up (`0x765fd0`) does not walk. It goes to the frame that consumed that key's last +//! down (`[root+code*4+0x84]`, written at `0x765fa2`), through its `OnKeyUp` slot (`0x76bba0`), +//! and the auto-repeat (`0x766070`, category `0xa`) to the same frame's `+0x58`: a stub on a plain +//! frame (`0x46ff90`), the key-down again on an EditBox (`0x77b770`). A delivered up never clears +//! the record. use mlua::Lua; use super::{event, Model}; use crate::widget::{FrameHandle, FrameKind}; +/// What the key-down walk (`0x765f10`) came to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum KeyWalk { + /// No frame took the key: the world frame's binding is next. + Declined, + /// A frame took it (`0x76b7d0`'s existence gate): nothing below it, the focused box and the + /// keybinding included, sees it. + Frame, + /// The walk reached an EditBox that took it: focused already or, with none focused, an + /// `autoFocus` box that just took the focus (`0x77b1c7`-`0x77b1db`). The key's editing action + /// is that box's. + Box, +} + /// The two walks the reference registers separately. #[derive(Clone, Copy, PartialEq, Eq)] enum Channel { @@ -27,15 +45,28 @@ enum Channel { KeyDown, } -/// The walk order (`0x765f10`): keyboard-enabled, effectively-visible frames, strata TOOLTIP down -/// to WORLD, then level descending, ties oldest registration first as the inserter keeps them -/// (`0x764aa0`). -fn walk_order(model: &Model) -> Vec { - let mut candidates: Vec<(FrameHandle, u8, u16, u32)> = model +/// The walk order (`0x765f10`): the channel's enabled, effectively-visible frames, strata TOOLTIP +/// down to WORLD, then level descending, ties oldest first by when each joined the bucket +/// (`0x764aa0` inserts after the equal levels; a show, a level or strata change and a live enable +/// each insert again). +fn walk_order(model: &Model, channel: Channel) -> Vec { + let mut candidates: Vec<(FrameHandle, u8, u16, u64)> = 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)) + .filter(|(_, f)| { + f.effective_visible + && match channel { + Channel::Char => f.char_enabled, + Channel::KeyDown => f.key_enabled, + } + }) + .map(|(h, f)| { + let stamp = match channel { + Channel::Char => f.char_stamp, + Channel::KeyDown => f.key_stamp, + }; + (h, f.strata as u8, f.level, stamp) + }) .collect(); 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() @@ -46,11 +77,9 @@ fn has_script(lua: &Lua, id: u32, name: &str) -> bool { event::has_widget_handler(lua, id, name) } -/// Whether `h` is a `CSimpleEditBox`, which the walk asks about focus, never a script slot: its -/// ctor (`0x779ce8`) puts it in both buckets, and its vtable `0x81c910` replaces the input slots -/// (`+0x5c` with `0x77a900`, `+0x60` with `0x77b160`) without chaining to the base, consuming -/// when focused and otherwise declining unless it takes a free focus as `autoFocus` -/// (`0x77a952`/`0x77a956`, `0x77b1c7`/`0x77b21e`). So an unfocused box's `OnChar` (`+0x180`) +/// Whether `h` is a `CSimpleEditBox`, which the walk asks [`super::editbox::takes_event`] about, +/// never a script slot: its ctor (`0x779ce8`) puts it in both buckets, and its vtable `0x81c910` +/// replaces the input slots without chaining to the base. So an unfocused box's `OnChar` (`+0x180`) /// never runs and never eats a neighbour's keys. fn is_editbox(lua: &Lua, h: FrameHandle) -> bool { let model = lua.app_data_ref::().expect("model app_data"); @@ -60,62 +89,233 @@ fn is_editbox(lua: &Lua, h: FrameHandle) -> bool { .is_some_and(|f| f.kind == FrameKind::EditBox) } -/// One channel's walk, gate and fire. Returns whether a frame consumed the event, which is what -/// suppresses the keybinding; a handler's own return is discarded (`0x7026f0`). -fn walk(lua: &Lua, channel: Channel, arg: &str) -> bool { - let order = { - let model = lua.app_data_ref::().expect("model app_data"); - walk_order(&model) +/// Whether the EditBox `h` declines `key` before it looks at focus (`0x77b160`): PRINTSCREEN +/// (`0x77b17f`), and the four arrows on a box in alt-arrow mode (`ignoreArrows`) with ALT up +/// (`0x77b18e`-`0x77b1c4`), so the walk goes on below it and the world frame's binding can run. +fn box_declines(lua: &Lua, h: FrameHandle, key: &str) -> bool { + if key.eq_ignore_ascii_case("PRINTSCREEN") { + return true; + } + let arrow = ["LEFT", "UP", "RIGHT", "DOWN"] + .iter() + .any(|a| key.eq_ignore_ascii_case(a)); + let model = lua.app_data_ref::().expect("model app_data"); + arrow + && !model.alt_down + && model.arena.frame(h).is_some_and(|f| { + matches!(&f.kind_state, + crate::widget::KindState::EditBox(eb) if eb.alt_arrow_key_mode) + }) +} + +/// Whether the frame's own key or char slot takes the event, firing it: the base gates +/// `0x76b760` (`OnChar` alone) and `0x76b7d0` (either key slot, `OnKeyDown` fired). +fn frame_takes(lua: &Lua, h: FrameHandle, channel: Channel, arg: &str) -> bool { + let id = { + let mut model = lua.app_data_mut::().expect("model app_data"); + model.frame_id(h) }; - for h in order { - // The focused box answers through its own vtable, at its walk position. - let is_focused_box = { - let model = lua.app_data_ref::().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 { + match channel { + Channel::Char => { + if has_script(lua, id, "OnChar") { + fire(lua, id, "OnChar", arg); return true; } - continue; } - // An unfocused box declines here; the topmost `autoFocus` box takes a free focus in - // `super::editbox::route` once the whole walk declines, where the reference's takes it - // at its own walk position (`0x77a917`, `0x77b1c7`). - if is_editbox(lua, h) { - continue; - } - let id = { - let mut model = lua.app_data_mut::().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`: 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; + Channel::KeyDown => { + let down = has_script(lua, id, "OnKeyDown"); + if down || has_script(lua, id, "OnKeyUp") { + remember(lua, arg, h); + if down { + fire(lua, id, "OnKeyDown", arg); } + return true; } } } false } +/// The char walk (`0x765df0`): whether a frame or a box took the character. +fn walk_char(lua: &Lua, text: &str) -> bool { + let order = { + let model = lua.app_data_ref::().expect("model app_data"); + walk_order(&model, Channel::Char) + }; + for h in order { + if is_editbox(lua, h) { + if super::editbox::takes_event(lua, h) && super::editbox::char_input(lua, text) { + return true; + } + } else if frame_takes(lua, h, Channel::Char, text) { + return true; + } + } + false +} + +/// The key-down walk (`0x765f10`). A handler's own return is discarded (`0x7026f0`). +fn walk_key(lua: &Lua, key: &str) -> KeyWalk { + let order = { + let model = lua.app_data_ref::().expect("model app_data"); + walk_order(&model, Channel::KeyDown) + }; + for h in order { + if is_editbox(lua, h) { + if box_declines(lua, h, key) || !super::editbox::takes_event(lua, h) { + continue; + } + if super::editbox::key_input(lua, key) { + remember(lua, key, h); + return KeyWalk::Box; + } + } else if frame_takes(lua, h, Channel::KeyDown, key) { + return KeyWalk::Frame; + } + } + KeyWalk::Declined +} + +/// The XML walker's auto-enable after a `SetScript` (`0x769ef0`): `OnChar` sets the char bit +/// (`0x769f6b`), `OnKeyDown` and `OnKeyUp` the key bit (`0x769fa4`), each alone. Lua's `SetScript` +/// never does (`0x7748d0`). +pub(crate) fn enable_for_script(lua: &Lua, this: &mlua::Table, script: &str) -> mlua::Result<()> { + let char_bit = script.eq_ignore_ascii_case("OnChar"); + let key_bit = + script.eq_ignore_ascii_case("OnKeyDown") || script.eq_ignore_ascii_case("OnKeyUp"); + if !(char_bit || key_bit) { + return Ok(()); + } + let h = super::object::frame_handle_of(lua, this)?; + let mut model = lua.app_data_mut::().expect("model app_data"); + if char_bit { + model.arena.set_char_enabled(h, true); + } else { + model.arena.set_key_enabled(h, true); + } + Ok(()) +} + +/// The consumer of a key-down, which that key's up and repeats go to (`0x765fa2`). +fn remember(lua: &Lua, key: &str, h: FrameHandle) { + lua.app_data_mut::() + .expect("model app_data") + .key_consumers + .insert(key.to_string(), h); +} + +/// A binding took the key's press: the world frame's key-down (`0x483b70`) ran it and returned +/// that, so the world frame is the consumer, and the up and repeats go to it +/// (`0x483bd0`), not to whichever frame took the key before. +pub(super) fn note_world_key(lua: &Lua, key: &str) { + let world = { + let model = lua.app_data_ref::().expect("model app_data"); + let found = model + .arena + .iter_frames() + .find(|(_, f)| f.kind == FrameKind::WorldFrame) + .map(|(h, _)| h); + found + }; + if let Some(h) = world { + remember(lua, key, h); + } +} + +/// Whether `h` is linked, the base handlers' first test (`[+0xd4]`, `0x76bba7`, `0x77b7c0`). +fn linked(lua: &Lua, h: FrameHandle) -> bool { + let model = lua.app_data_ref::().expect("model app_data"); + model.arena.frame(h).is_some_and(|f| f.effective_visible) +} + +/// What the key-up dispatcher (`0x765fd0`) did with a release. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum KeyUp { + /// A consumer was on record and got the up (or was gone or hidden, which ends it the same). + Delivered, + /// No consumer on record, and the release was PRINTSCREEN, which the client runs through the + /// key-down walk (`0x76603c`-`0x76604d`): Windows sends that key as an up alone. `Declined` + /// leaves the world frame's binding to the host. + Walked(KeyWalk), + /// No consumer on record: nothing. + Unclaimed, +} + +impl KeyUp { + /// Whether the release was taken, by a recorded consumer or the PRINTSCREEN walk. + pub fn taken(self) -> bool { + !matches!(self, KeyUp::Unclaimed | KeyUp::Walked(KeyWalk::Declined)) + } +} + +/// The key-up dispatcher (`0x765fd0`): the key's last consumer gets `OnKeyUp` with the key's name +/// (`0x76bba0`, gated on that slot alone, and on being linked), however the key stands now. An +/// EditBox consumer runs no script: its override (`0x77b7c0`) only grants an `autoFocus` box the +/// free focus. A delivered up leaves the record. With none on record the release is nothing, +/// except PRINTSCREEN's, which runs the key-down walk and then clears the record that walk wrote +/// (`0x76603c`-`0x76605b`). +pub(super) fn key_up(lua: &Lua, key: &str) -> KeyUp { + let Some(h) = lua + .app_data_ref::() + .expect("model app_data") + .key_consumers + .get(key) + .copied() + else { + if !key.eq_ignore_ascii_case("PRINTSCREEN") { + return KeyUp::Unclaimed; + } + let walked = walk_key(lua, key); + lua.app_data_mut::() + .expect("model app_data") + .key_consumers + .remove(key); + return KeyUp::Walked(walked); + }; + if !linked(lua, h) { + return KeyUp::Delivered; + } + if is_editbox(lua, h) { + super::editbox::takes_event(lua, h); + return KeyUp::Delivered; + } + let id = { + let mut model = lua.app_data_mut::().expect("model app_data"); + model.frame_id(h) + }; + if has_script(lua, id, "OnKeyUp") { + fire(lua, id, "OnKeyUp", key); + } + KeyUp::Delivered +} + +/// The auto-repeat dispatcher (`0x766070`): the key's consumer's `+0x58`, and any consumer on +/// record makes the repeat consumed whatever that returns (`0x7660bb`). A plain frame's is a stub +/// (`0x46ff90`): [`KeyWalk::Frame`], nothing runs. An EditBox's (`0x77b770`) grants an `autoFocus` +/// box the free focus first (`0x77b799`) and then runs its key-down, which may decline: the +/// repeat is [`KeyWalk::Box`] only when the box acted, else `Frame`, consumed and inert. No +/// consumer on record: `Declined`, and nothing happens. +pub(super) fn key_repeat(lua: &Lua, key: &str) -> KeyWalk { + let Some(h) = lua + .app_data_ref::() + .expect("model app_data") + .key_consumers + .get(key) + .copied() + else { + return KeyWalk::Declined; + }; + if is_editbox(lua, h) + && linked(lua, h) + && super::editbox::takes_event(lua, h) + && !box_declines(lua, h, key) + && super::editbox::key_input(lua, key) + { + return KeyWalk::Box; + } + KeyWalk::Frame +} + /// Fire one key handler with its one argument, a string on all three channels (`0x7026f0`); /// errors are recorded in [`Model::errors`], never raised into the dispatch. fn fire(lua: &Lua, id: u32, script: &str, arg: &str) { @@ -138,45 +338,11 @@ fn fire(lua: &Lua, id: u32, script: &str, arg: &str) { /// The char channel (`0x765df0`): `arg1` is the character in UTF-8, a multi-byte codepoint whole /// (`0x41abb0` encodes with no range guard). pub(super) fn char_input(lua: &Lua, text: &str) -> bool { - walk(lua, Channel::Char, text) + walk_char(lua, text) } /// The key-down channel (`0x765f10`): `arg1` is the key name without modifiers, from the same /// table as the keybinding chord (`0x76b7d0`, `0x4b66b0`); the host already speaks those names. -pub(super) fn key_input(lua: &Lua, key: &str) -> bool { - walk(lua, Channel::KeyDown, key) -} - -/// The key-down walk for the editing keys a focused box takes as a [`crate::script::EditAction`] -/// (BACKSPACE, DELETE, the arrows, HOME, END): a frame above the box consumes them, and the walk -/// stops at the box with `false` for its chord path, so no frame below it takes them. -pub(super) fn frame_key_input(lua: &Lua, key: &str) -> bool { - let order = { - let model = lua.app_data_ref::().expect("model app_data"); - walk_order(&model) - }; - for h in order { - { - let model = lua.app_data_ref::().expect("model app_data"); - if model.focused_editbox == Some(h) { - return false; // the box's chord path owns this key - } - } - // An unfocused box declines rather than gating on `+0x188`/`+0x190`. - if is_editbox(lua, h) { - continue; - } - let id = { - let mut model = lua.app_data_mut::().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 +pub(super) fn key_down(lua: &Lua, key: &str) -> KeyWalk { + walk_key(lua, key) } diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index a6bdaf2a..a0f659a7 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -93,6 +93,8 @@ mod item_stats; mod item_text; pub mod keybind; mod keyboard; +pub(crate) use keyboard::enable_for_script; +pub use keyboard::{KeyUp, KeyWalk}; mod layout; mod layout_cache; mod loot; @@ -104,6 +106,7 @@ mod measure; mod merchant; mod messageframe; mod minimap; +mod movieframe; pub mod nameplate; pub use nameplate::{PlateBitWrites, PlateGeometry, PlateState, PLATE_BIT_ENEMY, PLATE_BIT_FRIEND}; mod model; @@ -421,7 +424,7 @@ pub const SCREEN: crate::layout::Handle = 0; /// ScrollFrame's three scroll kinds are `+0x32c`/`+0x334`/`+0x33c` (script-name map `0x786c40`). /// The EditBox's vtable (`0x81c910`) replaces the key and char slots: an EditBox never fires /// `OnKeyDown`, and fires `OnChar` only from `Insert`, with the inserted text (`0x77c13c`). -const SCRIPT_KINDS: [&str; 41] = [ +const SCRIPT_KINDS: [&str; 44] = [ "OnLoad", "OnEvent", "OnUpdate", @@ -475,9 +478,15 @@ const SCRIPT_KINDS: [&str; 41] = [ "OnDoubleClick", // The layout event (base map `0x76a0d0`, `+0x120`), fired by the resolve pass on a size change. "OnSizeChanged", - // The key channels, fired by [`keyboard`]'s walk (`0x765f10`). `OnKeyUp` never fires, as the - // host feeds no key-up, but a frame carrying only `OnKeyUp` still consumes every key-down and - // runs nothing, the reference's own asymmetry. + // The movie frame's three (script map `0x78c2e0`): fired by its player alone, which does not + // exist here ([`movieframe`]), so they are stored and never run. + "OnMovieFinished", + "OnMovieShowSubtitle", + "OnMovieHideSubtitle", + // The key channels: `OnChar` and `OnKeyDown` fired by [`keyboard`]'s walks (`0x765df0`, + // `0x765f10`), `OnKeyUp` by the key-up dispatcher (`0x765fd0`) on the frame that took the + // key's down. A frame carrying only `OnKeyUp` still consumes every key-down and runs nothing + // on it, the reference's own asymmetry. "OnChar", "OnKeyDown", "OnKeyUp", @@ -548,6 +557,7 @@ fn install_framescript(vm: VmKind) -> mlua::Result { simplehtml::install(&lua)?; slider::install(&lua)?; colorselect::install(&lua)?; + movieframe::install(&lua)?; modelframe::install(&lua)?; // `0x4587f0` (table `0x835a50`): `PlaySound`, `PlayMusic`, `PlaySoundFile`, `StopMusic`. @@ -1122,16 +1132,17 @@ impl UiScript { // ── Keyboard entry ─────────────────────────────────────────────────────────────────────────── // - // Keys arrive by name; the host maps its keycodes. The box routing: a focused box consumes - // every event; with none focused, the topmost visible `autoFocus` box takes focus and + // Keys arrive by name; the host maps its keycodes. Every EditBox is a frame in the key and + // char walks ([`keyboard`]): the focused box consumes every event at its own strata and level; + // with none focused, the first visible `autoFocus` box in walk order takes focus and // processes that same event; otherwise nothing is consumed. /// A typed character (UTF-8) from the host: a focused box inserts it (numeric, cap and /// password rules apply) or, for Ctrl+A's control code, selects all. `true` if consumed. pub fn char_input(&mut self, text: &str) -> bool { - // The frame walk first ([`keyboard`]), the focused box a participant at its own strata and - // level, as in the reference's one dispatcher; an unconsumed event goes to the box routing. - keyboard::char_input(&self.lua, text) || editbox::char_input(&self.lua, text) + // The one dispatcher (`0x765df0`): the boxes are frames in the walk, at their own strata + // and level. + keyboard::char_input(&self.lua, text) } /// Paste host clipboard text into the focused EditBox as one edit: newlines survive only in a @@ -1140,18 +1151,45 @@ impl UiScript { editbox::paste(&self.lua, text) } - /// A non-character key by name (`"ENTER"`, `"ESCAPE"`, `"TAB"`); editing keys come through - /// [`Self::editbox_action`]. A focused box consumes a key even when it does nothing with it. - pub fn key_input(&mut self, key: &str) -> bool { - // The same two stages as `char_input`. - keyboard::key_input(&self.lua, key) || editbox::key_input(&self.lua, key) + /// A key pressed, by name (`"ENTER"`, `"A"`, `"SHIFT"`, `"UNKNOWN"`): the key-down walk + /// (`0x765f10`), which an EditBox joins at its own place. `Box` means the walk reached a box + /// that took the key, and the host then runs the key's editing action on it + /// ([`Self::editbox_action`], [`Self::paste`]); `Frame`, a frame that took it, and the host + /// runs neither the action nor the key's binding (`0x76b7d0`). + pub fn key_down(&mut self, key: &str) -> KeyWalk { + keyboard::key_down(&self.lua, key) } - /// An editing key (BACKSPACE, DELETE, the arrows, HOME, END) offered to the keyboard frames - /// before the focused box gets it as an [`EditAction`]. On `true` the caller dispatches neither - /// the action nor the key's binding (consumption suppresses it, `0x76b7d0`). + /// A key held down and repeating, by name: the auto-repeat dispatcher (`0x766070`) hands it to + /// the frame that took the press, a stub on a plain frame and the key-down again on a box. + pub fn key_repeat(&mut self, key: &str) -> KeyWalk { + keyboard::key_repeat(&self.lua, key) + } + + /// A key released, by the name its press went by: the key-up dispatcher (`0x765fd0`) runs + /// `OnKeyUp` on the frame that consumed that key's last down. + pub fn key_up(&mut self, key: &str) -> KeyUp { + keyboard::key_up(&self.lua, key) + } + + /// A binding took this key's press: the world frame is its consumer (`0x483b70`). + pub fn note_world_key(&mut self, key: &str) { + keyboard::note_world_key(&self.lua, key); + } + + /// Whether ALT is held, which an alt-arrow EditBox reads for the arrows (`0x77b1b3`). + pub fn set_alt_down(&mut self, alt: bool) { + self.model_mut().alt_down = alt; + } + + /// Whether the key-down walk takes `key`, a frame or a box. + pub fn key_input(&mut self, key: &str) -> bool { + self.key_down(key) != KeyWalk::Declined + } + + /// Whether a frame, not a box, takes `key`. pub fn frame_key_input(&mut self, key: &str) -> bool { - keyboard::frame_key_input(&self.lua, key) + self.key_down(key) == KeyWalk::Frame } /// One text-editing operation on the focused EditBox, from the host's per-OS keymap. diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index a371d29a..6b80207e 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -282,6 +282,12 @@ pub(crate) struct Model { /// The focused edit box (`0xcf4dc8`), taking all keys while visible; `None` admits `autoFocus`. pub(crate) focused_editbox: Option, + /// ALT is held (`0x41f8f0(2)`), as the host reads it: an alt-arrow EditBox takes the arrows + /// only then. + pub(crate) alt_down: bool, + /// Per key name, the frame that consumed its last key-down: where its key-up and repeats go + /// (`[root+code*4+0x84]`, `0x765fa2`; [`super::keyboard`]). + pub(crate) key_consumers: HashMap, /// The mouse-enabled frame under the cursor, for the next move's `OnLeave`/`OnEnter`. pub(crate) mouseover: Option, @@ -1221,6 +1227,8 @@ impl Model { frame_events: HashMap::new(), all_event_frames: Vec::new(), focused_editbox: None, + alt_down: false, + key_consumers: HashMap::new(), mouseover: None, hover_repick: false, world_mouseover: None, diff --git a/crates/benilla-ui/src/script/movieframe.rs b/crates/benilla-ui/src/script/movieframe.rs new file mode 100644 index 00000000..c1886a38 --- /dev/null +++ b/crates/benilla-ui/src/script/movieframe.rs @@ -0,0 +1,126 @@ +//! The `MovieFrame` methods (`CSimpleMovieFrame`, factory `0x6ef000`; method table `0x87ab4c`): +//! `StartMovie` (`0x78d770`), `StopMovie` (`0x78d870`) and `EnableSubtitles` (`0x78d940`). +//! +//! Deviation: benilla plays no pre-rendered movie (#968), so `StartMovie` answers nil, the +//! reference's "could not start" (`0x78d861`), which the stock `MovieFrame_PlayMovie` answers by +//! hiding the frame and so by returning to the login screen. With no movie ever running, +//! `StopMovie` has nothing to stop: `0x78ced0` returns at once when the playing slot `+0x314` is +//! clear, and only past that test fires `OnMovieFinished` (`0x78cf0b`-`0x78cf1e`). The three +//! `OnMovie*` scripts (script map `0x78c2e0`, slots `+0x320`/`+0x328`/`+0x330`) are accepted by +//! `SetScript` and never run. + +use mlua::{Lua, MultiValue, Table, Value}; + +use super::binding_abi::bool_or_default; +use super::font_block::usage_name; +use super::object::frame_handle_of; +use super::Model; +use crate::widget::FrameKind; + +/// Registry key of the MovieFrame method table (the MAXCSTACK discipline: a Lua-side root). +pub(super) const REG_MOVIEFRAME_METHODS: &str = "__benilla_movieframe_methods"; + +/// Raises unless `this` is a live MovieFrame: the method table is a plain Lua value a caller can +/// lift onto another frame (`0x847e98`, "Wrong object type for member function"). +fn check_movie_frame(lua: &Lua, this: &Table) -> mlua::Result<()> { + let h = frame_handle_of(lua, this)?; + let model = lua.app_data_ref::().expect("model app_data"); + match model.arena.frame(h) { + Some(f) if f.kind == FrameKind::MovieFrame => Ok(()), + _ => Err(mlua::Error::runtime( + "Wrong object type for member function", + )), + } +} + +pub(super) fn install(lua: &Lua) -> mlua::Result<()> { + let m = lua.create_table()?; + + // `lua_isstring` on the path (`0x6f3510`: a string or a number), else + // `Usage: %s:StartMovie("filename")` (`0x87ab8c`); one value back (`0x78d84d`/`0x78d861`), + // 1 for a movie that started and nil for one that did not. + m.set( + "StartMovie", + lua.create_function(|lua, (this, path): (Table, Value)| { + check_movie_frame(lua, &this)?; + if !matches!( + path, + Value::String(_) | Value::Integer(_) | Value::Number(_) + ) { + return Err(mlua::Error::runtime(format!( + "Usage: {}:StartMovie(\"filename\")", + usage_name(&this) + ))); + } + Ok(Value::Nil) + })?, + )?; + // No arguments, no returns (`0x78d8b8`). + m.set( + "StopMovie", + lua.create_function(|lua, this: Table| check_movie_frame(lua, &this))?, + )?; + // The flag defaults to true when absent (`0x6f1c10` with the default `1`, as + // `EnableKeyboard`'s `0x776f4e`), stored at `+0x31c` for the player's subtitle events, which + // nothing here fires; no returns. + m.set( + "EnableSubtitles", + lua.create_function(|lua, (this, args): (Table, MultiValue)| { + check_movie_frame(lua, &this)?; + let _enabled = bool_or_default(args.front(), true); + Ok(()) + })?, + )?; + + lua.set_named_registry_value(REG_MOVIEFRAME_METHODS, m)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::script::UiScript; + + /// `StartMovie` answers nil (no player), takes a string or a number, and raises its usage on + /// anything else; `StopMovie` and `EnableSubtitles` answer nothing; and the three player + /// scripts are taken by `SetScript` and read back. + #[test] + fn the_movie_frame_takes_its_methods_and_scripts_and_plays_nothing() { + let s = UiScript::new_glue().unwrap(); + s.run(r#"M = CreateFrame("MovieFrame", "MovieProbe")"#) + .unwrap(); + assert!(s + .eval::(r#"return M:StartMovie("a\\b") == nil"#) + .unwrap()); + assert!(s.eval::("return M:StartMovie(7) == nil").unwrap()); + assert!(s + .eval::("return table.getn({M:StopMovie()}) == 0") + .unwrap()); + assert!(s + .eval::("return table.getn({M:EnableSubtitles(1)}) + table.getn({M:EnableSubtitles()}) == 0") + .unwrap()); + for bad in ["M:StartMovie()", "M:StartMovie({})", "M:StartMovie(true)"] { + let err = s.run(bad).unwrap_err().to_string(); + assert!( + err.contains(r#"Usage: MovieProbe:StartMovie("filename")"#), + "{bad}: {err}" + ); + } + for name in [ + "OnMovieFinished", + "OnMovieShowSubtitle", + "OnMovieHideSubtitle", + ] { + s.run(&format!("M:SetScript('{name}', function() end)")) + .unwrap(); + assert!( + s.eval::(&format!("return M:GetScript('{name}') ~= nil")) + .unwrap(), + "{name}" + ); + } + // A plain frame has none of the methods. + s.run(r#"P = CreateFrame("Frame", "NotAMovie")"#).unwrap(); + assert!(s.eval::("return P.StartMovie == nil").unwrap()); + assert!(s.errors().is_empty(), "{:?}", s.errors()); + } +} diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index 35757b69..2c6d194a 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -507,6 +507,7 @@ fn kind_method_registries(kind: Option) -> &'static [&'static str] { Some(FrameKind::SimpleHtml) => &[super::simplehtml::REG_SIMPLEHTML_METHODS], Some(FrameKind::Slider) => &[super::slider::REG_SLIDER_METHODS], Some(FrameKind::ColorSelect) => &[super::colorselect::REG_COLORSELECT_METHODS], + Some(FrameKind::MovieFrame) => &[super::movieframe::REG_MOVIEFRAME_METHODS], Some(FrameKind::Button) => &[super::button::REG_BUTTON_METHODS], // Its one method, then `Button`'s: `0x4c1be0` probes its map `0xb71b64`, then `0x782c90`. Some(FrameKind::LootButton) => &[ diff --git a/crates/benilla-ui/src/script/object/events_regions.rs b/crates/benilla-ui/src/script/object/events_regions.rs index f680d634..ef081fd8 100644 --- a/crates/benilla-ui/src/script/object/events_regions.rs +++ b/crates/benilla-ui/src/script/object/events_regions.rs @@ -220,8 +220,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { /// `SetScript(name, func)`: stores the closure under one of [`SCRIPT_KINDS`], else raises. /// Deviation: real 1.12 kinds nothing fires yet raise too (`OnMessageScrollChanged`, -/// `OnInputLanguageChanged`, the movie frame's), because an accepted handler that never runs fails -/// silently. `OnAttributeChanged` is 2.0's, with no 1.12 slot. +/// `OnInputLanguageChanged`), because an accepted handler that never runs fails silently. The movie +/// frame's three are accepted all the same: the stock XML sets them, and its player ([`super::super::movieframe`]) +/// is the one thing that fires them. `OnAttributeChanged` is 2.0's, with no 1.12 slot. fn set_script(lua: &Lua, this: &Table, name: &str, func: Option) -> mlua::Result<()> { let kind = SCRIPT_KINDS .iter() diff --git a/crates/benilla-ui/src/script/tests/glue_surface.rs b/crates/benilla-ui/src/script/tests/glue_surface.rs index 1e27e424..5652b7e8 100644 --- a/crates/benilla-ui/src/script/tests/glue_surface.rs +++ b/crates/benilla-ui/src/script/tests/glue_surface.rs @@ -139,8 +139,8 @@ const NOT_YET: &[(&str, &[&str])] = &[ &["ReadFile", "DeleteFile", "AppendToFile"], ), ( - "the glue core (0x46ddf0, table 0x8373b8): the addon list, the movie, the patch and survey, \ - the billing, the cursor, the URLs and the credits music; each a binding of its own, the \ + "the glue core (0x46ddf0, table 0x8373b8): the addon list, the movie's setter, the patch and \ + survey, the billing, the URLs and the credits music; each a binding of its own, the \ ones with an in-game namesake included", &[ "DisableAddOn", @@ -154,11 +154,8 @@ const NOT_YET: &[(&str, &[&str])] = &[ "GetBillingPlan", "GetBillingTimeRemaining", "GetBillingTimeRested", - "GetMovieResolution", - "GetMovieSubtitles", "GetNumAddOns", "GetScriptMemory", - "HideCursor", "IsAddonVersionCheckEnabled", "LaunchAddOnURL", "LaunchURL", @@ -173,7 +170,6 @@ const NOT_YET: &[(&str, &[&str])] = &[ "SetAddonVersionCheck", "SetMovieSubtitles", "SetScriptMemory", - "ShowCursor", "SurveyNotificationDone", ], ), diff --git a/crates/benilla-ui/src/script/tests/keyboard.rs b/crates/benilla-ui/src/script/tests/keyboard.rs index e1d2571a..3708d009 100644 --- a/crates/benilla-ui/src/script/tests/keyboard.rs +++ b/crates/benilla-ui/src/script/tests/keyboard.rs @@ -248,3 +248,534 @@ fn an_unfocused_editbox_does_not_swallow_a_focused_boxs_editing_key() { ); assert!(s.errors().is_empty(), "{:?}", s.errors()); } + +/// The EditBox ctor enables char and key input (`0x779ced`, `0x779cf8`); `IsKeyboardEnabled` +/// (`0x776f90`) answers 1 for it and nil for a plain frame, and `EnableKeyboard(false)` takes it +/// back out of the walk (`0x76af50`), where the box goes deaf. +#[test] +fn an_editbox_is_born_keyboard_enabled() { + let mut s = script(); + s.run( + r#" + box = CreateFrame("EditBox", "KbBorn") + box:SetAutoFocus(false) + box:SetFocus() + plain = CreateFrame("Frame", "KbPlain") + "#, + ) + .unwrap(); + assert!(s + .eval::("return box:IsKeyboardEnabled() == 1") + .unwrap()); + assert!(s + .eval::("return plain:IsKeyboardEnabled() == nil") + .unwrap()); + assert!(s.char_input("a")); + s.run("box:EnableKeyboard(false)").unwrap(); + assert!( + !s.char_input("b"), + "out of the bucket, the box takes nothing" + ); + assert_eq!(s.eval::("return box:GetText()").unwrap(), "a"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A focused box is a frame in the walk at its own place (`0x765df0`, `0x765f10`): a keyboard +/// frame in a lower stratum never sees the key, one in a higher stratum takes it first. +#[test] +fn a_focused_box_takes_keys_at_its_own_stratum() { + let mut s = script(); + s.run( + r#" + seen = {} + box = CreateFrame("EditBox", "KbPlaced") + box:SetAutoFocus(false) + box:SetFrameStrata("DIALOG") + box:SetFocus() + function mk(name, strata) + local f = CreateFrame("Frame", name) + f:SetFrameStrata(strata) + f:EnableKeyboard(true) + f:SetScript("OnChar", function() table.insert(seen, name) end) + f:SetScript("OnKeyDown", function() table.insert(seen, name .. ":" .. arg1) end) + return f + end + low = mk("KbLower", "MEDIUM") + "#, + ) + .unwrap(); + assert!(s.char_input("x")); + assert!(s.key_input("ENTER")); + assert_eq!(s.eval::("return table.getn(seen)").unwrap(), 0); + assert_eq!(s.eval::("return box:GetText()").unwrap(), "x"); + + s.run(r#"mk("KbUpper", "FULLSCREEN_DIALOG")"#).unwrap(); + assert!(s.char_input("y")); + assert!(s.key_input("ENTER")); + assert_eq!( + s.eval::("return table.concat(seen, ',')").unwrap(), + "KbUpper,KbUpper:ENTER", + "a higher stratum pre-empts the box" + ); + assert_eq!(s.eval::("return box:GetText()").unwrap(), "x"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// With no box focused, the first `autoFocus` box in walk order takes the focus and the key at +/// its own position (`0x77a917`-`0x77a92b`): a keyboard frame below it never sees the key, one +/// above it does, and at one stratum and level the older registration wins. +#[test] +fn an_autofocus_box_takes_its_key_at_its_own_walk_position() { + let mut s = script(); + s.run( + r#" + seen = {} + function mkframe(name, strata) + local f = CreateFrame("Frame", name) + f:SetFrameStrata(strata) + f:EnableKeyboard(true) + f:SetScript("OnKeyDown", function() table.insert(seen, name) end) + return f + end + function mkbox(name) + local b = CreateFrame("EditBox", name) + b:SetAutoFocus(true) + b:Hide() + b:SetFrameStrata("DIALOG") + return b + end + a = mkbox("KbAutoA") + b = mkbox("KbAutoB") + mkframe("KbBelowBox", "MEDIUM") + a:Show() b:Show() + a:ClearFocus() b:ClearFocus() + "#, + ) + .unwrap(); + assert_eq!(s.focused_editbox_name(), None); + assert!(s.key_input("ENTER")); + assert_eq!( + s.focused_editbox_name().as_deref(), + Some("KbAutoA"), + "the older of two boxes at one position takes the focus" + ); + assert_eq!( + s.eval::("return table.getn(seen)").unwrap(), + 0, + "the frame below saw nothing" + ); + + s.run("KbAutoA:ClearFocus() mkframe('KbAboveBox', 'TOOLTIP')") + .unwrap(); + assert!(s.key_input("ENTER")); + assert_eq!( + s.focused_editbox_name(), + None, + "the frame above took it first" + ); + assert_eq!( + s.eval::("return table.concat(seen, ',')").unwrap(), + "KbAboveBox" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// The key-up (`0x765fd0`) goes to the frame that consumed the key's down, as `OnKeyUp` with the +/// key's name (`0x76bba0`); a key nobody consumed has no target, and the target is not cleared by +/// a delivered up (`0x76605b` sits on the no-target path). +#[test] +fn a_key_up_goes_to_the_frame_that_took_the_down() { + let mut s = script(); + s.run( + r#" + log = {} + f = CreateFrame("Frame", "KbUpA") + f:EnableKeyboard(true) + f:SetScript("OnKeyDown", function() table.insert(log, "down:" .. arg1) end) + f:SetScript("OnKeyUp", function() table.insert(log, "up:" .. arg1) end) + g = CreateFrame("Frame", "KbUpB") + g:EnableKeyboard(true) + g:SetFrameStrata("LOW") + g:SetScript("OnKeyUp", function() table.insert(log, "g:" .. arg1) end) + "#, + ) + .unwrap(); + assert!( + !s.key_up("ENTER").taken(), + "nothing consumed the down: no target" + ); + assert!(s.key_input("ENTER")); + assert!(s.key_up("ENTER").taken()); + assert!( + s.key_up("ENTER").taken(), + "the target outlives a delivered up" + ); + assert!(!s.key_up("TAB").taken()); + assert_eq!( + s.eval::("return table.concat(log, ',')").unwrap(), + "down:ENTER,up:ENTER,up:ENTER" + ); + + // The up follows the down's consumer, not whoever would take the key now. + s.run("f:Hide()").unwrap(); + assert!( + s.key_up("ENTER").taken(), + "a hidden consumer still gets its up" + ); + assert_eq!( + s.eval::("return log[table.getn(log)]").unwrap(), + "up:ENTER" + ); + assert!( + s.key_input("ENTER"), + "the lower frame's OnKeyUp-only slot takes the next down" + ); + assert!(s.key_up("ENTER").taken()); + assert_eq!( + s.eval::("return log[table.getn(log)]").unwrap(), + "g:ENTER" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A box in alt-arrow mode declines the four arrows without ALT, and every box declines +/// PRINTSCREEN, before it looks at focus (`0x77b17f`, `0x77b18e`-`0x77b1c4`): the walk goes on to +/// the frames below it, and a declined key gives an `autoFocus` box no focus. +#[test] +fn a_box_declines_printscreen_and_unaltered_arrows_and_the_walk_goes_on() { + use crate::script::KeyWalk; + let mut s = script(); + s.run( + r#" + seen = {} + box = CreateFrame("EditBox", "KbChatLike") + box:SetAutoFocus(false) + box:SetAltArrowKeyMode(1) + box:SetFrameStrata("DIALOG") + box:SetFocus() + spin = CreateFrame("Frame", "KbSpinner") + spin:SetFrameStrata("MEDIUM") + spin:EnableKeyboard(true) + spin:SetScript("OnKeyDown", function() table.insert(seen, arg1) end) + "#, + ) + .unwrap(); + for key in ["LEFT", "UP", "RIGHT", "DOWN", "PRINTSCREEN"] { + assert_eq!( + s.key_down(key), + KeyWalk::Frame, + "{key} goes on to the frame below" + ); + } + assert_eq!( + s.eval::("return table.concat(seen, ',')").unwrap(), + "LEFT,UP,RIGHT,DOWN,PRINTSCREEN" + ); + // Any other key stops at the focused box, as before. + assert_eq!(s.key_down("HOME"), KeyWalk::Box); + assert_eq!(s.key_down("A"), KeyWalk::Box); + // ALT up is the whole condition: with it down the box takes the arrows. + s.set_alt_down(true); + assert_eq!(s.key_down("RIGHT"), KeyWalk::Box); + assert_eq!( + s.key_down("PRINTSCREEN"), + KeyWalk::Frame, + "PRINTSCREEN is declined whatever ALT does" + ); + s.set_alt_down(false); + + // Nothing below: the world frame's binding is next, and the unfocused autoFocus box gets + // no focus from a key it declines. + s.run( + r#" + spin:Hide() + box:ClearFocus() + box:Hide() + auto = CreateFrame("EditBox", "KbAutoChat") + auto:SetAutoFocus(true) + auto:SetAltArrowKeyMode(1) + auto:ClearFocus() + "#, + ) + .unwrap(); + assert_eq!(s.focused_editbox_name(), None); + assert_eq!(s.key_down("LEFT"), KeyWalk::Declined); + assert_eq!(s.key_down("PRINTSCREEN"), KeyWalk::Declined); + assert_eq!( + s.focused_editbox_name(), + None, + "a declined key grants no focus" + ); + assert_eq!(s.key_down("X"), KeyWalk::Box, "any other key does"); + assert_eq!(s.focused_editbox_name().as_deref(), Some("KbAutoChat")); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// The base key-up (`0x76bba0`) and a box's (`0x77b7c0`) return before anything when the frame is +/// not linked, so a consumer that has since hidden gets no `OnKeyUp`; the record outlives it. +#[test] +fn a_hidden_consumer_gets_no_key_up() { + let mut s = script(); + s.run( + r#" + ups = 0 + f = CreateFrame("Frame", "KbUpHidden") + f:EnableKeyboard(true) + f:SetScript("OnKeyDown", function() end) + f:SetScript("OnKeyUp", function() ups = ups + 1 end) + "#, + ) + .unwrap(); + assert!(s.key_input("K")); + s.run("f:Hide()").unwrap(); + assert!( + s.key_up("K").taken(), + "the record stands, the up ends there" + ); + assert_eq!(s.eval::("return ups").unwrap(), 0); + s.run("f:Show()").unwrap(); + assert!(s.key_up("K").taken()); + assert_eq!( + s.eval::("return ups").unwrap(), + 1, + "shown again, it gets it" + ); +} + +/// A press a binding took is the world frame's (`0x483b70` returns the binding's result), so the +/// up and repeats go to it, and an earlier consumer of the same key is not reached. +#[test] +fn a_press_a_binding_took_moves_the_up_to_the_world_frame() { + let mut s = script(); + s.run( + r#" + log = {} + CreateFrame("WorldFrame", "WorldFrame") + f = CreateFrame("Frame", "KbUpStale") + f:EnableKeyboard(true) + f:SetScript("OnKeyDown", function() table.insert(log, "down") end) + f:SetScript("OnKeyUp", function() table.insert(log, "up") end) + "#, + ) + .unwrap(); + assert!(s.key_input("J")); + s.run("f:Hide()").unwrap(); + // The frame is gone from the walk, so the press is the binding's. + assert!(!s.key_input("J")); + s.note_world_key("J"); + s.run("f:Show()").unwrap(); + assert!(s.key_up("J").taken()); + assert_eq!( + s.eval::("return table.concat(log, ',')").unwrap(), + "down", + "the stale frame's OnKeyUp is not run" + ); + assert_eq!( + s.key_repeat("J"), + crate::script::KeyWalk::Frame, + "a stub: nothing runs" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// The auto-repeat (`0x766070`) goes to the frame that took the press, whose `+0x58` is a stub on +/// a plain frame (`0x46ff90`) and the key-down again on a box (`0x77b770`). +#[test] +fn a_repeat_goes_to_the_consumer_not_the_walk() { + use crate::script::KeyWalk; + let mut s = script(); + s.run( + r#" + downs = 0 + f = CreateFrame("Frame", "KbRepeat") + f:SetFrameStrata("MEDIUM") + f:EnableKeyboard(true) + f:SetScript("OnKeyDown", function() downs = downs + 1 end) + box = CreateFrame("EditBox", "KbRepeatBox") + box:SetAutoFocus(false) + box:SetFrameStrata("LOW") + box:SetFocus() + enters = 0 + box:SetScript("OnEnterPressed", function() enters = enters + 1 end) + "#, + ) + .unwrap(); + assert_eq!( + s.key_repeat("RIGHT"), + KeyWalk::Declined, + "no press on record, nothing" + ); + assert_eq!(s.key_down("RIGHT"), KeyWalk::Frame); + for _ in 0..3 { + assert_eq!(s.key_repeat("RIGHT"), KeyWalk::Frame); + } + assert_eq!( + s.eval::("return downs").unwrap(), + 1, + "holding the key fires OnKeyDown once, not once per repeat" + ); + + // A box that took the press gets its key-down again. + s.run("f:Hide()").unwrap(); + assert_eq!(s.key_down("ENTER"), KeyWalk::Box); + assert_eq!(s.key_repeat("ENTER"), KeyWalk::Box); + assert_eq!(s.eval::("return enters").unwrap(), 2); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// Equal-level frames walk in the order they joined the bucket (`0x764aa0` inserts after the +/// equals): a live `EnableKeyboard` inserts then, and a level change inserts again (`0x76a4f0` +/// takes the frame out and back in). +#[test] +fn equal_levels_walk_in_bucket_join_order() { + let mut s = script(); + s.run( + r#" + winner = nil + function mk(name) + local f = CreateFrame("Frame", name) + f:SetFrameStrata("HIGH") + f:SetScript("OnKeyDown", function() winner = name end) + return f + end + a = mk("KbJoinA") + b = mk("KbJoinB") + -- created A then B, but B joined first + b:EnableKeyboard(true) + a:EnableKeyboard(true) + "#, + ) + .unwrap(); + assert!(s.key_input("Z")); + assert_eq!(s.eval::("return winner").unwrap(), "KbJoinB"); + + // A level change re-inserts the frame after its equals. + s.run( + r#" + local l = b:GetFrameLevel() + b:SetFrameLevel(l + 1) + b:SetFrameLevel(l) + "#, + ) + .unwrap(); + assert!(s.key_input("Z")); + assert_eq!(s.eval::("return winner").unwrap(), "KbJoinA"); + + // Already enabled: enabling again joins nothing. + s.run("b:EnableKeyboard(true) a:EnableKeyboard(true)") + .unwrap(); + assert!(s.key_input("Z")); + assert_eq!(s.eval::("return winner").unwrap(), "KbJoinA"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// The XML walker enables one kind per handler (`0x769ef0`): `OnChar` the char bucket +/// (`0x769f6b`), `OnKeyDown` and `OnKeyUp` the key bucket (`0x769fa4`). A frame with only +/// `OnKeyDown` is in no char walk, and the reverse. +#[test] +fn an_xml_handler_enables_only_its_own_kind() { + let mut s = script(); + let doc = crate::framexml::parse( + r#" + keys = (keys or 0) + 1 + chars = (chars or 0) + 1 + "#, + ) + .expect("valid FrameXML"); + let report = crate::loader::load(&s, &doc, &|_| None); + assert!(report.errors.is_empty(), "{:?}", report.errors); + assert!(s.key_input("Q")); + assert!(s.char_input("q")); + assert_eq!(s.eval::("return keys").unwrap(), 1); + assert_eq!(s.eval::("return chars").unwrap(), 1); + // A handler Lua adds later enables nothing (`0x7748d0`), so it shows which buckets each + // frame is in: the keys-only frame is in no char walk, the chars-only one in no key walk. + s.run( + r#" + XmlKeysOnly:SetScript("OnKeyDown", nil) + XmlKeysOnly:SetScript("OnChar", function() chars = chars + 10 end) + XmlCharsOnly:SetScript("OnChar", nil) + XmlCharsOnly:SetScript("OnKeyDown", function() keys = keys + 10 end) + "#, + ) + .unwrap(); + assert!( + !s.char_input("q"), + "the keys-only frame is not in the char walk" + ); + assert!( + !s.key_input("Q"), + "the chars-only frame is not in the key walk" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A consumer on record makes the repeat consumed whatever its `+0x58` does (`0x7660bb`), and a +/// box's (`0x77b770`) grants an `autoFocus` box the free focus (`0x77b799`) before its key-down +/// can decline. +#[test] +fn a_repeat_grants_autofocus_before_the_box_may_decline() { + use crate::script::KeyWalk; + let mut s = script(); + s.run( + r#" + box = CreateFrame("EditBox", "KbRepeatAuto") + box:SetAutoFocus(true) + box:Hide() box:Show() + box:ClearFocus() + "#, + ) + .unwrap(); + assert_eq!( + s.key_down("LEFT"), + KeyWalk::Box, + "the box takes LEFT, a plain box" + ); + s.run("box:ClearFocus() box:SetAltArrowKeyMode(1)").unwrap(); + assert_eq!(s.focused_editbox_name(), None); + assert_eq!( + s.key_repeat("LEFT"), + KeyWalk::Frame, + "consumed, though the box declines the arrow now" + ); + assert_eq!( + s.focused_editbox_name().as_deref(), + Some("KbRepeatAuto"), + "and the repeat gave it the focus" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// PRINTSCREEN's up with no consumer on record runs the key-down walk (`0x76603c`-`0x76604d`) and +/// leaves no record (`0x76605b`); any other key's up with none is nothing. +#[test] +fn a_printscreen_up_with_no_consumer_walks_as_a_key_down() { + use crate::script::{KeyUp, KeyWalk}; + let mut s = script(); + assert_eq!(s.key_up("PRINTSCREEN"), KeyUp::Walked(KeyWalk::Declined)); + assert_eq!(s.key_up("G"), KeyUp::Unclaimed); + s.run( + r#" + got = {} + f = CreateFrame("Frame", "KbPrint") + f:EnableKeyboard(true) + f:SetScript("OnKeyDown", function() table.insert(got, arg1) end) + f:SetScript("OnKeyUp", function() table.insert(got, "up") end) + "#, + ) + .unwrap(); + assert_eq!(s.key_up("PRINTSCREEN"), KeyUp::Walked(KeyWalk::Frame)); + assert_eq!( + s.key_up("PRINTSCREEN"), + KeyUp::Walked(KeyWalk::Frame), + "the record the walk wrote is cleared, so the next up walks again" + ); + assert_eq!( + s.eval::("return table.concat(got, ',')").unwrap(), + "PRINTSCREEN,PRINTSCREEN" + ); + // With a recorded down, the up is an ordinary delivery. + assert!(s.key_input("PRINTSCREEN")); + assert_eq!(s.key_up("PRINTSCREEN"), KeyUp::Delivered); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index 6e18c3f6..309668ac 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -185,10 +185,21 @@ pub struct Frame { /// `EnableMouseWheel`, or an `` script at XML load: the mask's wheel bit (3). /// A frame with the script but not the bit is transparent to the wheel. pub mouse_wheel_enabled: bool, - /// `EnableKeyboard` (`0x776ec0`), XML `enableKeyboard` or a key script at XML load: the - /// mask's char and key bits (`0x76af00`), which key delivery walks - /// ([`crate::script::keyboard`]); a script-less frame there declines. - pub keyboard_enabled: bool, + /// The input mask's char bit (0): membership in the char walk ([`crate::script::keyboard`]), + /// `0x76af00(0)`. `EnableKeyboard` (`0x776ec0`), XML `enableKeyboard` or a key script at XML + /// load set it with [`Self::key_enabled`]; the EditBox ctor sets it (`0x779ced`); a script-less + /// frame in the walk declines. + pub char_enabled: bool, + /// The mask's key bit (1): membership in the key-down walk, `0x76af00(1)`. The EditBox ctor + /// (`0x779cf8`) and the WorldFrame's (`0x481b09`, which has no char bit) set it. + pub key_enabled: bool, + /// When the frame last joined the char bucket (`0x764aa0`, kind 0): at its link (creation + /// visible, show, a strata or level change) or, on a linked frame, at the bit's own enable + /// (`0x76af00`). Equal-level frames walk in this order, oldest first. A [`WidgetArena`] + /// counter, apart from [`Self::insertion_seq`], whose draw order an enable does not touch. + pub char_stamp: u64, + /// The same for the key bucket (kind 1). + pub key_stamp: u64, /// `SetClampedToScreen`, geometry flags `0x10`: `0x767a20` shifts the rect back on screen. /// Every GameTooltip is born with it here; the stock tooltips get it from their template, /// `GameTooltipTemplate.xml:3`. @@ -282,6 +293,8 @@ pub struct WidgetArena { regions: Arena, names: HashMap, next_insertion: u32, + /// The next [`Frame::char_stamp`] / [`Frame::key_stamp`]. + next_kb_stamp: u64, /// Each stratum's level bound, the reference's `bucket+0x8`: one above the highest level a /// visible frame has linked at since the last compaction set it. It starts at 0 and only a /// visible link grows it ([`Self::link_bound`]); nothing else lowers it but the compaction. @@ -348,6 +361,7 @@ impl WidgetArena { regions: Arena::new(), names: HashMap::new(), next_insertion: 0, + next_kb_stamp: 0, level_bound: [0; Strata::ALL.len()], next_created: 0, renumbers: 0, @@ -441,9 +455,13 @@ impl WidgetArena { self.renumbers += 1; } let seq = self.next_insertion; + let stamp = self.next_kb_stamp; + self.next_kb_stamp += 1; let mut linked = None; if let Some(f) = self.frame_mut(h) { f.insertion_seq = seq; + f.char_stamp = stamp; + f.key_stamp = stamp; linked = Some((f.strata, f.level)); self.next_insertion += 1; if let Some(log) = &mut self.link_log { @@ -508,6 +526,8 @@ impl WidgetArena { let insertion_seq = self.next_insertion; self.next_insertion += 1; + let kb_stamp = self.next_kb_stamp; + self.next_kb_stamp += 1; let created = self.next_created; self.next_created += 1; @@ -546,9 +566,12 @@ impl WidgetArena { kind, FrameKind::ScrollingMessageFrame | FrameKind::ScrollFrame | FrameKind::WorldFrame ), - // No kind is keyboard-enabled at creation here; the reference's EditBox - // (`0x779ced`/`0x779cf8`) and WorldFrame (`0x481b09`) ctors enable key input. - keyboard_enabled: false, + // The EditBox ctor enables char and key input (`0x779ced`, `0x779cf8`), the + // WorldFrame's key input alone (`0x481b09`); every other kind is born inert. + char_enabled: kind == FrameKind::EditBox, + key_enabled: matches!(kind, FrameKind::EditBox | FrameKind::WorldFrame), + char_stamp: kb_stamp, + key_stamp: kb_stamp, clamped_to_screen: matches!(kind, FrameKind::GameTooltip), hit_rect_insets: [0.0; 4], movable: false, diff --git a/crates/benilla-ui/src/widget/propagation.rs b/crates/benilla-ui/src/widget/propagation.rs index a0de33d8..9a2d5188 100644 --- a/crates/benilla-ui/src/widget/propagation.rs +++ b/crates/benilla-ui/src/widget/propagation.rs @@ -403,16 +403,47 @@ impl WidgetArena { } } - /// `EnableKeyboard` (`0x776ec0`): the char and key bits the key walk filters on. + /// `EnableKeyboard` (`0x776ec0`): the char and key bits the key walks filter on, both. pub fn set_keyboard_enabled(&mut self, h: FrameHandle, enabled: bool) { - if let Some(f) = self.frame_mut(h) { - f.keyboard_enabled = enabled; - } + self.set_char_enabled(h, enabled); + self.set_key_enabled(h, enabled); } - /// `IsKeyboardEnabled` (`0x776f90`). + /// The input mask's char bit alone (`0x76af00(0)`, `0x76af50(0)`), which the XML walker sets + /// for `OnChar` (`0x769f6b`). A bit newly set on a linked frame inserts it into the bucket + /// now, so it walks after the frames already there. + pub fn set_char_enabled(&mut self, h: FrameHandle, enabled: bool) { + let stamp = self.next_kb_stamp; + let mut joined = false; + if let Some(f) = self.frame_mut(h) { + joined = enabled && !f.char_enabled && f.effective_visible; + f.char_enabled = enabled; + if joined { + f.char_stamp = stamp; + } + } + self.next_kb_stamp += u64::from(joined); + } + + /// The mask's key bit alone (`0x76af00(1)`), which the XML walker sets for `OnKeyDown` and + /// `OnKeyUp` (`0x769fa4`). + pub fn set_key_enabled(&mut self, h: FrameHandle, enabled: bool) { + let stamp = self.next_kb_stamp; + let mut joined = false; + if let Some(f) = self.frame_mut(h) { + joined = enabled && !f.key_enabled && f.effective_visible; + f.key_enabled = enabled; + if joined { + f.key_stamp = stamp; + } + } + self.next_kb_stamp += u64::from(joined); + } + + /// `IsKeyboardEnabled` (`0x776f90`): either bit. pub fn is_keyboard_enabled(&self, h: FrameHandle) -> bool { - self.frame(h).is_some_and(|f| f.keyboard_enabled) + self.frame(h) + .is_some_and(|f| f.char_enabled || f.key_enabled) } /// `EnableMouseWheel`, the wheel's own bit, separate from the mouse's.