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) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
This commit is contained in:
Jonas Örnfelt
2026-09-29 17:19:26 +07:00
committed by GitHub
co-authored by Claude Opus 5.5 Sam
parent f000aa0128
commit d4c280d2b1
3 changed files with 183 additions and 0 deletions
+180
View File
@@ -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<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.
#[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<bevy::input::keyboard::KeyboardInput>) {
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<KeyCode> {
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<KeyCode> {
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);
}
}
+1
View File
@@ -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;
@@ -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)