Keys are named by the active layout, as 1.12.1 names them (#748)

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) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-01 09:55:26 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e9f0e89a0b
commit be9e3d8aac
14 changed files with 1304 additions and 292 deletions
Generated
+1
View File
@@ -546,6 +546,7 @@ dependencies = [
"toml",
"wgpu",
"windows-sys 0.59.0",
"winit",
]
[[package]]
+6 -1
View File
@@ -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`).
+146 -28
View File
@@ -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 `<Realm>-<Char>.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<Input>,
/// Inputs whose Start, or whose one-shot, came this frame (the press edge).
@@ -89,6 +96,15 @@ pub(crate) struct BindingsState {
down: Vec<KeyCode>,
}
/// 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::<BindingDispatch>()
app.add_plugins(layout::plugin)
.init_resource::<BindingDispatch>()
.init_resource::<BindingsState>()
.init_resource::<BindingFiles>()
.add_systems(
@@ -308,6 +325,8 @@ impl WheelNotches {
fn latch_and_dispatch(
mut script: Option<NonSendMut<UiScript>>,
mut keyboard: MessageReader<KeyboardInput>,
// What the active layout names a letter, digit or punctuation key.
layout: Res<LayoutNames>,
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
scroll: Res<AccumulatedMouseScroll>,
@@ -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<BindKey> = Vec::new();
let mut stuck: Vec<Held> = 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<KeyCode>,
buttons: &ButtonInput<MouseButton>,
) -> 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<NonSendMut<UiScript>>,
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<NonSendMut<UiScript>>, key: BindKey) {
fn release(state: &mut BindingsState, script: &mut Option<NonSendMut<UiScript>>, 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::<PointerOverUiPanel>()
.init_resource::<BindingsState>()
.init_resource::<BindingDispatch>()
.init_resource::<LayoutNames>()
.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::<LayoutNames>()
.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]
+270 -194
View File
@@ -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<KeyName> {
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<KeyName> {
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<BindKey> {
"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<BindKey> {
"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<String> {
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('ù')))
);
}
}
+668
View File
@@ -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<char>),
}
impl Position {
fn us(self) -> Option<char> {
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<Position> {
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<KeyCode, LayoutName>);
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<LayoutName> {
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<LayoutName>) {
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::<LayoutNames>();
// A headless build has no winit to register it.
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
app.add_message::<bevy::winit::RawWinitWindowEvent>()
.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<bevy::winit::RawWinitWindowEvent>,
mut names: ResMut<LayoutNames>,
#[cfg(target_os = "linux")] mut mods: Local<LinuxMods>,
_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<char> {
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<LayoutName> {
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<super::LayoutName> {
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<char>,
ascii_layout_char: impl FnOnce() -> Option<char>,
) -> Option<LayoutName> {
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<char> {
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<Option<LayoutName>> {
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<char> {
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<u32> = (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");
}
}
+4 -2
View File
@@ -340,8 +340,9 @@ fn create_input(
keys: Res<ButtonInput<KeyCode>>,
catalog: Option<Res<CharCreate>>,
mut sel: ResMut<CreateSelection>,
// The host pasteboard and the window handle its Wayland backend needs.
mut clipboard: NonSendMut<HostClipboard>,
// The host pasteboard and the window handle its Wayland backend needs, and the layout that
// names the clipboard chords' letters.
(mut clipboard, layout): (NonSendMut<HostClipboard>, Res<crate::bindings::LayoutNames>),
raw_handle: Query<&bevy::window::RawHandleWrapper, With<bevy::window::PrimaryWindow>>,
time: Res<Time>,
mut preview: ResMut<GluePreview>,
@@ -421,6 +422,7 @@ fn create_input(
&mut sel.name,
ev,
mods,
&layout,
&mut clipboard,
wl,
textinput::CharFilter::Letters,
+3 -1
View File
@@ -92,7 +92,8 @@ pub(super) fn drive_delete_dialog(
Option<&mut Text>,
&mut Visibility,
)>,
mut clipboard: NonSendMut<HostClipboard>,
// The pasteboard, and the layout that names the clipboard chords' letters.
(mut clipboard, layout): (NonSendMut<HostClipboard>, Res<crate::bindings::LayoutNames>),
// The window drives the glue scale; its raw handle carries the Wayland clipboard's display.
window: Query<
(&Window, Option<&bevy::window::RawHandleWrapper>),
@@ -139,6 +140,7 @@ pub(super) fn drive_delete_dialog(
&mut dialog.typed,
ev,
mods,
&layout,
&mut clipboard,
wl,
textinput::CharFilter::Any,
+4 -1
View File
@@ -651,7 +651,8 @@ fn login_input(
clicks: Res<crate::glue::GlueClicks>,
mut keyboard: MessageReader<KeyboardInput>,
keys: Res<ButtonInput<KeyCode>>,
mut clipboard: NonSendMut<HostClipboard>,
// The pasteboard, and the layout that names the clipboard chords' letters.
(mut clipboard, layout): (NonSendMut<HostClipboard>, Res<crate::bindings::LayoutNames>),
raw_handle: Query<&bevy::window::RawHandleWrapper, With<bevy::window::PrimaryWindow>>,
mut form: ResMut<LoginForm>,
mut attempt: Attempt,
@@ -742,6 +743,7 @@ fn login_input(
&mut dialog.edit,
ev,
mods,
&layout,
&mut clipboard,
wl,
textinput::CharFilter::Any,
@@ -755,6 +757,7 @@ fn login_input(
form.focused(),
ev,
mods,
&layout,
&mut clipboard,
wl,
textinput::CharFilter::Any,
+164 -47
View File
@@ -7,6 +7,8 @@
use benilla_ui::script::{EditAction, EditUnit};
use bevy::input::keyboard::KeyCode;
use crate::bindings::chord::KeyName;
/// The modifier snapshot a chord is read against; `sup` is Cmd on macOS, the OS key elsewhere.
#[derive(Clone, Copy, Default)]
pub(crate) struct Mods {
@@ -29,8 +31,24 @@ pub(crate) enum Chord {
Paste,
}
/// What `key` under `m` means; `None` falls through to character input.
pub(crate) fn chord(key: KeyCode, m: Mods, mac: bool) -> Option<Chord> {
/// What `key`, which the layout names `name`, means under `m`; `None` falls through to character
/// input.
pub(crate) fn chord(key: KeyCode, name: Option<KeyName>, m: Mods, mac: bool) -> Option<Chord> {
// Ctrl (Cmd on a Mac) with the keys the layout names A, C, X and V: the reference's handler
// switches on the key's code (`0x77b1fb`), the name its bindings use, so on AZERTY select-all
// is the key labelled A. On the PC `!m.alt` excludes AltGr, which arrives as Ctrl+Alt and types
// letters on European layouts (Polish AltGr+A is `ą`); must agree with the char-input branch
// in `input.rs`.
let held = if mac { m.sup } else { m.ctrl && !m.alt };
if let (true, Some(KeyName::Char(letter))) = (held, name) {
match letter {
'A' => return Some(Chord::Edit(EditAction::SelectAll)),
'C' => return Some(Chord::Copy),
'X' => return Some(Chord::Cut),
'V' => return Some(Chord::Paste),
_ => {}
}
}
if mac {
chord_mac(key, m)
} else {
@@ -94,10 +112,6 @@ fn chord_mac(key: KeyCode, m: Mods) -> Option<Chord> {
del(Char, false)
}
}
KeyCode::KeyA if m.sup => Some(Chord::Edit(EditAction::SelectAll)),
KeyCode::KeyC if m.sup => Some(Chord::Copy),
KeyCode::KeyX if m.sup => Some(Chord::Cut),
KeyCode::KeyV if m.sup => Some(Chord::Paste),
_ => None,
}
}
@@ -152,12 +166,6 @@ fn chord_pc(key: KeyCode, m: Mods) -> Option<Chord> {
// The reference's other CUA mirrors: Ctrl+Insert copies, Shift+Insert pastes.
KeyCode::Insert if m.ctrl => Some(Chord::Copy),
KeyCode::Insert if m.shift => Some(Chord::Paste),
// `!m.alt` excludes AltGr, which arrives as Ctrl+Alt and types letters on European
// layouts (Polish AltGr+A is `ą`); must agree with the char-input branch in `input.rs`.
KeyCode::KeyA if m.ctrl && !m.alt => Some(Chord::Edit(EditAction::SelectAll)),
KeyCode::KeyC if m.ctrl && !m.alt => Some(Chord::Copy),
KeyCode::KeyX if m.ctrl && !m.alt => Some(Chord::Cut),
KeyCode::KeyV if m.ctrl && !m.alt => Some(Chord::Paste),
_ => None,
}
}
@@ -180,6 +188,11 @@ mod tests {
const ALT: Mods = Mods { alt: true, ..NONE };
const SUP: Mods = Mods { sup: true, ..NONE };
/// A key's name on a US layout.
fn us(key: KeyCode) -> Option<KeyName> {
crate::bindings::chord::key_token(key, &Default::default())
}
fn edit(c: Option<Chord>) -> EditAction {
match c {
Some(Chord::Edit(a)) => a,
@@ -192,7 +205,12 @@ mod tests {
use EditAction::*;
use EditUnit::*;
assert_eq!(
edit(chord(KeyCode::ArrowLeft, NONE, true)),
edit(chord(
KeyCode::ArrowLeft,
us(KeyCode::ArrowLeft),
NONE,
true
)),
Move {
unit: Char,
back: true,
@@ -200,7 +218,12 @@ mod tests {
}
);
assert_eq!(
edit(chord(KeyCode::ArrowRight, ALT, true)),
edit(chord(
KeyCode::ArrowRight,
us(KeyCode::ArrowRight),
ALT,
true
)),
Move {
unit: Word,
back: false,
@@ -208,7 +231,7 @@ mod tests {
}
);
assert_eq!(
edit(chord(KeyCode::ArrowLeft, SUP, true)),
edit(chord(KeyCode::ArrowLeft, us(KeyCode::ArrowLeft), SUP, true)),
Move {
unit: Edge,
back: true,
@@ -218,6 +241,7 @@ mod tests {
assert_eq!(
edit(chord(
KeyCode::ArrowRight,
us(KeyCode::ArrowRight),
Mods { shift: true, ..ALT },
true
)),
@@ -227,10 +251,21 @@ mod tests {
extend: true
}
);
assert_eq!(edit(chord(KeyCode::ArrowUp, NONE, true)), HistoryPrev);
assert_eq!(edit(chord(KeyCode::ArrowDown, NONE, true)), HistoryNext);
assert_eq!(
edit(chord(KeyCode::ArrowUp, SHIFT, true)),
edit(chord(KeyCode::ArrowUp, us(KeyCode::ArrowUp), NONE, true)),
HistoryPrev
);
assert_eq!(
edit(chord(
KeyCode::ArrowDown,
us(KeyCode::ArrowDown),
NONE,
true
)),
HistoryNext
);
assert_eq!(
edit(chord(KeyCode::ArrowUp, us(KeyCode::ArrowUp), SHIFT, true)),
Move {
unit: Edge,
back: true,
@@ -238,7 +273,7 @@ mod tests {
}
);
assert_eq!(
edit(chord(KeyCode::ArrowDown, SUP, true)),
edit(chord(KeyCode::ArrowDown, us(KeyCode::ArrowDown), SUP, true)),
Move {
unit: Edge,
back: false,
@@ -246,32 +281,44 @@ mod tests {
}
);
assert_eq!(
edit(chord(KeyCode::Backspace, SUP, true)),
edit(chord(KeyCode::Backspace, us(KeyCode::Backspace), SUP, true)),
Delete {
unit: Edge,
back: true
}
);
assert_eq!(
edit(chord(KeyCode::Backspace, ALT, true)),
edit(chord(KeyCode::Backspace, us(KeyCode::Backspace), ALT, true)),
Delete {
unit: Word,
back: true
}
);
assert_eq!(
edit(chord(KeyCode::Delete, ALT, true)),
edit(chord(KeyCode::Delete, us(KeyCode::Delete), ALT, true)),
Delete {
unit: Word,
back: false
}
);
assert_eq!(edit(chord(KeyCode::KeyA, SUP, true)), SelectAll);
assert_eq!(chord(KeyCode::KeyC, SUP, true), Some(Chord::Copy));
assert_eq!(chord(KeyCode::KeyX, SUP, true), Some(Chord::Cut));
assert_eq!(chord(KeyCode::KeyV, SUP, true), Some(Chord::Paste));
assert_eq!(chord(KeyCode::KeyA, CTRL, true), None);
assert_eq!(chord(KeyCode::KeyA, NONE, true), None);
assert_eq!(
edit(chord(KeyCode::KeyA, us(KeyCode::KeyA), SUP, true)),
SelectAll
);
assert_eq!(
chord(KeyCode::KeyC, us(KeyCode::KeyC), SUP, true),
Some(Chord::Copy)
);
assert_eq!(
chord(KeyCode::KeyX, us(KeyCode::KeyX), SUP, true),
Some(Chord::Cut)
);
assert_eq!(
chord(KeyCode::KeyV, us(KeyCode::KeyV), SUP, true),
Some(Chord::Paste)
);
assert_eq!(chord(KeyCode::KeyA, us(KeyCode::KeyA), CTRL, true), None);
assert_eq!(chord(KeyCode::KeyA, us(KeyCode::KeyA), NONE, true), None);
}
#[test]
@@ -279,7 +326,12 @@ mod tests {
use EditAction::*;
use EditUnit::*;
assert_eq!(
edit(chord(KeyCode::ArrowLeft, CTRL, false)),
edit(chord(
KeyCode::ArrowLeft,
us(KeyCode::ArrowLeft),
CTRL,
false
)),
Move {
unit: Word,
back: true,
@@ -287,17 +339,28 @@ mod tests {
}
);
assert_eq!(
edit(chord(KeyCode::ArrowRight, SHIFT, false)),
edit(chord(
KeyCode::ArrowRight,
us(KeyCode::ArrowRight),
SHIFT,
false
)),
Move {
unit: Char,
back: false,
extend: true
}
);
assert_eq!(edit(chord(KeyCode::ArrowUp, NONE, false)), HistoryPrev);
assert_eq!(chord(KeyCode::ArrowUp, SHIFT, false), None);
assert_eq!(
edit(chord(KeyCode::End, SHIFT, false)),
edit(chord(KeyCode::ArrowUp, us(KeyCode::ArrowUp), NONE, false)),
HistoryPrev
);
assert_eq!(
chord(KeyCode::ArrowUp, us(KeyCode::ArrowUp), SHIFT, false),
None
);
assert_eq!(
edit(chord(KeyCode::End, us(KeyCode::End), SHIFT, false)),
Move {
unit: Edge,
back: false,
@@ -305,26 +368,49 @@ mod tests {
}
);
assert_eq!(
edit(chord(KeyCode::Backspace, CTRL, false)),
edit(chord(
KeyCode::Backspace,
us(KeyCode::Backspace),
CTRL,
false
)),
Delete {
unit: Word,
back: true
}
);
assert_eq!(
edit(chord(KeyCode::Delete, CTRL, false)),
edit(chord(KeyCode::Delete, us(KeyCode::Delete), CTRL, false)),
Delete {
unit: Word,
back: false
}
);
assert_eq!(edit(chord(KeyCode::KeyA, CTRL, false)), SelectAll);
assert_eq!(chord(KeyCode::KeyC, CTRL, false), Some(Chord::Copy));
assert_eq!(chord(KeyCode::KeyV, CTRL, false), Some(Chord::Paste));
assert_eq!(chord(KeyCode::Insert, CTRL, false), Some(Chord::Copy));
assert_eq!(chord(KeyCode::Insert, SHIFT, false), Some(Chord::Paste));
assert_eq!(chord(KeyCode::Delete, SHIFT, false), Some(Chord::Cut));
assert_eq!(chord(KeyCode::KeyA, SUP, false), None);
assert_eq!(
edit(chord(KeyCode::KeyA, us(KeyCode::KeyA), CTRL, false)),
SelectAll
);
assert_eq!(
chord(KeyCode::KeyC, us(KeyCode::KeyC), CTRL, false),
Some(Chord::Copy)
);
assert_eq!(
chord(KeyCode::KeyV, us(KeyCode::KeyV), CTRL, false),
Some(Chord::Paste)
);
assert_eq!(
chord(KeyCode::Insert, us(KeyCode::Insert), CTRL, false),
Some(Chord::Copy)
);
assert_eq!(
chord(KeyCode::Insert, us(KeyCode::Insert), SHIFT, false),
Some(Chord::Paste)
);
assert_eq!(
chord(KeyCode::Delete, us(KeyCode::Delete), SHIFT, false),
Some(Chord::Cut)
);
assert_eq!(chord(KeyCode::KeyA, us(KeyCode::KeyA), SUP, false), None);
}
/// AltGr arrives as Ctrl+Alt and types letters on European layouts (Polish `ą`, `ć`, `ź`).
@@ -337,17 +423,48 @@ mod tests {
};
for key in [KeyCode::KeyA, KeyCode::KeyC, KeyCode::KeyX, KeyCode::KeyV] {
assert_eq!(
chord(key, ALTGR, false),
chord(key, us(key), ALTGR, false),
None,
"AltGr+{key:?} must reach character input, not act as a clipboard chord"
);
}
assert_eq!(
edit(chord(KeyCode::KeyA, CTRL, false)),
edit(chord(KeyCode::KeyA, us(KeyCode::KeyA), CTRL, false)),
EditAction::SelectAll
);
assert_eq!(chord(KeyCode::KeyC, CTRL, false), Some(Chord::Copy));
assert_eq!(chord(KeyCode::KeyX, CTRL, false), Some(Chord::Cut));
assert_eq!(chord(KeyCode::KeyV, CTRL, false), Some(Chord::Paste));
assert_eq!(
chord(KeyCode::KeyC, us(KeyCode::KeyC), CTRL, false),
Some(Chord::Copy)
);
assert_eq!(
chord(KeyCode::KeyX, us(KeyCode::KeyX), CTRL, false),
Some(Chord::Cut)
);
assert_eq!(
chord(KeyCode::KeyV, us(KeyCode::KeyV), CTRL, false),
Some(Chord::Paste)
);
}
/// Select-all, copy, cut and paste follow the layout's names, as the reference's handler
/// switches on the key's code (`0x77b1fb`): on AZERTY they sit on the keys labelled A, C, X
/// and V, wherever those are.
#[test]
fn the_clipboard_chords_sit_on_the_keys_the_layout_names() {
let azerty_a = Some(KeyName::Char('A'));
let azerty_q = Some(KeyName::Char('Q'));
for mac in [false, true] {
let held = if mac { SUP } else { CTRL };
assert_eq!(
edit(chord(KeyCode::KeyQ, azerty_a, held, mac)),
EditAction::SelectAll,
"the key labelled A (mac: {mac})"
);
assert_eq!(
chord(KeyCode::KeyA, azerty_q, held, mac),
None,
"the key labelled Q (mac: {mac})"
);
}
}
}
+5 -2
View File
@@ -74,11 +74,13 @@ pub(crate) enum FieldKey {
Passthrough,
}
/// Feed one key press to `field`. `wl_display` comes from [`wayland_display`], `None` off Wayland.
/// Feed one key press to `field`, its letters named by `layout`. `wl_display` comes from
/// [`wayland_display`], `None` off Wayland.
pub(crate) fn feed_key(
field: &mut EditBoxState,
ev: &KeyboardInput,
mods: Mods,
layout: &crate::bindings::LayoutNames,
clipboard: &mut HostClipboard,
wl_display: Option<*mut c_void>,
filter: CharFilter,
@@ -93,7 +95,8 @@ pub(crate) fn feed_key(
) {
return FieldKey::Passthrough;
}
if let Some(chord) = chord(ev.key_code, mods, cfg!(target_os = "macos")) {
let name = crate::bindings::chord::key_token(ev.key_code, layout);
if let Some(chord) = chord(ev.key_code, name, mods, cfg!(target_os = "macos")) {
match chord {
Chord::Edit(action) => {
field.apply(action);
@@ -97,8 +97,8 @@ fn the_host_has_a_reference_name_for_the_keys_it_now_delivers() {
(KeyCode::F1, "F1"),
] {
assert_eq!(
key_token(code),
Some(name),
key_token(code, &Default::default()).map(|n| n.to_string()),
Some(name.to_string()),
"{code:?} must reach a keyboard frame under the reference's own name"
);
}
+11 -5
View File
@@ -92,6 +92,8 @@ pub(super) fn feed_ui_input(
ResMut<UiKeyboardCapture>,
NonSendMut<HostClipboard>,
),
// The characters the active layout makes, which name a key for a keyboard frame.
layout: Res<crate::bindings::LayoutNames>,
// The uiScale dial folded into the seam scale.
ui_scale: Res<super::UiScaleCvar>,
) {
@@ -232,8 +234,11 @@ pub(super) fn feed_ui_input(
KeyCode::Tab => Some("TAB"),
_ => None,
};
// 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.
let chord = keymap::chord(ev.key_code, mods, mac);
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`).
@@ -258,14 +263,15 @@ pub(super) fn feed_ui_input(
}
// 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`
// here, and the gate is existence, not handling: a shown keyboard frame with an
// `OnKeyDown` swallows the key whatever its script does (`0x76b7d0`, `0x76ba25`).
// 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) = crate::bindings::chord::key_token(ev.key_code) {
if script.frame_key_input(token) {
if let Some(token) = token {
if script.frame_key_input(&token.to_string()) {
capture.consumed.push(ev.key_code);
}
}
+14 -5
View File
@@ -20,8 +20,8 @@ enum Verdict {
}
use Verdict::{Open, Plumbing, Swallowed};
/// Every `bevy::input::…` / `bevy::window::…` leaf the workspace names, plus the prelude input
/// items, each with its verdict.
/// Every `bevy::input::…` / `bevy::window::…` / `bevy::winit::…` leaf the workspace names, plus
/// the prelude input items, each with its verdict.
const VERDICTS: &[(&str, Verdict)] = &[
// ── The channels the cover takes ────────────────────────────────────────────────────────
("ButtonInput", Swallowed),
@@ -59,6 +59,14 @@ const VERDICTS: &[(&str, Verdict)] = &[
"CursorOptions",
Open("an OUTPUT (grab/visible), not a channel — the cover has nothing to take from it"),
),
(
"RawWinitWindowEvent",
Open(
"read only to learn what the layout names each key (`bindings/layout.rs`), which acts \
on nothing: a press under the cover still never reaches a binding or a box, and the \
name it refreshes must be current when the cover lifts",
),
),
(
"CursorLeft",
Open(
@@ -76,6 +84,7 @@ const VERDICTS: &[(&str, Verdict)] = &[
("CustomCursor", Plumbing),
("CustomCursorImage", Plumbing),
("InputPlugin", Plumbing),
("WinitPlugin", Plumbing),
("InputSystems", Plumbing),
("Key", Plumbing),
("KeyCode", Plumbing),
@@ -174,11 +183,11 @@ fn every_input_channel_the_client_reads_has_a_verdict_under_the_cover() {
);
}
/// Every input identifier `text` names: the leaf of any `bevy::input::…`/`bevy::window::…` path
/// (grouped `use` braces expanded), plus the prelude names.
/// Every input identifier `text` names: the leaf of any `bevy::input::…`/`bevy::window::…`/
/// `bevy::winit::…` path (grouped `use` braces expanded), plus the prelude names.
fn input_names(text: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for prefix in ["bevy::input::", "bevy::window::"] {
for prefix in ["bevy::input::", "bevy::window::", "bevy::winit::"] {
let mut rest = text;
while let Some(at) = rest.find(prefix) {
rest = &rest[at + prefix.len()..];
+6 -4
View File
@@ -10,8 +10,10 @@
//! takes that named key's code, before Bevy's input collection, so every reader (the bindings,
//! the text boxes, the glue screens, `ButtonInput<KeyCode>`) sees the layout's key. The Mac client
//! reads its non-character keys off a fixed table on the physical keycode (`0x5bf320`), so macOS
//! keeps them physical. Character keys stay physical, and so do the numpad keys, whose Num Lock
//! navigation meanings are not remaps.
//! keeps them physical. A letter, digit or punctuation key keeps its code too: 1.12 names it by
//! the virtual key the layout makes it, a digit as itself (`0x42d81c`) and a letter or punctuation
//! key through `MapVirtualKeyA` (`0x42da39`), which the game's key namer reads, not by a named
//! key. So do the numpad keys, whose Num Lock navigation meanings are not remaps.
#[cfg(any(not(target_os = "macos"), test))]
use bevy::input::keyboard::{Key, KeyCode};
@@ -165,8 +167,8 @@ mod tests {
}
#[test]
fn characters_and_the_numpad_stay_physical() {
// An azerty A: the key where a qwerty Q sits.
fn characters_and_the_numpad_keep_their_codes() {
// An azerty A, the key where a qwerty Q sits: named by its character, never moved.
assert_eq!(
layout_code(KeyCode::KeyQ, &Key::Character("a".into())),
None