Key input follows the client's walks; stock login takes typing (#1027)

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 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 18:07:34 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7547081a58
commit 781bf36ab3
28 changed files with 2334 additions and 312 deletions
@@ -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);
+302 -14
View File
@@ -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<KeyCode>,
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<KeyCode>);
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<KeyCode>) {
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<KeyCode>) -> 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<MouseButton>,
) -> 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::<UiKeyboardCapture>().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::<UiScript>();
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::<UiScript>();
s.run("stale:Show()").unwrap();
assert!(s.key_up("G").taken());
assert_eq!(s.eval::<i64>("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<UiScript>,
mut capture: ResMut<UiKeyboardCapture>,
mut events: MessageReader<KeyboardInput>,
keys: Res<ButtonInput<KeyCode>>,
layout: Res<LayoutNames>,
mut clipboard: NonSendMut<crate::textinput::HostClipboard>,
mut down: Local<KeyDownList>,
) {
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::<ButtonInput<KeyCode>>()
.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::<UiKeyboardCapture>()
.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::<UiScript>();
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::<UiScript>();
assert_eq!(s.eval::<i64>("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::<UiScript>()
.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::<UiScript>()
.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#"<Bindings>
<Binding name="PROBEGO">MoveForwardStart()</Binding>
+26
View File
@@ -113,6 +113,32 @@ pub(crate) fn key_token(k: KeyCode, layout: &LayoutNames) -> Option<KeyName> {
}
}
/// 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<String> {
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<KeyName> {
+2
View File
@@ -409,6 +409,8 @@ fn launch(build: BuildId, extend: Option<Extension<'_>>) -> 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)
+96
View File
@@ -45,6 +45,50 @@ pub(crate) fn on_app_exit(app: &mut App, systems: ScheduleConfigs<ScheduleSystem
app.add_systems(Last, systems.in_set(OnAppExit))
}
/// Alt+F4 closes the primary window on Windows and Linux.
///
/// Deviation: the reference leaves it to the OS. Its key arm (`0x42d261`-`0x42d276`) hands Alt+F4
/// to `DefWindowProc`, whose `SC_CLOSE` becomes the kind-5 event (`0x42d287`, as `WM_CLOSE` posts at
/// `0x42d292`) and runs the quit at once (`0x423a21` -> `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<bevy::input::keyboard::KeyboardInput>,
keys: Res<ButtonInput<KeyCode>>,
windows: Query<Entity, With<bevy::window::PrimaryWindow>>,
mut close: MessageWriter<bevy::window::WindowCloseRequested>,
) {
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::<Saw>().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::<WindowCloseRequested>()
.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<Entity> = app
.world_mut()
.resource_mut::<Messages<WindowCloseRequested>>()
.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");
}
}
@@ -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<Option<String>, 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,
}
}
}
@@ -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,
&[
+1 -25
View File
@@ -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, `<frame>: SetScript("<handler>") 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),
@@ -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::<String>(&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::<bool>("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::<bool>("return MovieFrame:IsShown() == 1").unwrap(),
s.eval::<bool>("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::<String>::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::<bool>("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::<bool>("return MovieFrame:IsShown() == 1").unwrap(),
"…and nothing happens until the key is up"
);
assert!(s.key_input("ESCAPE"));
assert!(s.eval::<bool>("return MovieFrame:IsShown() == 1").unwrap());
s.key_up("ESCAPE");
s.resolve();
assert!(!s.eval::<bool>("return MovieFrame:IsShown() == 1").unwrap());
assert!(s
.eval::<bool>("return AccountLoginUI:IsVisible() == 1")
.unwrap());
assert!(!s.glue_cursor_hidden());
assert_eq!(s.take_errors(), Vec::<String>::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::<bool>("return BenillaRealmlist_Middle:IsShown() == 1")
.unwrap());
s.run("GlueDialogButton2:Click()").unwrap();
s.resolve();
assert!(!s
.eval::<bool>("return BenillaRealmlist_Middle:IsShown() == 1")
.unwrap());
assert_eq!(
s.eval::<f64>("return GlueDialogEditBox:GetWidth()")
.unwrap(),
130.0
);
assert_eq!(s.take_errors(), Vec::<String>::new());
}
@@ -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)]
+129 -88
View File
@@ -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<UiKeyboardCapture>,
NonSendMut<HostClipboard>,
),
// The keys this feed believes down, a fact about the keyboard and not the VM.
mut pressed: Local<crate::bindings::KeyDownList>,
// Who sends the key-ups: the bindings pass in the world, this feed on the glue screens.
state: Option<Res<State<crate::char_select::ClientState>>>,
// The characters the active layout makes, which name a key for a keyboard frame.
layout: Res<crate::bindings::LayoutNames>,
) {
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<Item = &'a KeyboardInput>,
keys: &ButtonInput<KeyCode>,
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)]
@@ -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<KeyCode>,
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::<i64>("return StackSplitFrame.split").unwrap(), 1);
let mut feed = Feed::new("");
feed.send(&mut s, &[down(KeyCode::ArrowRight, None)]);
assert_eq!(
s.eval::<i64>("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::<i64>("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::<i64>("return StackSplitFrame.split").unwrap(), 3);
assert_eq!(
s.eval::<String>("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::<i64>("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::<String>("return box:GetText()").unwrap(), "");
assert_eq!(
s.eval::<String>("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::<String>("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::<String>("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"));
}
+54 -1
View File
@@ -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::<crate::cvars::Cvars>() 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::<super::glue_mgr::GlueMgr>() {
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<E: std::fmt::Display>(
@@ -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::<super::super::glue_mgr::GlueMgr>();
setup_script(&mut world);
world
.non_send_resource::<UiScript>()
.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.
+13 -1
View File
@@ -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::<UiClock>()
.init_resource::<AddOnIdentity>()
// 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;
@@ -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};
+4 -5
View File
@@ -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.
+41 -30
View File
@@ -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<FrameHandle> {
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(h) = model.focused_editbox {
match model.arena.frame(h) {
Some(f) if f.effective_visible && f.kind == FrameKind::EditBox => return Some(h),
Some(_) => return None, // alive but hidden: block self-acquire, don't consume
None => model.focused_editbox = None, // stale: drop and self-acquire below
}
let mut model = lua.app_data_mut::<Model>().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<FrameHandle> {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let sorted = order::traversal(&model.arena);
for (target, _) in sorted.iter().rev() {
if let ZTarget::Frame(fh) = *target {
if let Some(KindState::EditBox(eb)) = model.arena.frame(fh).map(|f| &f.kind_state) {
if eb.auto_focus {
return Some(fh);
}
/// 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::<Model>().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::<Model>()
.expect("model app_data")
.focused_editbox
== Some(h)
}
/// `SetFocus` (`0x77e3d0`): needs effective visibility, a no-op when already focused; fires
+84 -1
View File
@@ -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::<Model>().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::<Model>().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::<Model>()
.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::<bool>("return GetMovieSubtitles() == nil").unwrap());
s.set_cvar_host("movieSubtitle", "1");
assert_eq!(
s.eval::<Option<i64>>("return GetMovieSubtitles()").unwrap(),
Some(1)
);
s.set_cvar_host("movieSubtitle", "0");
assert!(s.eval::<bool>("return GetMovieSubtitles() == nil").unwrap());
assert_eq!(
s.eval::<f64>("return GetMovieResolution()").unwrap(),
1024.0
);
s.set_cvar_host("gxResolution", "800x600");
assert_eq!(s.eval::<f64>("return GetMovieResolution()").unwrap(), 800.0);
assert!(s
.eval::<bool>("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());
}
}
+272 -106
View File
@@ -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 `<Scripts>` 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 `<Scripts>` 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<FrameHandle> {
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<FrameHandle> {
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::<Model>().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::<Model>().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::<Model>().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::<Model>().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::<Model>().expect("model app_data");
model.focused_editbox == Some(h)
};
if is_focused_box {
let consumed = match channel {
Channel::Char => super::editbox::char_input(lua, arg),
Channel::KeyDown => super::editbox::key_input(lua, arg),
};
if consumed {
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::<Model>().expect("model app_data");
model.frame_id(h)
};
match channel {
// `0x76b760`: gates on the OnChar slot alone.
Channel::Char => {
if has_script(lua, id, "OnChar") {
fire(lua, id, "OnChar", arg);
return true;
}
}
// `0x76b7d0`: 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::<Model>().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::<Model>().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::<Model>().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::<Model>()
.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::<Model>().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::<Model>().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::<Model>()
.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::<Model>()
.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::<Model>().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::<Model>()
.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::<Model>().expect("model app_data");
walk_order(&model)
};
for h in order {
{
let model = lua.app_data_ref::<Model>().expect("model app_data");
if model.focused_editbox == Some(h) {
return false; // the box'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::<Model>().expect("model app_data");
model.frame_id(h)
};
let down = has_script(lua, id, "OnKeyDown");
if down || has_script(lua, id, "OnKeyUp") {
if down {
fire(lua, id, "OnKeyDown", key);
}
return true;
}
}
false
pub(super) fn key_down(lua: &Lua, key: &str) -> KeyWalk {
walk_key(lua, key)
}
+56 -18
View File
@@ -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<Lua> {
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.
+8
View File
@@ -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<FrameHandle>,
/// 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<String, FrameHandle>,
/// The mouse-enabled frame under the cursor, for the next move's `OnLeave`/`OnEnter`.
pub(crate) mouseover: Option<FrameHandle>,
@@ -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,
+126
View File
@@ -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::<Model>().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::<bool>(r#"return M:StartMovie("a\\b") == nil"#)
.unwrap());
assert!(s.eval::<bool>("return M:StartMovie(7) == nil").unwrap());
assert!(s
.eval::<bool>("return table.getn({M:StopMovie()}) == 0")
.unwrap());
assert!(s
.eval::<bool>("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::<bool>(&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::<bool>("return P.StartMovie == nil").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
}
+1
View File
@@ -507,6 +507,7 @@ fn kind_method_registries(kind: Option<FrameKind>) -> &'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) => &[
@@ -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<Function>) -> mlua::Result<()> {
let kind = SCRIPT_KINDS
.iter()
@@ -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",
],
),
@@ -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::<bool>("return box:IsKeyboardEnabled() == 1")
.unwrap());
assert!(s
.eval::<bool>("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::<String>("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::<i64>("return table.getn(seen)").unwrap(), 0);
assert_eq!(s.eval::<String>("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::<String>("return table.concat(seen, ',')").unwrap(),
"KbUpper,KbUpper:ENTER",
"a higher stratum pre-empts the box"
);
assert_eq!(s.eval::<String>("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::<i64>("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::<String>("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::<String>("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::<String>("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::<String>("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::<String>("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::<i64>("return ups").unwrap(), 0);
s.run("f:Show()").unwrap();
assert!(s.key_up("K").taken());
assert_eq!(
s.eval::<i64>("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::<String>("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::<i64>("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::<i64>("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::<String>("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::<String>("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::<String>("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#"<Ui>
<Frame name="XmlKeysOnly"><Scripts><OnKeyDown>keys = (keys or 0) + 1</OnKeyDown></Scripts></Frame>
<Frame name="XmlCharsOnly"><Scripts><OnChar>chars = (chars or 0) + 1</OnChar></Scripts></Frame>
</Ui>"#,
)
.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::<i64>("return keys").unwrap(), 1);
assert_eq!(s.eval::<i64>("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::<String>("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());
}
+30 -7
View File
@@ -185,10 +185,21 @@ pub struct Frame {
/// `EnableMouseWheel`, or an `<OnMouseWheel>` 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<Region>,
names: HashMap<String, FrameHandle>,
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,
+37 -6
View File
@@ -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.