From d4c280d2b10d471c53a03a1f7bce238680e8ed17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20=C3=96rnfelt?= <49749070+ornfelt@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:19:26 +0200 Subject: [PATCH] Caps Lock remapped to Escape (X11 caps:escape) acts as Escape on Linux, as in the real client (#602) - On Linux a key the keyboard layout remaps (X11's caps:escape) acts as the key it is remapped to, as in the real client - A layout-moved named key also acts as that key on Windows, where the reference reads the layout's virtual key; macOS keeps its physical table Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Sam --- crates/benilla-world/src/layout_keys.rs | 180 ++++++++++++++++++++++ crates/benilla-world/src/lib.rs | 1 + crates/benilla-world/src/world_plugins.rs | 2 + 3 files changed, 183 insertions(+) create mode 100644 crates/benilla-world/src/layout_keys.rs diff --git a/crates/benilla-world/src/layout_keys.rs b/crates/benilla-world/src/layout_keys.rs new file mode 100644 index 00000000..5af12378 --- /dev/null +++ b/crates/benilla-world/src/layout_keys.rs @@ -0,0 +1,180 @@ +//! The keyboard layout's named keys. The Windows client reads a key as the virtual-key code the +//! OS layout produced from the scancode (the WndProc's key arm `0x42d21e` hands `wParam` to the +//! translator `0x42d800`, which never reads the scancode), so a layout that gives a key another +//! named function (X11's `caps:escape`, `caps:swapescape`, `ctrl:nocaps`, Colemak's Caps Lock as +//! Backspace) moves that function in 1.12; under Wine the same keysym becomes the same virtual +//! key. Bevy's `KeyCode` is the physical key, which such a layout does not move: Caps Lock mapped +//! to Escape arrives as `CapsLock` with the logical key `Escape`. +//! +//! Off macOS, each keyboard message whose logical key is a named key other than its physical one +//! takes that named key's code, before Bevy's input collection, so every reader (the bindings, +//! the text boxes, the glue screens, `ButtonInput`) 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. + +#[cfg(any(not(target_os = "macos"), test))] +use bevy::input::keyboard::{Key, KeyCode}; +use bevy::prelude::*; + +/// Rewrites remapped named keys before Bevy's input collection (all but macOS). +pub struct LayoutKeysPlugin; + +impl Plugin for LayoutKeysPlugin { + #[cfg(not(target_os = "macos"))] + fn build(&self, app: &mut App) { + app.add_systems(PreUpdate, remap.before(bevy::input::InputSystems)); + } + + #[cfg(target_os = "macos")] + fn build(&self, _app: &mut App) {} +} + +/// Gives each new keyboard message the code of the named key its layout made it. +#[cfg(not(target_os = "macos"))] +fn remap(mut keys: bevy::ecs::message::MessageMutator) { + for key in keys.read() { + if let Some(code) = layout_code(key.key_code, &key.logical_key) { + key.key_code = code; + } + } +} + +/// The code of the named key `logical` stands for when a layout put it on the physical key +/// `physical`, or `None` when the key keeps its code: a character, a numpad key, a named key with +/// no code, or the key already there (either side, for a modifier). +#[cfg(any(not(target_os = "macos"), test))] +fn layout_code(physical: KeyCode, logical: &Key) -> Option { + use KeyCode as C; + if is_numpad(physical) { + return None; + } + let (code, same_family) = match logical { + Key::Shift => (C::ShiftLeft, [C::ShiftLeft, C::ShiftRight]), + Key::Control => (C::ControlLeft, [C::ControlLeft, C::ControlRight]), + Key::Alt => (C::AltLeft, [C::AltLeft, C::AltRight]), + Key::Super => (C::SuperLeft, [C::SuperLeft, C::SuperRight]), + _ => { + let code = named_code(logical)?; + (code, [code, code]) + } + }; + (!same_family.contains(&physical)).then_some(code) +} + +/// The physical key a non-modifier named key sits on in a plain layout. +#[cfg(any(not(target_os = "macos"), test))] +fn named_code(logical: &Key) -> Option { + use KeyCode as C; + Some(match logical { + Key::Escape => C::Escape, + Key::Tab => C::Tab, + Key::Enter => C::Enter, + Key::Space => C::Space, + Key::Backspace => C::Backspace, + Key::Delete => C::Delete, + Key::Insert => C::Insert, + Key::Home => C::Home, + Key::End => C::End, + Key::PageUp => C::PageUp, + Key::PageDown => C::PageDown, + Key::ArrowUp => C::ArrowUp, + Key::ArrowDown => C::ArrowDown, + Key::ArrowLeft => C::ArrowLeft, + Key::ArrowRight => C::ArrowRight, + Key::CapsLock => C::CapsLock, + Key::NumLock => C::NumLock, + Key::ScrollLock => C::ScrollLock, + Key::Pause => C::Pause, + Key::PrintScreen => C::PrintScreen, + Key::ContextMenu => C::ContextMenu, + Key::F1 => C::F1, + Key::F2 => C::F2, + Key::F3 => C::F3, + Key::F4 => C::F4, + Key::F5 => C::F5, + Key::F6 => C::F6, + Key::F7 => C::F7, + Key::F8 => C::F8, + Key::F9 => C::F9, + Key::F10 => C::F10, + Key::F11 => C::F11, + Key::F12 => C::F12, + _ => return None, + }) +} + +#[cfg(any(not(target_os = "macos"), test))] +fn is_numpad(code: KeyCode) -> bool { + use KeyCode as C; + matches!( + code, + C::Numpad0 + | C::Numpad1 + | C::Numpad2 + | C::Numpad3 + | C::Numpad4 + | C::Numpad5 + | C::Numpad6 + | C::Numpad7 + | C::Numpad8 + | C::Numpad9 + | C::NumpadAdd + | C::NumpadSubtract + | C::NumpadMultiply + | C::NumpadDivide + | C::NumpadDecimal + | C::NumpadComma + | C::NumpadEnter + | C::NumpadEqual + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn caps_mapped_to_escape_is_escape() { + assert_eq!( + layout_code(KeyCode::CapsLock, &Key::Escape), + Some(KeyCode::Escape) + ); + // `caps:swapescape`: the other half of the swap. + assert_eq!( + layout_code(KeyCode::Escape, &Key::CapsLock), + Some(KeyCode::CapsLock) + ); + } + + #[test] + fn a_key_where_the_layout_leaves_it_keeps_its_code() { + assert_eq!(layout_code(KeyCode::Escape, &Key::Escape), None); + assert_eq!(layout_code(KeyCode::F5, &Key::F5), None); + assert_eq!(layout_code(KeyCode::ShiftRight, &Key::Shift), None); + assert_eq!(layout_code(KeyCode::ControlRight, &Key::Control), None); + } + + #[test] + fn a_modifier_moved_by_the_layout_is_that_modifier() { + // `ctrl:nocaps`: Caps Lock is a Control key. + assert_eq!( + layout_code(KeyCode::CapsLock, &Key::Control), + Some(KeyCode::ControlLeft) + ); + } + + #[test] + fn characters_and_the_numpad_stay_physical() { + // An azerty A: the key where a qwerty Q sits. + assert_eq!( + layout_code(KeyCode::KeyQ, &Key::Character("a".into())), + None + ); + // Num Lock off: the numpad 7 means Home, and stays NUMPAD7. + assert_eq!(layout_code(KeyCode::Numpad7, &Key::Home), None); + assert_eq!(layout_code(KeyCode::NumpadEnter, &Key::Enter), None); + // AltGr has no code of its own. + assert_eq!(layout_code(KeyCode::AltRight, &Key::AltGraph), None); + } +} diff --git a/crates/benilla-world/src/lib.rs b/crates/benilla-world/src/lib.rs index 02aef8e8..e007f2ce 100644 --- a/crates/benilla-world/src/lib.rs +++ b/crates/benilla-world/src/lib.rs @@ -122,6 +122,7 @@ pub mod ground_fx; pub mod instance_tint; pub mod interact; pub mod interior; +pub mod layout_keys; pub mod lighting; pub mod liquid; pub mod log_ring; diff --git a/crates/benilla-world/src/world_plugins.rs b/crates/benilla-world/src/world_plugins.rs index 05a1fb5a..2ad9a86e 100644 --- a/crates/benilla-world/src/world_plugins.rs +++ b/crates/benilla-world/src/world_plugins.rs @@ -114,6 +114,8 @@ impl PluginGroup for WorldPlugins { // Stuck-modifier reconciliation: macOS system shortcuts (⇧⌘5) swallow modifier // releases without a focus loss, wedging every bare-key binding. .add(crate::modkeys::ModKeysPlugin) + // Off macOS: a named key the keyboard layout moved (`caps:escape`) acts as that key. + .add(crate::layout_keys::LayoutKeysPlugin) // Terrain streaming (`AdtTile` through the `AssetServer`): tiles with their doodads, // WMOs, liquid and clutter. .add(crate::terrain_stream::TerrainPlugin)