From be9e3d8aac4398de791493ad263d1a31f472eb90 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:55:26 +0700 Subject: [PATCH] Keys are named by the active layout, as 1.12.1 names them (#748) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Letter and punctuation keys were named by their US position, so on AZERTY the key labelled Z bound as W, and Ctrl+A sat on the key labelled Q. 1.12.1 on Windows names a key from the VK its layout produced. `VK_A`–`VK_Z` and digits are named as themselves, and other keys by `MapVirtualKeyA` (`0x42da39`); a key with no character is dropped (`0x42da49`). The EditBox's Ctrl chords switch on that same name (`0x77b1fb`). The Mac client names keys by `KeyTranslate`. Now: - Windows takes the same path through `MapVirtualKeyExW`. - The Mac names keys through the current layout, or the ASCII-capable layout it pairs with. - Linux follows one documented rule. - Bindings, Key Bindings capture and Ctrl/Cmd+A/C/X/V all follow the layout. - A character above U+00FF names its key, as a deviation. Tests cover AZERTY, Hebrew, Greek, Lithuanian, Turkish, Bulgarian, Hungarian and Dvorak names, layout switches and chords. Fixes #621 Fixes #746 πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + crates/benilla-app/Cargo.toml | 7 +- crates/benilla-app/src/bindings.rs | 174 ++++- crates/benilla-app/src/bindings/chord.rs | 464 +++++++----- crates/benilla-app/src/bindings/layout.rs | 668 ++++++++++++++++++ crates/benilla-app/src/char_create/mod.rs | 6 +- crates/benilla-app/src/char_select/dialog.rs | 4 +- crates/benilla-app/src/login/mod.rs | 5 +- crates/benilla-app/src/textinput/keymap.rs | 211 ++++-- crates/benilla-app/src/textinput/mod.rs | 7 +- .../src/ui_script/cinematic_tests.rs | 4 +- crates/benilla-app/src/ui_script/input.rs | 16 +- crates/benilla-app/tests/cover_input_wall.rs | 19 +- crates/benilla-world/src/layout_keys.rs | 10 +- 14 files changed, 1304 insertions(+), 292 deletions(-) create mode 100644 crates/benilla-app/src/bindings/layout.rs diff --git a/Cargo.lock b/Cargo.lock index fdb1df09..7afa1b73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -546,6 +546,7 @@ dependencies = [ "toml", "wgpu", "windows-sys 0.59.0", + "winit", ] [[package]] diff --git a/crates/benilla-app/Cargo.toml b/crates/benilla-app/Cargo.toml index 3d448e10..7dd1c49f 100644 --- a/crates/benilla-app/Cargo.toml +++ b/crates/benilla-app/Cargo.toml @@ -50,6 +50,9 @@ toml = "0.8" avian3d = "0.6" # Shapes and rasterizes `FontString` text standalone (`ui_text`), at bevy_text's own 0.16. cosmic-text = "0.16" +# The layout's unshifted character per key (`KeyEventExtModifierSupplement`), which bevy's +# `KeyboardInput` drops; bevy_winit's own 0.30, whose features it sets. +winit = { version = "0.30", default-features = false } # `cosmic-text`'s font database, named by `ui_text`; must stay the version it depends on. fontdb = "0.23" # The mixer. Its `cpal` backend stays off on every target: `sound::output` is our own backend. @@ -94,12 +97,14 @@ objc2-foundation = { version = "0.2", features = ["NSArray", "NSData", "NSGeomet [target.'cfg(not(target_os = "macos"))'.dependencies] cpal = "0.17" -# Windows CPU clocks for `perf::clock`; `GetLocalTime` for the log stamp (`ui_chat::logging`). +# Windows CPU clocks for `perf::clock`; `GetLocalTime` for the log stamp (`ui_chat::logging`); the +# layout's virtual keys for `bindings::layout`. [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = [ "Win32_Foundation", "Win32_System_SystemInformation", "Win32_System_Threading", + "Win32_UI_Input_KeyboardAndMouse", ] } # The login cycle's fake realmd runs the server half of SRP6 (`net::io::cycle_tests`). diff --git a/crates/benilla-app/src/bindings.rs b/crates/benilla-app/src/bindings.rs index 2dc2963c..a3b035ba 100644 --- a/crates/benilla-app/src/bindings.rs +++ b/crates/benilla-app/src/bindings.rs @@ -7,7 +7,7 @@ //! Dispatch ([`latch_and_dispatch`], in [`crate::ui_script::UiInput`] after the UI key feed), as //! `CBindings::ExecuteBinding` (`0x4b7990`) and `RunCommand` (`0x4b7b50`) do: //! - a press probes its exact chord, then once more with its leftmost modifier dropped -//! ([`Chord::fallback`]); Super held matches nothing; +//! ([`Chord::fallback`]); //! - the resolved command's body runs with `keystate = "down"`, and the base key's release runs a //! `runOnUp` body again with `"up"`. UI focus suppresses presses and releases nothing already //! held; a latch ends only on its base key's release, the stuck-latch sweep (OS focus loss, the @@ -15,6 +15,11 @@ //! - what the bodies call reaches the engine through the Lua binding functions //! ([`benilla_ui::script::BindingInput`]) as [`BindingsState`]'s [`Input`]s. //! +//! Deviation: a press with Super (Cmd) held runs no binding, so a system chord such as Cmd+Tab or +//! Cmd+Q never acts in game. The reference has no such modifier and runs the bare key's binding: +//! the Mac enqueues the key with Command held and skips only its text (`0x8a519`), and Windows +//! hands `VK_LWIN`/`VK_RWIN` to the layout arm (`0x42da39`) like any key. +//! //! Persistence: `benilla-config/bindings/account.txt` and `-.txt` ([`store`]); the //! character file's existence is the character-set state, as in the reference. @@ -30,10 +35,12 @@ use crate::char_select::InWorldGated; use crate::ui_script::{PlayerUiHover, PointerOverUiPanel, UiKeyboardCapture}; pub(crate) mod chord; +mod layout; mod script_input; mod store; use chord::{BindKey, Chord}; +pub(crate) use layout::LayoutNames; pub(crate) use script_input::Input; /// The chord-to-command map, rebuilt whenever the engine table's generation moves. Probe it @@ -69,10 +76,10 @@ impl BindingDispatch { /// [`Input`]s the running bodies' Lua calls set. #[derive(Resource, Default)] pub(crate) struct BindingsState { - /// Live latches, (base key, command): a press not yet released, whose release runs the + /// Live latches, (base input, command): a press not yet released, whose release runs the /// command's `runOnUp` half. The reference replays the press-time chord at key-up (`0x483bd0`); /// latching the resolved command is equivalent. - latched: Vec<(BindKey, String)>, + latched: Vec<(Held, String)>, /// The held bits a Start set and no Stop has cleared. held: Vec, /// Inputs whose Start, or whose one-shot, came this frame (the press edge). @@ -89,6 +96,15 @@ pub(crate) struct BindingsState { down: Vec, } +/// The physical input a latch waits on: a keyboard key by its code, not its name, so its release +/// ends the latch whatever the layout names it by then. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Held { + Key(KeyCode), + Mouse(MouseButton), + Wheel, +} + impl BindingsState { /// Is a held bit set right now (the reference's `[InputControl+4]`)? pub(crate) fn pressed(&self, i: Input) -> bool { @@ -140,7 +156,8 @@ pub(crate) struct BindingsPlugin; impl Plugin for BindingsPlugin { fn build(&self, app: &mut App) { - app.init_resource::() + app.add_plugins(layout::plugin) + .init_resource::() .init_resource::() .init_resource::() .add_systems( @@ -308,6 +325,8 @@ impl WheelNotches { fn latch_and_dispatch( mut script: Option>, mut keyboard: MessageReader, + // What the active layout names a letter, digit or punctuation key. + layout: Res, keys: Res>, buttons: Res>, scroll: Res, @@ -357,8 +376,9 @@ fn latch_and_dispatch( let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight); let alt = keys.pressed(KeyCode::AltLeft) || keys.pressed(KeyCode::AltRight); let sup = keys.pressed(KeyCode::SuperLeft) || keys.pressed(KeyCode::SuperRight); - // The dev overlays' plane (`modkeys::dev_chord`; the `sup` gate covers its Super arm), which - // costs the keyboard its fallback probe (`BindingDispatch::resolve`). Only in a build with dev + // The dev overlays' plane, Ctrl+Shift without Alt (`modkeys::dev_chord`, which Super blocks + // too, as the `sup` gate below keeps any Super-held press from dispatching), which costs the + // keyboard its fallback probe (`BindingDispatch::resolve`). Only in a build with dev // affordances: a player build keeps the reference's `CTRL-SHIFT-P` fallback to `SHIFT-P`. let dev_plane = ctrl && shift && !alt && crate::run_mode::dev_affordances(); @@ -374,7 +394,7 @@ fn latch_and_dispatch( // reference's `0x424790` wipe, so a key held across an alt-tab re-latches on its next repeat. state .down - .retain(|&kc| physically_down(BindKey::Key(kc), &keys, &buttons)); + .retain(|&kc| physically_down(Held::Key(kc), &keys, &buttons)); // ── Keyboard ── press edges run their command, gated on ownership and the capture arm; // release edges unlatch and run the `runOnUp` up-half. @@ -402,25 +422,31 @@ fn latch_and_dispatch( if !repeat { state.down.push(key); } + // `sup`: the Super deviation (module doc). if (typing && !arrow_exempt) || eaten || sup || repeat { continue; } - if state.latched.iter().any(|(k, _)| *k == BindKey::Key(key)) { + if state.latched.iter().any(|(k, _)| *k == Held::Key(key)) { continue; // already latched (missed release would double-latch) } + // Named under the layout this press was typed in; a key 1.12 cannot name binds + // nothing. + let Some(name) = chord::key_token(ev.key_code, &layout) else { + continue; + }; let chord = Chord { alt, ctrl, shift, - key: BindKey::Key(key), + key: BindKey::Key(name), }; if let Some(command) = dispatch.resolve(chord, dev_plane) { - press(&mut state, &mut script, command, BindKey::Key(key)); + press(&mut state, &mut script, command, Held::Key(key)); } } ButtonState::Released => { state.down.retain(|&kc| kc != key); - release(&mut state, &mut script, BindKey::Key(key)); + release(&mut state, &mut script, Held::Key(key)); } } } @@ -437,7 +463,7 @@ fn latch_and_dispatch( if buttons.just_pressed(b) && !sup && hover.0.is_none() - && !state.latched.iter().any(|(k, _)| *k == BindKey::Mouse(b)) + && !state.latched.iter().any(|(k, _)| *k == Held::Mouse(b)) { let chord = Chord { alt, @@ -446,11 +472,11 @@ fn latch_and_dispatch( key: BindKey::Mouse(b), }; if let Some(command) = dispatch.resolve(chord, false) { - press(&mut state, &mut script, command, BindKey::Mouse(b)); + press(&mut state, &mut script, command, Held::Mouse(b)); } } if buttons.just_released(b) { - release(&mut state, &mut script, BindKey::Mouse(b)); + release(&mut state, &mut script, Held::Mouse(b)); } } @@ -476,8 +502,8 @@ fn latch_and_dispatch( }; if let Some(command) = dispatch.resolve(chord, false) { for _ in 0..steps.unsigned_abs() { - press(&mut state, &mut script, command, key); - release(&mut state, &mut script, key); + press(&mut state, &mut script, command, Held::Wheel); + release(&mut state, &mut script, Held::Wheel); } } } @@ -486,7 +512,7 @@ fn latch_and_dispatch( // unlatches now and runs its up-half. The reference's two bulk clears land here too, since // bevy zeroes `ButtonInput` for both (see the clears above). UI keyboard focus never reaches // here. - let mut stuck: Vec = Vec::new(); + let mut stuck: Vec = Vec::new(); for (k, _) in &state.latched { if !physically_down(*k, &keys, &buttons) && !stuck.contains(k) { stuck.push(*k); @@ -504,32 +530,32 @@ fn latch_and_dispatch( } } -/// Is this base key physically down, per bevy's button planes? The stuck-latch sweep and the +/// Is this input physically down, per bevy's button planes? The stuck-latch sweep and the /// pressed-key reconcile must agree, so both ask here. `ENTER` is down while either physical /// key is, since [`chord::normalize_key`] folds `NUMPADENTER` into it. fn physically_down( - key: BindKey, + key: Held, keys: &ButtonInput, buttons: &ButtonInput, ) -> bool { match key { - BindKey::Key(KeyCode::Enter) => { + Held::Key(KeyCode::Enter) => { keys.pressed(KeyCode::Enter) || keys.pressed(KeyCode::NumpadEnter) } - BindKey::Key(kc) => keys.pressed(kc), - BindKey::Mouse(b) => buttons.pressed(b), + Held::Key(kc) => keys.pressed(kc), + Held::Mouse(b) => buttons.pressed(b), // A notch is a press and a release in one frame; it is never "held". - BindKey::WheelUp | BindKey::WheelDown => false, + Held::Wheel => false, } } -/// One matching press: the command's body with `keystate = "down"`, and a latch on its base key -/// so the release can run the up-half. +/// One matching press: the command's body with `keystate = "down"`, and a latch on its input so +/// the release can run the up-half. fn press( state: &mut BindingsState, script: &mut Option>, command: &str, - key: BindKey, + key: Held, ) { if let Some(s) = script.as_mut() { if let Err(e) = s.execute_binding(command, true) { @@ -541,7 +567,7 @@ fn press( /// A base key's release: drop its latch and run the command's `runOnUp` up-half, even while /// typing (the reference completes a pressed binding's release regardless of focus). -fn release(state: &mut BindingsState, script: &mut Option>, key: BindKey) { +fn release(state: &mut BindingsState, script: &mut Option>, key: Held) { let mut i = 0; while i < state.latched.len() { if state.latched[i].0 == key { @@ -684,6 +710,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_systems(Update, (sync_dispatch, latch_and_dispatch).chain()); app.insert_non_send_resource(script); app @@ -1170,7 +1197,7 @@ mod tests { app.update(); assert_eq!(lua_count(&app, "STABS"), 1); assert_eq!(lua_count(&app, "TABS"), 1); - // Super is never a binding modifier: a Super-held press builds no chord, so no fallback. + // The Super deviation: a Super-held press runs no binding, not even its bare key's. let mut app = harness(); press_key(&mut app, KeyCode::SuperLeft); press_key(&mut app, KeyCode::KeyZ); @@ -1178,6 +1205,97 @@ mod tests { assert!(!state(&app).fired(Input::ToggleSheath)); } + /// What the layout names `k`, as `layout::record_layout_names` records it from the key's own + /// OS message, ahead of the press. + fn layout_key(app: &mut App, k: KeyCode, name: char) { + app.world_mut() + .resource_mut::() + .set(k, Some(layout::LayoutName::Char(name))); + } + + /// A press with the logical key the OS reports, modifiers applied. + fn type_key(app: &mut App, k: KeyCode, logical: &str) { + app.world_mut().write_message(KeyboardInput { + key_code: k, + logical_key: Key::Character(logical.into()), + state: bevy::input::ButtonState::Pressed, + text: Some(logical.into()), + repeat: false, + window: Entity::PLACEHOLDER, + }); + } + + /// 1.12 binds a key by what its layout names it: on AZERTY the key where a US W sits is + /// `VK_Z` and runs `Z`'s binding, and `W`'s moves to the key labelled W. Shift is a prefix, + /// never part of the name. + #[test] + fn a_key_runs_the_binding_of_what_its_layout_names_it() { + let script = core_script(); + script + .run( + r#"SetBinding("SHIFT-Z", "TOGGLEUI"); SetBinding("SHIFT-1", "TARGETPREVIOUSENEMY"); + SetBinding("SHIFT-;", "TARGETNEARESTENEMY")"#, + ) + .expect("bind"); + let mut app = vm_harness(script); + layout_key(&mut app, KeyCode::KeyW, 'Z'); + layout_key(&mut app, KeyCode::KeyZ, 'W'); + type_key(&mut app, KeyCode::KeyW, "z"); + app.update(); + assert!( + state(&app).fired(Input::ToggleSheath), + "the key labelled Z is Z" + ); + assert!(!state(&app).pressed(Input::MoveForward)); + release_key(&mut app, KeyCode::KeyW); + type_key(&mut app, KeyCode::KeyZ, "w"); + app.update(); + assert!( + state(&app).pressed(Input::MoveForward), + "the key labelled W is W" + ); + release_key(&mut app, KeyCode::KeyZ); + app.update(); + assert!( + !state(&app).pressed(Input::MoveForward), + "its release ends it" + ); + // Shift makes the character `Z`, and the chord `SHIFT-Z`. + press_key(&mut app, KeyCode::ShiftLeft); + type_key(&mut app, KeyCode::KeyW, "Z"); + app.update(); + assert_eq!(lua_count(&app, "UITOGGLES"), 1, "SHIFT-Z"); + release_key(&mut app, KeyCode::KeyW); + // Shift+1 makes `!` on a US layout, and is `SHIFT-1`. + type_key(&mut app, KeyCode::Digit1, "!"); + app.update(); + assert_eq!(lua_count(&app, "STABS"), 1, "SHIFT-1, never SHIFT-!"); + release_key(&mut app, KeyCode::Digit1); + // And Shift+; makes `:`, and is `SHIFT-;`. + type_key(&mut app, KeyCode::Semicolon, ":"); + app.update(); + assert_eq!(lua_count(&app, "TABS"), 1, "SHIFT-;, never SHIFT-:"); + } + + /// A key's name follows a layout switch while running: its next press is named under the + /// layout it was typed in. + #[test] + fn a_layout_switch_renames_a_key_at_its_next_press() { + let mut app = harness(); + layout_key(&mut app, KeyCode::KeyW, 'Z'); + type_key(&mut app, KeyCode::KeyW, "z"); + app.update(); + assert!(state(&app).fired(Input::ToggleSheath)); + release_key(&mut app, KeyCode::KeyW); + app.update(); + // Back on a US layout, the key's next message reports `w`. + layout_key(&mut app, KeyCode::KeyW, 'W'); + type_key(&mut app, KeyCode::KeyW, "w"); + app.update(); + assert!(state(&app).pressed(Input::MoveForward)); + assert!(!state(&app).fired(Input::ToggleSheath)); + } + /// The dev plane spends the keyboard's fallback probe and nothing else, asserted on /// [`BindingDispatch::resolve`] itself. #[test] diff --git a/crates/benilla-app/src/bindings/chord.rs b/crates/benilla-app/src/bindings/chord.rs index e5e34227..2e7f4ffe 100644 --- a/crates/benilla-app/src/bindings/chord.rs +++ b/crates/benilla-app/src/bindings/chord.rs @@ -4,15 +4,37 @@ //! //! Prefix order is ALT-CTRL-SHIFT (`Blizzard_BindingUI.lua:176-182`; the emitter `0x4b6630` walks //! the table at `0x846bd0`), and it decides which modifier the fallback drops. Super/Cmd is not a -//! 1.12 modifier: a chord never carries it and a press with it held never matches. +//! 1.12 modifier, so a chord never carries it; that a press with it held runs nothing is +//! `crate::bindings`'s deviation. + +use std::fmt; use bevy::input::mouse::MouseButton; use bevy::prelude::KeyCode; -/// A bindable base input: a keyboard key, a mouse button, or one wheel direction. +use super::layout::{LayoutName, LayoutNames}; + +/// A key's 1.12 name: one character (`Z`, `1`, `ΓΉ`), or a word from the reference's name table +/// (`F1`, `NUMPAD7`, `SPACE`). +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub(crate) enum KeyName { + Char(char), + Word(&'static str), +} + +impl fmt::Display for KeyName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + KeyName::Char(c) => write!(f, "{c}"), + KeyName::Word(w) => f.write_str(w), + } + } +} + +/// A bindable base input: a keyboard key by its 1.12 name, a mouse button, or one wheel direction. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] pub(crate) enum BindKey { - Key(KeyCode), + Key(KeyName), Mouse(MouseButton), WheelUp, WheelDown, @@ -78,143 +100,107 @@ impl Chord { } } -/// The 1.12 token for a physical key; `None` for a key the reference names `UNKNOWN` and for the -/// modifiers, which are only prefixes (`IsKeyPressIgnoredForBinding`). Both Enters are `ENTER`. -pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> { +/// The 1.12 name a key press gets: a letter, digit or punctuation key what the active layout +/// names it ([`LayoutNames`]), every other key its fixed name. `None` for a key the reference +/// names `UNKNOWN` or drops, and for the modifiers, which are only prefixes +/// (`IsKeyPressIgnoredForBinding`). Both Enters are `ENTER`. +pub(crate) fn key_token(k: KeyCode, layout: &LayoutNames) -> Option { + match layout.name(k) { + // The lookup folds `a`-`z` up (`0x64b419`), so Turkish `VK_OEM_7`'s `i` is `I`'s key. + Some(LayoutName::Char(c)) => Some(KeyName::Char(c.to_ascii_uppercase())), + Some(LayoutName::Dropped) => None, + None => fixed_name(k), + } +} + +/// The fixed half of `0x42d800` and the Mac table: every key no layout names +/// ([`super::layout::position`]). +fn fixed_name(k: KeyCode) -> Option { use KeyCode::*; + use KeyName::Word; Some(match k { - KeyA => "A", - KeyB => "B", - KeyC => "C", - KeyD => "D", - KeyE => "E", - KeyF => "F", - KeyG => "G", - KeyH => "H", - KeyI => "I", - KeyJ => "J", - KeyK => "K", - KeyL => "L", - KeyM => "M", - KeyN => "N", - KeyO => "O", - KeyP => "P", - KeyQ => "Q", - KeyR => "R", - KeyS => "S", - KeyT => "T", - KeyU => "U", - KeyV => "V", - KeyW => "W", - KeyX => "X", - KeyY => "Y", - KeyZ => "Z", - Digit1 => "1", - Digit2 => "2", - Digit3 => "3", - Digit4 => "4", - Digit5 => "5", - Digit6 => "6", - Digit7 => "7", - Digit8 => "8", - Digit9 => "9", - Digit0 => "0", - F1 => "F1", - F2 => "F2", - F3 => "F3", - F4 => "F4", - F5 => "F5", - F6 => "F6", - F7 => "F7", - F8 => "F8", - F9 => "F9", - F10 => "F10", - F11 => "F11", - F12 => "F12", + F1 => Word("F1"), + F2 => Word("F2"), + F3 => Word("F3"), + F4 => Word("F4"), + F5 => Word("F5"), + F6 => Word("F6"), + F7 => Word("F7"), + F8 => Word("F8"), + F9 => Word("F9"), + F10 => Word("F10"), + F11 => Word("F11"), + F12 => Word("F12"), // On a Mac F13 is print-screen: the Mac key table `0x5bf320` maps keycode `0x69` to // `0x212`, `PRINTSCREEN`, and macOS has no PrintScreen keycode. #[cfg(target_os = "macos")] - F13 => "PRINTSCREEN", + F13 => Word("PRINTSCREEN"), // `IsValidBindingKeyString` accepts `F` + any digits (`0x846c04`) and the Mac table maps // keycode `0x6A` to `F16` (`0x30d`). Deviation: the Windows key table stops at `VK_F12` // and drops later F-keys; they are named here so a stored `F13`-`F24` can be pressed. #[cfg(not(target_os = "macos"))] - F13 => "F13", + F13 => Word("F13"), // On a Mac F14 and F15 are ScrollLock and Pause: keycodes `0x6B` and `0x71` map to // `0x210` and `0x211`, which the namer calls `UNKNOWN`, so they stay unbindable there. #[cfg(not(target_os = "macos"))] - F14 => "F14", + F14 => Word("F14"), #[cfg(not(target_os = "macos"))] - F15 => "F15", - F16 => "F16", - F17 => "F17", - F18 => "F18", - F19 => "F19", - F20 => "F20", - F21 => "F21", - F22 => "F22", - F23 => "F23", - F24 => "F24", - Space => "SPACE", - Tab => "TAB", - Enter | NumpadEnter => "ENTER", - Escape => "ESCAPE", - Backspace => "BACKSPACE", - Insert => "INSERT", - Delete => "DELETE", - Home => "HOME", - End => "END", - PageUp => "PAGEUP", - PageDown => "PAGEDOWN", - ArrowUp => "UP", - ArrowDown => "DOWN", - ArrowLeft => "LEFT", - ArrowRight => "RIGHT", - Numpad0 => "NUMPAD0", - Numpad1 => "NUMPAD1", - Numpad2 => "NUMPAD2", - Numpad3 => "NUMPAD3", - Numpad4 => "NUMPAD4", - Numpad5 => "NUMPAD5", - Numpad6 => "NUMPAD6", - Numpad7 => "NUMPAD7", - Numpad8 => "NUMPAD8", - Numpad9 => "NUMPAD9", - NumpadAdd => "NUMPADPLUS", - NumpadSubtract => "NUMPADMINUS", - NumpadDivide => "NUMPADDIVIDE", - NumpadMultiply => "NUMPADMULTIPLY", - NumpadDecimal => "NUMPADDECIMAL", + F15 => Word("F15"), + F16 => Word("F16"), + F17 => Word("F17"), + F18 => Word("F18"), + F19 => Word("F19"), + F20 => Word("F20"), + F21 => Word("F21"), + F22 => Word("F22"), + F23 => Word("F23"), + F24 => Word("F24"), + Space => Word("SPACE"), + Tab => Word("TAB"), + Enter | NumpadEnter => Word("ENTER"), + Escape => Word("ESCAPE"), + Backspace => Word("BACKSPACE"), + Insert => Word("INSERT"), + Delete => Word("DELETE"), + Home => Word("HOME"), + End => Word("END"), + PageUp => Word("PAGEUP"), + PageDown => Word("PAGEDOWN"), + ArrowUp => Word("UP"), + ArrowDown => Word("DOWN"), + ArrowLeft => Word("LEFT"), + ArrowRight => Word("RIGHT"), + Numpad0 => Word("NUMPAD0"), + Numpad1 => Word("NUMPAD1"), + Numpad2 => Word("NUMPAD2"), + Numpad3 => Word("NUMPAD3"), + Numpad4 => Word("NUMPAD4"), + Numpad5 => Word("NUMPAD5"), + Numpad6 => Word("NUMPAD6"), + Numpad7 => Word("NUMPAD7"), + Numpad8 => Word("NUMPAD8"), + Numpad9 => Word("NUMPAD9"), + NumpadAdd => Word("NUMPADPLUS"), + NumpadSubtract => Word("NUMPADMINUS"), + NumpadDivide => Word("NUMPADDIVIDE"), + NumpadMultiply => Word("NUMPADMULTIPLY"), + NumpadDecimal => Word("NUMPADDECIMAL"), // The Mac keypad's `=`: the namer's `0x30c`, and in `IsValidBindingKeyString`'s table. - NumpadEqual => "NUMPADEQUALS", - NumLock => "NUMLOCK", - PrintScreen => "PRINTSCREEN", + NumpadEqual => Word("NUMPADEQUALS"), + NumLock => Word("NUMLOCK"), + PrintScreen => Word("PRINTSCREEN"), // No ScrollLock or Pause: the namer (`0x4b66b0`) calls `0x210`/`0x211` `UNKNOWN`, and // `IsValidBindingKeyString` (`0x4b7890`) has neither name. - CapsLock => "CAPSLOCK", - Minus => "-", - Equal => "=", - BracketLeft => "[", - BracketRight => "]", - Backslash => "\\", - Semicolon => ";", - Quote => "'", - Comma => ",", - Period => ".", - Slash => "/", - Backquote => "`", + CapsLock => Word("CAPSLOCK"), _ => return None, }) } -/// Fold key aliases that share one 1.12 token (`NumpadEnter` β†’ `Enter`) so a chord parsed from -/// `ENTER` matches either physical key. +/// Fold the keys the reference gives one code (`NumpadEnter` β†’ `Enter`, both `0x201`), so the +/// pressed-key list and a latch treat them as one key. pub(crate) fn normalize_key(k: KeyCode) -> KeyCode { match k { KeyCode::NumpadEnter => KeyCode::Enter, - // The Mac print-screen key; must agree with `key_token`, which names it for capture. - #[cfg(target_os = "macos")] - KeyCode::F13 => KeyCode::PrintScreen, other => other, } } @@ -251,43 +237,15 @@ fn token_key(t: &str) -> Option { "MOUSEWHEELDOWN" => return Some(BindKey::WheelDown), _ => {} } + // One character: what a layout names some key, which the namer writes as UTF-8 (`0x4b66b0`'s + // `[0x21, 0xff]` arm through `0x41abb0`). [`key_token`] folds `a`-`z` up as the lookup does, + // so a lowercase letter is no press's name. + let mut chars = t.chars(); + if let (Some(c), None) = (chars.next(), chars.next()) { + let pressable = !c.is_control() && !c.is_whitespace() && !c.is_ascii_lowercase(); + return pressable.then_some(BindKey::Key(KeyName::Char(c))); + } let k = match t { - "A" => KeyA, - "B" => KeyB, - "C" => KeyC, - "D" => KeyD, - "E" => KeyE, - "F" => KeyF, - "G" => KeyG, - "H" => KeyH, - "I" => KeyI, - "J" => KeyJ, - "K" => KeyK, - "L" => KeyL, - "M" => KeyM, - "N" => KeyN, - "O" => KeyO, - "P" => KeyP, - "Q" => KeyQ, - "R" => KeyR, - "S" => KeyS, - "T" => KeyT, - "U" => KeyU, - "V" => KeyV, - "W" => KeyW, - "X" => KeyX, - "Y" => KeyY, - "Z" => KeyZ, - "1" => Digit1, - "2" => Digit2, - "3" => Digit3, - "4" => Digit4, - "5" => Digit5, - "6" => Digit6, - "7" => Digit7, - "8" => Digit8, - "9" => Digit9, - "0" => Digit0, "F1" => F1, "F2" => F2, "F3" => F3, @@ -346,26 +304,13 @@ fn token_key(t: &str) -> Option { "NUMLOCK" => NumLock, "PRINTSCREEN" => PrintScreen, "CAPSLOCK" => CapsLock, - "-" => Minus, - "=" => Equal, - "[" => BracketLeft, - "]" => BracketRight, - "\\" => Backslash, - ";" => Semicolon, - "'" => Quote, - "," => Comma, - "." => Period, - "/" => Slash, - "`" => Backquote, _ => return None, }; - // Fold aliases so a parsed chord equals a normalized press, and accept only a token the - // namer produces on this platform (on a Mac, F13-F15 are other keys). - let k = normalize_key(k); - if key_token(k) != Some(t) { - return None; + // Only a name the namer gives on this platform: on a Mac, F13-F15 are other keys. + match fixed_name(k) { + Some(name @ KeyName::Word(w)) if w == t => Some(BindKey::Key(name)), + _ => None, } - Some(BindKey::Key(k)) } #[cfg(test)] @@ -397,6 +342,7 @@ mod tests { "{command}: default '{default}' does not parse" ); } + let us = LayoutNames::default(); for k in [ KeyCode::KeyW, KeyCode::Digit0, @@ -431,9 +377,9 @@ mod tests { KeyCode::Slash, KeyCode::Backquote, ] { - let token = key_token(k).expect("named"); + let token = key_token(k, &us).expect("named").to_string(); assert!( - normalize_binding_key(token).is_some(), + normalize_binding_key(&token).is_some(), "{k:?} names '{token}', which SetBinding refuses" ); } @@ -447,8 +393,8 @@ mod tests { assert!(normalize_binding_key(token).is_some()); } // The reference's namer calls these `UNKNOWN` (`0x210`/`0x211`). - assert_eq!(key_token(KeyCode::ScrollLock), None); - assert_eq!(key_token(KeyCode::Pause), None); + assert_eq!(key_token(KeyCode::ScrollLock, &us), None); + assert_eq!(key_token(KeyCode::Pause, &us), None); } /// The validator's accept set is infinite, so this covers the tokens a real keyboard or mouse @@ -517,6 +463,7 @@ mod tests { #[test] fn the_codec_round_trips_every_keyboard_token() { + let us = LayoutNames::default(); let mut checked = 0; for k in [ KeyCode::KeyW, @@ -530,38 +477,33 @@ mod tests { KeyCode::Backquote, KeyCode::Quote, ] { - let t = key_token(k).unwrap(); + let name = key_token(k, &us).unwrap(); assert_eq!( - token_key(t), - Some(BindKey::Key(normalize_key(k))), - "token {t}" + token_key(&name.to_string()), + Some(BindKey::Key(name)), + "token {name}" ); checked += 1; } assert_eq!(checked, 10); - assert_eq!(key_token(KeyCode::NumpadEnter), Some("ENTER")); - assert_eq!(token_key("ENTER"), Some(BindKey::Key(KeyCode::Enter))); + let enter = Some(KeyName::Word("ENTER")); + assert_eq!(key_token(KeyCode::NumpadEnter, &us), enter); + assert_eq!(key_token(KeyCode::Enter, &us), enter); + assert_eq!(token_key("ENTER"), enter.map(BindKey::Key)); } /// macOS has no `PrintScreen`, so capture and dispatch must both read F13 as `PRINTSCREEN`. #[cfg(target_os = "macos")] #[test] fn f13_is_print_screen_on_a_mac() { - assert_eq!( - key_token(KeyCode::F13), - Some("PRINTSCREEN"), - "the capture arm" - ); - assert_eq!( - normalize_key(KeyCode::F13), - KeyCode::PrintScreen, - "the dispatch arm" - ); + let name = key_token(KeyCode::F13, &LayoutNames::default()); + assert_eq!(name, Some(KeyName::Word("PRINTSCREEN"))); assert_eq!( token_key("PRINTSCREEN"), - Some(BindKey::Key(normalize_key(KeyCode::F13))), + name.map(BindKey::Key), "a chord parsed from the shipped default matches an F13 press" ); + assert_eq!(token_key("F13"), None, "no Mac key is F13"); } #[test] @@ -572,7 +514,7 @@ mod tests { alt: true, ctrl: true, shift: true, - key: BindKey::Key(KeyCode::F1) + key: BindKey::Key(KeyName::Word("F1")) }) ); // `CTRL--` is Ctrl + the minus key. @@ -582,7 +524,7 @@ mod tests { alt: false, ctrl: true, shift: false, - key: BindKey::Key(KeyCode::Minus) + key: BindKey::Key(KeyName::Char('-')) }) ); assert_eq!( @@ -610,8 +552,142 @@ mod tests { alt: true, ctrl: false, shift: true, - key: BindKey::Key(KeyCode::PageDown) + key: BindKey::Key(KeyName::Word("PAGEDOWN")) } ); } + + /// The names a layout gives its keys, as `layout::record_layout_names` keeps them. + fn layout(keys: &[(KeyCode, char)]) -> LayoutNames { + let mut names = LayoutNames::default(); + for &(k, c) in keys { + names.set(k, Some(LayoutName::Char(c))); + } + names + } + + fn name(k: KeyCode, layout: &LayoutNames) -> Option { + key_token(k, layout).map(|n| n.to_string()) + } + + /// 1.12 names a letter, digit or punctuation key what the active layout names it, its ASCII + /// letters folded up as the lookup folds them, and every other key by the fixed table. AZERTY + /// on Windows: `VK_Z` where a US W sits, `VK_M` on the semicolon key. + #[test] + fn a_layout_key_is_named_what_its_layout_names_it() { + use KeyCode::*; + let mut azerty = layout(&[ + (KeyW, 'Z'), + (KeyZ, 'W'), + (KeyQ, 'A'), + (KeyA, 'Q'), + (Semicolon, 'M'), + (KeyM, ','), + (Comma, ';'), + (Period, ':'), + (Slash, '!'), + (Quote, 'ΓΉ'), + (Backquote, 'Β²'), + (Minus, ')'), + (BracketLeft, '^'), + (BracketRight, '$'), + (Backslash, '*'), + (IntlBackslash, '<'), + (NumpadComma, '.'), + (Digit1, '1'), + ]); + // Turkish Q's `VK_OEM_7` names its key `i`, which the lookup folds to `I`. + azerty.set(KeyCode::KeyI, Some(LayoutName::Char('i'))); + for (k, expected) in [ + (KeyW, "Z"), + (KeyZ, "W"), + (KeyQ, "A"), + (KeyA, "Q"), + (Semicolon, "M"), + (KeyM, ","), + (Comma, ";"), + (Period, ":"), + (Slash, "!"), + (Quote, "ΓΉ"), + (Backquote, "Β²"), + (Minus, ")"), + (BracketLeft, "^"), + (BracketRight, "$"), + (Backslash, "*"), + (IntlBackslash, "<"), + (NumpadComma, "."), + (Digit1, "1"), + (KeyI, "I"), + (F1, "F1"), + (Numpad7, "NUMPAD7"), + (Space, "SPACE"), + ] { + assert_eq!(name(k, &azerty).as_deref(), Some(expected), "{k:?}"); + let token = key_token(k, &azerty).unwrap(); + assert_eq!( + token_key(expected), + Some(BindKey::Key(token)), + "the stored '{expected}' is that key's chord" + ); + } + // A key its layout drops binds nothing (`0x42da49`). + azerty.set(Backquote, Some(LayoutName::Dropped)); + assert_eq!(name(Backquote, &azerty), None); + } + + /// A US layout, reported or not, names every key as it always has. + #[test] + fn a_us_layout_names_its_keys_as_the_us_positions() { + use KeyCode::*; + let reported = layout(&[ + (KeyW, 'w'), + (KeyZ, 'z'), + (Quote, '\''), + (Backquote, '`'), + (Minus, '-'), + (IntlBackslash, '\\'), + ]); + let unreported = LayoutNames::default(); + for layout in [&reported, &unreported] { + for (k, expected) in [ + (KeyW, "W"), + (KeyZ, "Z"), + (Quote, "'"), + (Backquote, "`"), + (Minus, "-"), + (Digit1, "1"), + ] { + assert_eq!(name(k, layout).as_deref(), Some(expected), "{k:?}"); + } + } + assert_eq!(name(IntlBackslash, &reported).as_deref(), Some("\\")); + assert_eq!( + name(IntlBackslash, &unreported), + None, + "an ISO key no layout has named yet" + ); + } + + /// Modifiers are prefixes, never part of the name: Shift+1 is `SHIFT-1`, not `!`. + #[test] + fn a_chord_names_its_key_unshifted() { + let azerty = layout(&[(KeyCode::KeyW, 'z')]); + let key = |k| BindKey::Key(key_token(k, &azerty).unwrap()); + assert_eq!( + Chord::parse("SHIFT-Z").map(|c| (c.shift, c.key)), + Some((true, key(KeyCode::KeyW))) + ); + assert_eq!( + Chord::parse("SHIFT-1").map(|c| (c.shift, c.key)), + Some((true, key(KeyCode::Digit1))) + ); + // Nothing presses a lowercase letter, a space or a control character. + for t in ["w", " ", "\u{7}"] { + assert_eq!(Chord::parse(t), None, "{t:?}"); + } + assert_eq!( + Chord::parse("ΓΉ").map(|c| c.key), + Some(BindKey::Key(KeyName::Char('ΓΉ'))) + ); + } } diff --git a/crates/benilla-app/src/bindings/layout.rs b/crates/benilla-app/src/bindings/layout.rs new file mode 100644 index 00000000..b373b0f3 --- /dev/null +++ b/crates/benilla-app/src/bindings/layout.rs @@ -0,0 +1,668 @@ +//! What the active keyboard layout names each letter, digit and punctuation key, which 1.12 binds +//! by. The Windows client names a key from the virtual-key code the layout produced: the +//! translator `0x42d800` keeps `VK_0`-`VK_9` as themselves (`0x42d81c`) and sends every other key +//! outside its fixed table, `VK_A`-`VK_Z` and `VK_OEM_*` among them, to +//! `MapVirtualKeyA(vk, MAPVK_VK_TO_CHAR)` (`0x42da39`), which returns `VK_A`-`VK_Z`'s own letter +//! whatever the layout types there. The Mac client fills its letter and punctuation keys from the +//! live layout, `KeyTranslate` (`0x8a3c3`) on the current `KCHR` (re-read when it changes, +//! `0x8a345`), and fixes its digits by position (`0x5bf320`). Bevy's `KeyboardInput` carries the +//! physical key and a `logical_key` with Shift applied, so each platform's name is read off the +//! raw winit key messages here, before any `Update` reader. + +use std::collections::HashMap; + +use bevy::input::keyboard::KeyCode; +use bevy::prelude::*; + +/// A layout-named key's place on a US keyboard, with the US name there. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Position { + Letter(char), + Digit(char), + /// The ISO and JIS extra keys and the keypad comma have no US name. + Punctuation(Option), +} + +impl Position { + fn us(self) -> Option { + match self { + Position::Letter(c) | Position::Digit(c) => Some(c), + Position::Punctuation(c) => c, + } + } +} + +/// The keys a layout names: the letters, the digits, the punctuation, the ISO and JIS extras and +/// the keypad comma. Windows sends `VK_OEM_102`, `VK_ABNT_C2` and `VK_SEPARATOR` to the layout arm +/// like any `VK_OEM_*` key, and the Mac table leaves the ISO key `0x0A` and the keypad comma +/// `0x5F` to `KeyTranslate` (`-1` at `0x5bf320`). winit 0.30 cannot deliver those on a Mac, as on +/// main: it reports `0x0A` as `Backquote` (whose scancode it gives back as `0x32`) and leaves +/// `0x5E` and `0x5F` unidentified, so the `IntlBackslash`, `IntlRo` and `NumpadComma` arms never +/// fire there. +pub(crate) fn position(k: KeyCode) -> Option { + use KeyCode::*; + use Position::{Digit, Letter, Punctuation}; + Some(match k { + KeyA => Letter('A'), + KeyB => Letter('B'), + KeyC => Letter('C'), + KeyD => Letter('D'), + KeyE => Letter('E'), + KeyF => Letter('F'), + KeyG => Letter('G'), + KeyH => Letter('H'), + KeyI => Letter('I'), + KeyJ => Letter('J'), + KeyK => Letter('K'), + KeyL => Letter('L'), + KeyM => Letter('M'), + KeyN => Letter('N'), + KeyO => Letter('O'), + KeyP => Letter('P'), + KeyQ => Letter('Q'), + KeyR => Letter('R'), + KeyS => Letter('S'), + KeyT => Letter('T'), + KeyU => Letter('U'), + KeyV => Letter('V'), + KeyW => Letter('W'), + KeyX => Letter('X'), + KeyY => Letter('Y'), + KeyZ => Letter('Z'), + Digit1 => Digit('1'), + Digit2 => Digit('2'), + Digit3 => Digit('3'), + Digit4 => Digit('4'), + Digit5 => Digit('5'), + Digit6 => Digit('6'), + Digit7 => Digit('7'), + Digit8 => Digit('8'), + Digit9 => Digit('9'), + Digit0 => Digit('0'), + Minus => Punctuation(Some('-')), + Equal => Punctuation(Some('=')), + BracketLeft => Punctuation(Some('[')), + BracketRight => Punctuation(Some(']')), + Backslash => Punctuation(Some('\\')), + Semicolon => Punctuation(Some(';')), + Quote => Punctuation(Some('\'')), + Comma => Punctuation(Some(',')), + Period => Punctuation(Some('.')), + Slash => Punctuation(Some('/')), + Backquote => Punctuation(Some('`')), + IntlBackslash | IntlRo | IntlYen | NumpadComma => Punctuation(None), + _ => return None, + }) +} + +/// A layout-named key's name. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum LayoutName { + Char(char), + /// None: `MapVirtualKeyA` returned 0 and the reference drops the key's message (`0x42da49`). + Dropped, +} + +/// Each layout-named key's name under the active layout, as its platform last reported it: an +/// entry changes with the next message of its key after a layout switch, so a press is named under +/// the layout it was typed in. Empty where no winit feeds it (a headless run, a test). +#[derive(Resource, Default, Debug)] +pub(crate) struct LayoutNames(HashMap); + +impl LayoutNames { + /// The name of `key` if a layout names it, `None` for a key the fixed table names. A key never + /// reported (a headless run) keeps its US name. + /// + /// Deviation: on the Mac and Linux a key its platform reports no character for keeps its US + /// name too, where the Mac reference drops it (`0x8a4aa`), and a Linux dead key not yet pressed + /// bare stays bindable. + pub(crate) fn name(&self, key: KeyCode) -> Option { + let position = position(key)?; + Some(match self.0.get(&key) { + Some(name) => *name, + None => position.us().map_or(LayoutName::Dropped, LayoutName::Char), + }) + } + + /// Set what the layout names `key`; `None` clears it back to the US name. + pub(crate) fn set(&mut self, key: KeyCode, name: Option) { + match name { + Some(name) => self.0.insert(key, name), + None => self.0.remove(&key), + }; + } +} + +/// Keeps [`LayoutNames`] current from the raw key messages (every shipped platform). +pub(super) fn plugin(app: &mut App) { + app.init_resource::(); + // A headless build has no winit to register it. + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + app.add_message::() + .add_systems(PreUpdate, record_layout_names); +} + +/// Names each key a raw key message reports, by its platform's rule. On the main thread, the +/// window's: Windows reads that thread's layout, and macOS's input-source calls want it. +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +fn record_layout_names( + mut raw: MessageReader, + mut names: ResMut, + #[cfg(target_os = "linux")] mut mods: Local, + _main_thread: bevy::ecs::system::NonSendMarker, +) { + use bevy::winit::converters::convert_physical_key_code; + use winit::event::WindowEvent; + #[cfg(target_os = "macos")] + let mut ascii_capable = None; + for ev in raw.read() { + let event = match &ev.event { + WindowEvent::KeyboardInput { event, .. } => event, + #[cfg(target_os = "linux")] + WindowEvent::ModifiersChanged(m) => { + mods.state = m.state(); + continue; + } + _ => continue, + }; + #[cfg(target_os = "linux")] + mods.key(event.physical_key, event.state.is_pressed()); + let key = convert_physical_key_code(event.physical_key); + let Some(position) = position(key) else { + continue; + }; + #[cfg(target_os = "windows")] + { + let _ = position; + names.set(key, windows::name(event.physical_key)); + } + // The Mac and Linux keep their digits by position. + #[cfg(target_os = "macos")] + if !matches!(position, Position::Digit(_)) { + use winit::platform::modifier_supplement::KeyEventExtModifierSupplement; + let capable = *ascii_capable.get_or_insert_with(mac::current_is_ascii_capable); + let current = one_char(&event.key_without_modifiers()); + let name = mac_name(capable, current, || { + mac::ascii_capable_char(event.physical_key) + }); + names.set(key, name); + } + #[cfg(target_os = "linux")] + if !matches!(position, Position::Digit(_)) { + use winit::platform::modifier_supplement::KeyEventExtModifierSupplement; + let unshifted = event.key_without_modifiers(); + if let Some(name) = linux_name(position, &unshifted, &event.logical_key, mods.bare()) { + names.set(key, name); + } + } + } +} + +/// The one printable character a winit key stands for. +#[cfg(any(target_os = "macos", target_os = "linux", test))] +fn one_char(key: &winit::keyboard::Key) -> Option { + let winit::keyboard::Key::Character(s) = key else { + return None; + }; + let mut chars = s.chars(); + chars + .next() + .filter(|c| chars.next().is_none() && !c.is_control() && !c.is_whitespace()) +} + +/// The reference's name for the virtual key `vk`, given `MapVirtualKeyExW(vk, MAPVK_VK_TO_CHAR)`; +/// `None` for a key `0x42d800`'s fixed table names. +/// +/// Deviation: a character above U+00FF names the key by itself. The reference's `MapVirtualKeyA` +/// returns an ANSI code-page byte, which `0x42da4b` drops past `0xff` and the namer's UTF-8 +/// encoder (`0x41abb0`) otherwise reads as Latin-1, so such a key is mislabelled or dropped; named +/// by what it types, it stays bindable and readable. +#[cfg(any(target_os = "windows", test))] +fn windows_name(vk: u32, to_char: u32) -> Option { + match vk { + // `VK_A`-`VK_Z`, named by their letter, and `VK_0`-`VK_9`, kept as themselves. + 0x41..=0x5a | 0x30..=0x39 => Some(LayoutName::Char(char::from(vk as u8))), + _ if fixed_vk(vk) => None, + // The dynamic arm: `and eax,0xffff` (`0x42da42`) drops the dead-key bit, and 0 drops the + // key (`0x42da49`). U+F000-U+F002 are a layout table's `WCH_NONE`, `WCH_DEAD` and + // `WCH_LGTR` markers, no character, should the call ever return one. + _ => Some(match char::from_u32(to_char & 0xffff) { + Some(c) + if !c.is_control() + && !c.is_whitespace() + && !('\u{f000}'..='\u{f002}').contains(&c) => + { + LayoutName::Char(c) + } + _ => LayoutName::Dropped, + }), + } +} + +/// `0x42d800`'s fixed table, read from its byte remap at `0x42dafc` and jump table at `0x42da60`: +/// the keys it names itself, never by character. F1-F12 take their own arm (`0x42d800`). +#[cfg(any(target_os = "windows", test))] +fn fixed_vk(vk: u32) -> bool { + matches!( + vk, + 0x08 | 0x09 + | 0x0d + | 0x10..=0x14 + | 0x1b + | 0x20..=0x28 + | 0x2c..=0x2e + | 0x60..=0x6b + | 0x6d..=0x7b + | 0x90 + | 0x91 + ) +} + +#[cfg(target_os = "windows")] +mod windows { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + GetKeyboardLayout, MapVirtualKeyExW, MAPVK_VK_TO_CHAR, MAPVK_VSC_TO_VK_EX, + }; + + /// The reference's own path for the key at `physical`: the virtual key the window thread's + /// layout gives its scancode, then that key's name (`super::windows_name`). + pub(super) fn name(physical: winit::keyboard::PhysicalKey) -> Option { + use winit::platform::scancode::PhysicalKeyExtScancode; + let scancode = physical.to_scancode()?; + // SAFETY: plain Win32 calls with no pointers. `GetKeyboardLayout(0)` is the calling + // thread's layout; the caller runs on the main thread, the window's, so it is the layout + // the window's key messages were made under. + let (vk, to_char) = unsafe { + let hkl = GetKeyboardLayout(0); + let vk = MapVirtualKeyExW(scancode, MAPVK_VSC_TO_VK_EX, hkl); + (vk, MapVirtualKeyExW(vk, MAPVK_VK_TO_CHAR, hkl)) + }; + if vk == 0 { + return None; + } + super::windows_name(vk, to_char) + } +} + +/// The Mac name of a letter or punctuation key: the layout's own unshifted character, as +/// `KeyTranslate` gives the reference, or none. +/// +/// Deviation: a layout that is not ASCII-capable (Hebrew, Greek, Russian, Arabic, Thai) names its +/// keys through the ASCII-capable layout macOS pairs with it, as macOS names its own shortcuts. The +/// reference reads a non-Roman `KCHR`'s byte as MacRoman (`0x8a40f`), which names no real key. +/// +/// Deviation: a character above U+00FF (Czech `Δ›`, Polish `Ε‚`, Turkish `ş`) names the key by +/// itself. The reference reads `KeyTranslate`'s byte as MacRoman (`0x8a40f`) and keeps it only if +/// it converts to Latin-1 (`CFStringGetBytes`, `0x8a46b`, stored at `0x8a482`), else leaves the +/// slot `-1`, which `0x8a4aa` drops, so such a key is mislabelled or dropped; named by what it +/// types, it stays bindable and readable. +#[cfg(any(target_os = "macos", test))] +fn mac_name( + ascii_capable: bool, + current: Option, + ascii_layout_char: impl FnOnce() -> Option, +) -> Option { + let c = if ascii_capable { + current + } else { + ascii_layout_char() + }; + c.map(LayoutName::Char) +} + +#[cfg(target_os = "macos")] +mod mac { + use std::ffi::c_void; + use std::os::raw::c_ulong; + + type CFTypeRef = *const c_void; + + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFRelease(cf: CFTypeRef); + fn CFDataGetBytePtr(data: CFTypeRef) -> *const u8; + fn CFBooleanGetValue(boolean: CFTypeRef) -> u8; + } + + #[link(name = "Carbon", kind = "framework")] + extern "C" { + static kTISPropertyInputSourceIsASCIICapable: CFTypeRef; + static kTISPropertyUnicodeKeyLayoutData: CFTypeRef; + fn TISCopyCurrentKeyboardLayoutInputSource() -> CFTypeRef; + fn TISCopyCurrentASCIICapableKeyboardLayoutInputSource() -> CFTypeRef; + fn TISGetInputSourceProperty(source: CFTypeRef, key: CFTypeRef) -> CFTypeRef; + fn LMGetKbdType() -> u8; + fn UCKeyTranslate( + layout: *const u8, + virtual_key_code: u16, + key_action: u16, + modifier_key_state: u32, + keyboard_type: u32, + key_translate_options: u32, + dead_key_state: *mut u32, + max_string_length: c_ulong, + actual_string_length: *mut c_ulong, + unicode_string: *mut u16, + ) -> i32; + } + + const K_UC_KEY_ACTION_DISPLAY: u16 = 3; + const K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_MASK: u32 = 1; + + /// Is the current keyboard layout one macOS types shortcuts with directly? + pub(super) fn current_is_ascii_capable() -> bool { + // SAFETY: the copied source is released here; the property is a borrowed CFBoolean. + unsafe { + let source = TISCopyCurrentKeyboardLayoutInputSource(); + if source.is_null() { + return true; + } + let value = TISGetInputSourceProperty(source, kTISPropertyInputSourceIsASCIICapable); + let capable = value.is_null() || CFBooleanGetValue(value) != 0; + CFRelease(source); + capable + } + } + + /// The unshifted character the ASCII-capable layout macOS pairs with the current input + /// source makes on `physical`, dead keys off. + pub(super) fn ascii_capable_char(physical: winit::keyboard::PhysicalKey) -> Option { + use winit::platform::scancode::PhysicalKeyExtScancode; + let keycode = u16::try_from(physical.to_scancode()?).ok()?; + let mut buf = [0u16; 4]; + let mut len: c_ulong = 0; + // SAFETY: the copied source owns the layout bytes and is released after the translate; + // `buf` and `len` are valid for the length passed. + let status = unsafe { + let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource(); + if source.is_null() { + return None; + } + let data = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData); + let status = if data.is_null() { + -1 + } else { + let mut dead = 0u32; + UCKeyTranslate( + CFDataGetBytePtr(data), + keycode, + K_UC_KEY_ACTION_DISPLAY, + 0, + u32::from(LMGetKbdType()), + K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_MASK, + &mut dead, + buf.len() as c_ulong, + &mut len, + buf.as_mut_ptr(), + ) + }; + CFRelease(source); + status + }; + if status != 0 { + return None; + } + let units = buf.get(..usize::try_from(len).ok()?)?; + let mut chars = char::decode_utf16(units.iter().copied()); + let c = chars.next()?.ok()?; + (chars.next().is_none() && !c.is_control() && !c.is_whitespace()).then_some(c) + } +} + +/// The modifiers a Linux key press is made under, from the raw key stream. winit's +/// `ModifiersState` has no AltGr, so the right Alt key is followed by its own messages: an AltGr +/// press on a dead key types its AltGr level, never the key's own accent. +#[cfg(any(target_os = "linux", test))] +#[derive(Default)] +struct LinuxMods { + state: winit::keyboard::ModifiersState, + alt_right: bool, +} + +#[cfg(any(target_os = "linux", test))] +impl LinuxMods { + /// Follow the right Alt key through a key message. + fn key(&mut self, physical: winit::keyboard::PhysicalKey, pressed: bool) { + use winit::keyboard::{KeyCode, PhysicalKey}; + if physical == PhysicalKey::Code(KeyCode::AltRight) { + self.alt_right = pressed; + } + } + + /// No modifier held, AltGr included. + fn bare(&self) -> bool { + self.state.is_empty() && !self.alt_right + } +} + +/// The Linux name of a letter or punctuation key, `None` to keep what the key had. No 1.12 client +/// ran on Linux, so this is the closest consistent rule: a letter position takes an ASCII letter or +/// ASCII punctuation the layout types there, and anything else there (another script's letter, an +/// accented letter, a mark) keeps its US letter, as Windows keeps `VK_A`-`VK_Z` there on nearly +/// every such layout; any other key takes its character. A dead keysym has no character +/// (`key_without_modifiers` is `Unidentified`), so it takes the dead key's own character from a +/// press with no modifier held, and otherwise keeps what it had. +#[cfg(any(target_os = "linux", test))] +fn linux_name( + position: Position, + unshifted: &winit::keyboard::Key, + logical: &winit::keyboard::Key, + bare: bool, +) -> Option> { + use winit::keyboard::Key; + let c = match (unshifted, logical) { + (Key::Unidentified(_), Key::Dead(Some(c))) if bare => Some(*c), + (Key::Unidentified(_), _) => return None, + _ => one_char(unshifted), + }; + Some(c.map(|c| { + LayoutName::Char(match position { + Position::Letter(us) if !(c.is_ascii_alphabetic() || c.is_ascii_punctuation()) => us, + _ => c, + }) + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A layout key's name under `names`, its ASCII letters folded up as the lookup folds them. + fn named(names: &LayoutNames, k: KeyCode) -> Option { + match names.name(k) { + Some(LayoutName::Char(c)) => Some(c.to_ascii_uppercase()), + _ => None, + } + } + + const VK_OEM_1: u32 = 0xba; + const VK_OEM_COMMA: u32 = 0xbc; + const VK_OEM_3: u32 = 0xc0; + const VK_OEM_6: u32 = 0xdd; + const VK_OEM_7: u32 = 0xde; + + /// Windows: the reference's path on rows of the layouts' own tables (the key, its virtual + /// key, its unshifted character), as `MapVirtualKeyExW` returns them. + #[test] + fn windows_names_a_key_by_its_virtual_key_as_the_reference_does() { + let vk = |c: u8| u32::from(c); + let rows: &[(&str, KeyCode, u32, u32, char)] = &[ + // Hebrew: the W key types `'` but is `VK_W`; Q types `/` and is `VK_Q`. + ("hebrew W", KeyCode::KeyW, vk(b'W'), '\''.into(), 'W'), + ("hebrew Q", KeyCode::KeyQ, vk(b'Q'), '/'.into(), 'Q'), + ("greek Q", KeyCode::KeyQ, vk(b'Q'), ';'.into(), 'Q'), + ("lithuanian Q", KeyCode::KeyQ, vk(b'Q'), 'Δ…'.into(), 'Q'), + ("lithuanian W", KeyCode::KeyW, vk(b'W'), 'ΕΎ'.into(), 'W'), + ("turkish Δ±", KeyCode::KeyI, vk(b'I'), 'Δ±'.into(), 'I'), + // Turkish Q's `VK_OEM_7` types `i`: one name with `I`, as the lookup folds it. + ("turkish i", KeyCode::Quote, VK_OEM_7, 'i'.into(), 'I'), + // Bulgarian: `VK_Q` on the Period key, `VK_OEM_COMMA` on Q. + ("bulgarian .", KeyCode::Period, vk(b'Q'), 'Π»'.into(), 'Q'), + ("bulgarian Q", KeyCode::KeyQ, VK_OEM_COMMA, ','.into(), ','), + // Hungarian: `VK_0` on Backquote, `VK_OEM_3` `ΓΆ` on the 0 key. + ("hungarian `", KeyCode::Backquote, vk(b'0'), '0'.into(), '0'), + ("hungarian 0", KeyCode::Digit0, VK_OEM_3, 'ΓΆ'.into(), 'ΓΆ'), + // Dvorak's Q position is `VK_OEM_7`, `'`. + ("dvorak Q", KeyCode::KeyQ, VK_OEM_7, '\''.into(), '\''), + ("us ;", KeyCode::Semicolon, VK_OEM_1, ';'.into(), ';'), + // A dead key's bit (`0x80000000`) is masked off: French `^`. + ("french ^", KeyCode::BracketLeft, VK_OEM_6, 0x8000_005e, '^'), + // Above U+00FF the key is named by what it types (the deviation). + ("russian ΠΆ", KeyCode::Semicolon, VK_OEM_1, 'ΠΆ'.into(), 'ΠΆ'), + // The ISO key, German `VK_OEM_102`; the keypad comma, Brazilian ABNT's `VK_ABNT_C2` + // and a `VK_SEPARATOR` keypad. + ("german <", KeyCode::IntlBackslash, 0xe2, '<'.into(), '<'), + ("abnt keypad .", KeyCode::NumpadComma, 0xc2, '.'.into(), '.'), + ("keypad ,", KeyCode::NumpadComma, 0x6c, ','.into(), ','), + ]; + let mut names = LayoutNames::default(); + for &(what, key, vk, to_char, expected) in rows { + names.set(key, windows_name(vk, to_char)); + assert_eq!(named(&names, key), Some(expected), "{what}"); + } + // `MapVirtualKeyA` returning 0 drops the key (`0x42da49`): Japanese 106's + // `VK_OEM_AUTO` on Backquote. + assert_eq!(windows_name(0xf3, 0), Some(LayoutName::Dropped)); + names.set(KeyCode::Backquote, windows_name(0xf3, 0)); + assert_eq!(names.name(KeyCode::Backquote), Some(LayoutName::Dropped)); + // A layout table's `WCH_NONE`/`WCH_DEAD`/`WCH_LGTR` marker is no character. + for marker in 0xf000..=0xf002 { + assert_eq!( + windows_name(VK_OEM_1, marker), + Some(LayoutName::Dropped), + "{marker:#x}" + ); + } + // A key the fixed table names is no layout key's: F1, Space, Numpad 0. + for vk in [0x70, 0x20, 0x60] { + assert_eq!(windows_name(vk, 0), None, "{vk:#x}"); + } + } + + /// The fixed table is `0x42d800`'s, decoded from its remap bytes: the 60 entries less the + /// digits and F1-F12, which take their own arms. + #[test] + fn the_fixed_virtual_keys_are_the_translators_table() { + let fixed: Vec = (0..=0xffu32) + .filter(|&vk| fixed_vk(vk) && !(0x70..=0x7b).contains(&vk)) + .collect(); + assert_eq!( + fixed, + [ + 0x08, 0x09, 0x0d, 0x10, 0x11, 0x12, 0x13, 0x14, 0x1b, 0x20, 0x21, 0x22, 0x23, 0x24, + 0x25, 0x26, 0x27, 0x28, 0x2c, 0x2d, 0x2e, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, + 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6d, 0x6e, 0x6f, 0x90, 0x91, + ] + ); + } + + /// macOS: a Latin layout's own character; a layout that is not ASCII-capable names through + /// its ASCII-capable partner. + #[test] + fn a_mac_layout_that_is_not_ascii_capable_names_through_its_ascii_partner() { + // Hebrew: W types `'`; its U.S. partner types `w` there. + assert_eq!( + mac_name(false, Some('\''), || Some('w')), + Some(LayoutName::Char('w')) + ); + // French: W types `z`, and the partner is never asked. + assert_eq!( + mac_name(true, Some('z'), || panic!( + "an ASCII-capable layout names itself" + )), + Some(LayoutName::Char('z')) + ); + // No character: the US name stands in (the fallback deviation). + let mut names = LayoutNames::default(); + names.set(KeyCode::KeyW, mac_name(true, None, || None)); + assert_eq!(named(&names, KeyCode::KeyW), Some('W')); + } + + /// Linux: a letter position keeps its US letter unless the layout types an ASCII letter or + /// ASCII punctuation there; any other key takes its character. + #[test] + fn linux_names_a_letter_position_by_its_ascii_character_or_its_us_letter() { + use winit::keyboard::{Key, NativeKey}; + let ch = |c: &str| Key::Character(c.into()); + for (key, typed, expected) in [ + (KeyCode::KeyW, "z", 'z'), + (KeyCode::KeyM, ",", ','), + (KeyCode::KeyQ, "'", '\''), + (KeyCode::KeyQ, "Δ…", 'Q'), + (KeyCode::KeyI, "Δ±", 'I'), + (KeyCode::KeyW, "Ρ†", 'W'), + // Thai's tone mark on H, INSCRIPT's virama on D. + (KeyCode::KeyH, "\u{e48}", 'H'), + (KeyCode::KeyD, "\u{94d}", 'D'), + (KeyCode::Semicolon, "ΠΆ", 'ΠΆ'), + (KeyCode::Quote, "ΓΉ", 'ΓΉ'), + ] { + let got = linux_name(position(key).unwrap(), &ch(typed), &ch(typed), true); + assert_eq!( + got, + Some(Some(LayoutName::Char(expected))), + "{key:?} typing {typed}" + ); + } + // A dead keysym: its character from a bare press, kept through a modified one. + let none = Key::Unidentified(NativeKey::Unidentified); + let bracket = position(KeyCode::BracketLeft).unwrap(); + assert_eq!( + linux_name(bracket, &none, &Key::Dead(Some('^')), true), + Some(Some(LayoutName::Char('^'))) + ); + assert_eq!( + linux_name(bracket, &none, &Key::Dead(Some('Β¨')), false), + None + ); + } + + #[test] + fn a_layout_switch_renames_a_key_at_its_next_message() { + let mut names = LayoutNames::default(); + names.set(KeyCode::KeyW, Some(LayoutName::Char('z'))); + assert_eq!(named(&names, KeyCode::KeyW), Some('Z')); + names.set(KeyCode::KeyW, Some(LayoutName::Char('w'))); + assert_eq!( + named(&names, KeyCode::KeyW), + Some('W'), + "back on a US layout" + ); + // A layout that makes no character there falls back to the US name. + names.set(KeyCode::KeyQ, Some(LayoutName::Char('a'))); + names.set(KeyCode::KeyQ, None); + assert_eq!(named(&names, KeyCode::KeyQ), Some('Q')); + // Digits and punctuation name themselves until a layout says otherwise; an ISO key no + // layout reported has no name. + assert_eq!(named(&names, KeyCode::Digit1), Some('1')); + assert_eq!(named(&names, KeyCode::Quote), Some('\'')); + for key in [KeyCode::IntlBackslash, KeyCode::NumpadComma] { + assert_eq!(names.name(key), Some(LayoutName::Dropped), "{key:?}"); + } + assert_eq!(names.name(KeyCode::F1), None, "the fixed table's"); + } + + /// Linux: AltGr counts as a modifier for a dead key's bare press, though winit's + /// `ModifiersState` has no AltGr; AltGr on a dead-key position keeps the key's own accent. + #[test] + fn linux_altgr_is_no_bare_press() { + use winit::keyboard::{Key, KeyCode, ModifiersState, NativeKey, PhysicalKey}; + let none = Key::Unidentified(NativeKey::Unidentified); + let bracket = position(bevy::input::keyboard::KeyCode::BracketLeft).unwrap(); + let mut mods = LinuxMods::default(); + assert!(mods.bare()); + mods.key(PhysicalKey::Code(KeyCode::AltRight), true); + assert!(!mods.bare(), "AltGr held"); + // AltGr on the French `^` key types `~`: the key keeps its `^`. + assert_eq!( + linux_name(bracket, &none, &Key::Dead(Some('~')), mods.bare()), + None + ); + mods.key(PhysicalKey::Code(KeyCode::AltRight), false); + assert!(mods.bare(), "AltGr released"); + mods.key(PhysicalKey::Code(KeyCode::KeyQ), true); + assert!(mods.bare(), "another key is no modifier"); + mods.state = ModifiersState::SHIFT; + assert!(!mods.bare(), "Shift held"); + } +} diff --git a/crates/benilla-app/src/char_create/mod.rs b/crates/benilla-app/src/char_create/mod.rs index 0a15c7c2..b0d89aa5 100644 --- a/crates/benilla-app/src/char_create/mod.rs +++ b/crates/benilla-app/src/char_create/mod.rs @@ -340,8 +340,9 @@ fn create_input( keys: Res>, catalog: Option>, mut sel: ResMut, - // The host pasteboard and the window handle its Wayland backend needs. - mut clipboard: NonSendMut, + // The host pasteboard and the window handle its Wayland backend needs, and the layout that + // names the clipboard chords' letters. + (mut clipboard, layout): (NonSendMut, Res), raw_handle: Query<&bevy::window::RawHandleWrapper, With>, time: Res