From 0eb4603dc3d9f9941121720defaaee70c930d9d8 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Mon, 8 Jun 2026 18:53:28 +0300 Subject: [PATCH 1/3] chore(deps): add xkbcommon dependency for keyboard layout support --- Cargo.lock | 27 +++++++++++++++++++++++++++ Cargo.toml | 7 ++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b2a3da5..3cb09ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -264,6 +264,15 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "nix" version = "0.31.2" @@ -587,6 +596,7 @@ dependencies = [ "wayland-client", "wayland-protocols-wlr", "x11rb", + "xkbcommon", ] [[package]] @@ -930,6 +940,23 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xkbcommon" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" +dependencies = [ + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index b1b423a..fdeba83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,8 +13,8 @@ exclude = ["example.gif", "target/*", "nix/*", "flake.*", "shell.nix", "install. [features] default = ["wayland", "x11"] -wayland = ["dep:wayland-client", "dep:wayland-protocols-wlr", "dep:tempfile"] -x11 = ["dep:x11rb"] +wayland = ["dep:wayland-client", "dep:wayland-protocols-wlr", "dep:tempfile", "dep:xkbcommon"] +x11 = ["dep:x11rb", "dep:xkbcommon"] [dependencies] anyhow = "1.0" @@ -29,7 +29,8 @@ nix = { version = "0.31", features = ["fs", "poll"] } wayland-client = { version = "0.31", optional = true } wayland-protocols-wlr = { version = "0.3", features = ["client"], optional = true } tempfile = { version = "3", optional = true } -x11rb = { version = "0.13", features = ["xtest"], optional = true } +x11rb = { version = "0.13", features = ["xkb", "xtest"], optional = true } +xkbcommon = { version = "0.8", optional = true } [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" From 4f80cd832832d22a2810c6bb6ce60f95f0cdf22f Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Mon, 8 Jun 2026 18:54:19 +0300 Subject: [PATCH 2/3] fix(wl): route keyboard input through xkbcommon on wayland --- src/backend/wayland.rs | 213 ++++++++++++++--------------------------- 1 file changed, 74 insertions(+), 139 deletions(-) diff --git a/src/backend/wayland.rs b/src/backend/wayland.rs index 542d937..cbd27d1 100644 --- a/src/backend/wayland.rs +++ b/src/backend/wayland.rs @@ -2,6 +2,8 @@ use std::io::Write; use std::os::fd::AsFd; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use xkbcommon::xkb; + use nix::poll::{poll, PollFd, PollFlags, PollTimeout}; use wayland_client::protocol::wl_pointer::{Axis, AxisSource}; @@ -69,7 +71,8 @@ impl WaylandBackend { screen_h: 0, configured: false, pending_key: None, - shift_held: false, + xkb_context: xkb::Context::new(xkb::CONTEXT_NO_FLAGS), + xkb_state: None, current_output: None, held: None, pointer_pos: None, @@ -468,7 +471,8 @@ struct WaylandState { screen_h: u32, configured: bool, pending_key: Option, - shift_held: bool, + xkb_context: xkb::Context, + xkb_state: Option, current_output: Option, held: Option, pointer_pos: Option<(u32, u32)>, @@ -485,6 +489,23 @@ impl WaylandState { ) } + /// build an xkb_state from the compositor's keymap so character keys + /// can resolve. + fn load_keymap(&mut self, fd: std::os::fd::OwnedFd, size: u32) { + let result = unsafe { + xkb::Keymap::new_from_fd( + &self.xkb_context, + fd, + size as usize, + xkb::KEYMAP_FORMAT_TEXT_V1, + xkb::KEYMAP_COMPILE_NO_FLAGS, + ) + }; + if let Ok(Some(km)) = result { + self.xkb_state = Some(xkb::State::new(&km)); + } + } + /// If a key is held and its next repeat time has arrived, advance the /// schedule and return the event that should be dispatched. fn pop_repeat(&mut self) -> Option { @@ -640,36 +661,50 @@ impl Dispatch for WaylandState { _: &QueueHandle, ) { match event { + wl_keyboard::Event::Keymap { + format: WEnum::Value(format), + fd, + size, + } => { + if format == wl_keyboard::KeymapFormat::XkbV1 { + state.load_keymap(fd, size); + } + } + wl_keyboard::Event::Modifiers { + mods_depressed, + mods_latched, + mods_locked, + group, + .. + } => { + if let Some(xkb_state) = state.xkb_state.as_mut() { + xkb_state.update_mask(mods_depressed, mods_latched, mods_locked, 0, 0, group); + } + } wl_keyboard::Event::Key { key, state: WEnum::Value(key_state), .. } => match key_state { - wl_keyboard::KeyState::Pressed => match key { - 42 | 54 => state.shift_held = true, - _ => { - let decoded = keycode_to_key(key, state.shift_held).and_then(|k| { - config().keys.to_event(k).or(match k { - Key::Char(c) => Some(KeyEvent::Char(c)), - _ => None, - }) - }); - state.pending_key = decoded; - state.held = decoded.map(|event| HeldKey { - keycode: key, - event, - next_repeat_at: Instant::now() + Duration::from_millis(REPEAT_DELAY_MS), - }); - } - }, - wl_keyboard::KeyState::Released => match key { - 42 | 54 => state.shift_held = false, - _ => { - if matches!(state.held, Some(h) if h.keycode == key) { - state.held = None; - } + wl_keyboard::KeyState::Pressed => { + let decoded = keycode_to_key(key, state.xkb_state.as_ref()).and_then(|k| { + config().keys.to_event(k).or(match k { + Key::Char(c) => Some(KeyEvent::Char(c)), + _ => None, + }) + }); + state.pending_key = decoded; + state.held = decoded.map(|event| HeldKey { + keycode: key, + event, + next_repeat_at: Instant::now() + Duration::from_millis(REPEAT_DELAY_MS), + }); + } + wl_keyboard::KeyState::Released => { + if matches!(state.held, Some(h) if h.keycode == key) { + state.held = None; } - }, + } _ => {} }, // Focus loss: drop any held key so repeats don't fire in the background. @@ -769,9 +804,12 @@ fn timestamp() -> u32 { .as_millis() as u32 } -/// Maps a Wayland key code to a platform-agnostic Key. -fn keycode_to_key(kc: u32, shift_held: bool) -> Option { - // Special (non-character) keys — checked first, unaffected by shift +/// map an evdev keycode to a platform-agnostic Key. +/// +/// look up special keycodes directly since they're layout-invariant. route +/// character keys through xkb_state so they respect the user's layout and +/// modifiers. +pub(super) fn keycode_to_key(kc: u32, xkb_state: Option<&xkb::State>) -> Option { match kc { 1 => return Some(Key::Escape), 14 => return Some(Key::Backspace), @@ -838,115 +876,12 @@ fn keycode_to_key(kc: u32, shift_held: bool) -> Option { _ => {} } - // Character keys — shift changes the produced character - let ch = if shift_held { - match kc { - // Shifted digits → symbols - 2 => '!', - 3 => '@', - 4 => '#', - 5 => '$', - 6 => '%', - 7 => '^', - 8 => '&', - 9 => '*', - 10 => '(', - 11 => ')', - // Shifted punctuation - 12 => '_', - 13 => '+', - 26 => '{', - 27 => '}', - 43 => '|', - 39 => ':', - 40 => '"', - 51 => '<', - 52 => '>', - 53 => '?', - 41 => '~', - // Shifted letters → uppercase - 16 => 'Q', - 17 => 'W', - 18 => 'E', - 19 => 'R', - 20 => 'T', - 21 => 'Y', - 22 => 'U', - 23 => 'I', - 24 => 'O', - 25 => 'P', - 30 => 'A', - 31 => 'S', - 32 => 'D', - 33 => 'F', - 34 => 'G', - 35 => 'H', - 36 => 'J', - 37 => 'K', - 38 => 'L', - 44 => 'Z', - 45 => 'X', - 46 => 'C', - 47 => 'V', - 48 => 'B', - 49 => 'N', - 50 => 'M', - _ => return None, - } - } else { - match kc { - // Digits - 2 => '1', - 3 => '2', - 4 => '3', - 5 => '4', - 6 => '5', - 7 => '6', - 8 => '7', - 9 => '8', - 10 => '9', - 11 => '0', - // Punctuation - 12 => '-', - 13 => '=', - 26 => '[', - 27 => ']', - 43 => '\\', - 39 => ';', - 40 => '\'', - 51 => ',', - 52 => '.', - 53 => '/', - 41 => '`', - // Letters - 16 => 'q', - 17 => 'w', - 18 => 'e', - 19 => 'r', - 20 => 't', - 21 => 'y', - 22 => 'u', - 23 => 'i', - 24 => 'o', - 25 => 'p', - 30 => 'a', - 31 => 's', - 32 => 'd', - 33 => 'f', - 34 => 'g', - 35 => 'h', - 36 => 'j', - 37 => 'k', - 38 => 'l', - 44 => 'z', - 45 => 'x', - 46 => 'c', - 47 => 'v', - 48 => 'b', - 49 => 'n', - 50 => 'm', - _ => return None, - } - }; + // add 8 to convert evdev to XKB numbering. + let state = xkb_state?; + let utf32 = state.key_get_utf32(xkb::Keycode::new(kc + 8)); + let ch = char::from_u32(utf32)?; + if ch.is_control() { + return None; + } Some(Key::Char(ch)) } From 509ab5b204b153d9c406661c06e4e2a3c649250d Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Mon, 8 Jun 2026 18:55:11 +0300 Subject: [PATCH 3/3] fix(x11): route keyboard input through xkbcommon on x11 --- src/backend/x11.rs | 220 +++++++++++++++++++++------------------------ 1 file changed, 100 insertions(+), 120 deletions(-) diff --git a/src/backend/x11.rs b/src/backend/x11.rs index 1aa12c2..fabfea9 100644 --- a/src/backend/x11.rs +++ b/src/backend/x11.rs @@ -2,12 +2,15 @@ use std::time::Duration; use anyhow::{Context, Result}; use x11rb::connection::{Connection, RequestConnection}; +use x11rb::protocol::xkb as x11_xkb; +use x11rb::protocol::xkb::ConnectionExt as _; use x11rb::protocol::xproto::*; use x11rb::protocol::xtest::{self, ConnectionExt as _}; use x11rb::protocol::Event; use x11rb::rust_connection::RustConnection; use x11rb::wrapper::ConnectionExt as _; use x11rb::CURRENT_TIME; +use xkbcommon::xkb; use super::{Backend, KeyEvent}; use crate::config::{config, Key}; @@ -19,6 +22,7 @@ const BTN_SCROLL_UP: u8 = 4; const BTN_SCROLL_DOWN: u8 = 5; const BTN_SCROLL_LEFT: u8 = 6; const BTN_SCROLL_RIGHT: u8 = 7; +const XKB_USE_CORE_KBD: x11_xkb::DeviceSpec = 256; pub struct X11Backend { conn: RustConnection, @@ -29,7 +33,7 @@ pub struct X11Backend { screen_h: u32, depth: u8, mapped: bool, - shift_held: bool, + xkb_state: Option, /// Screenshot of the desktop captured before mapping the overlay. /// Used to alpha-blend the overlay on top (X11 has no compositor). background: Vec, @@ -97,6 +101,8 @@ impl X11Backend { // we first try (e.g. from the shortcut key-release event). grab_keyboard_with_retry(&conn, window).context("grab keyboard")?; + let xkb_state = load_xkb_state(&conn, root); + Ok(X11Backend { conn, window, @@ -106,7 +112,7 @@ impl X11Backend { screen_h, depth, mapped: true, - shift_held: false, + xkb_state, background, }) } @@ -338,16 +344,15 @@ impl Backend for X11Backend { let event = self.conn.wait_for_event().context("wait for event")?; match event { Event::KeyPress(ev) => { - let keycode = ev.detail; - // Shift keys (left=50, right=62 in X11 keycodes) - if keycode == 50 || keycode == 62 { - self.shift_held = true; - continue; + let xkb_kc = xkb::Keycode::new(ev.detail as u32); + // subtract 8 to convert X11 keycodes to evdev. + let evdev_kc = (ev.detail as u32).wrapping_sub(8); + if let Some(state) = self.xkb_state.as_mut() { + sync_xkb_state(&self.conn, state); + state.update_key(xkb_kc, xkb::KeyDirection::Down); } - // X11 keycodes are evdev + 8 - let evdev_kc = (keycode as u32).wrapping_sub(8); - if let Some(key_event) = - keycode_to_key(evdev_kc, self.shift_held).and_then(|k| { + if let Some(key_event) = keycode_to_key(evdev_kc, self.xkb_state.as_ref()) + .and_then(|k| { config().keys.to_event(k).or(match k { Key::Char(c) => Some(KeyEvent::Char(c)), _ => None, @@ -358,9 +363,9 @@ impl Backend for X11Backend { } } Event::KeyRelease(ev) => { - let keycode = ev.detail; - if keycode == 50 || keycode == 62 { - self.shift_held = false; + if let Some(state) = self.xkb_state.as_mut() { + state + .update_key(xkb::Keycode::new(ev.detail as u32), xkb::KeyDirection::Up); } } _ => {} @@ -462,9 +467,12 @@ fn capture_root(conn: &RustConnection, root: Window, w: u32, h: u32) -> Result Option { +/// map an evdev keycode to a platform-agnostic Key. +/// +/// look up special keycodes directly since they're layout-invariant. route +/// character keys through xkb_state so they respect the user's layout and +/// modifiers. +fn keycode_to_key(kc: u32, xkb_state: Option<&xkb::State>) -> Option { match kc { 1 => return Some(Key::Escape), 14 => return Some(Key::Backspace), @@ -525,108 +533,80 @@ fn keycode_to_key(kc: u32, shift_held: bool) -> Option { _ => {} } - let ch = if shift_held { - match kc { - 2 => '!', - 3 => '@', - 4 => '#', - 5 => '$', - 6 => '%', - 7 => '^', - 8 => '&', - 9 => '*', - 10 => '(', - 11 => ')', - 12 => '_', - 13 => '+', - 26 => '{', - 27 => '}', - 43 => '|', - 39 => ':', - 40 => '"', - 51 => '<', - 52 => '>', - 53 => '?', - 41 => '~', - 16 => 'Q', - 17 => 'W', - 18 => 'E', - 19 => 'R', - 20 => 'T', - 21 => 'Y', - 22 => 'U', - 23 => 'I', - 24 => 'O', - 25 => 'P', - 30 => 'A', - 31 => 'S', - 32 => 'D', - 33 => 'F', - 34 => 'G', - 35 => 'H', - 36 => 'J', - 37 => 'K', - 38 => 'L', - 44 => 'Z', - 45 => 'X', - 46 => 'C', - 47 => 'V', - 48 => 'B', - 49 => 'N', - 50 => 'M', - _ => return None, - } - } else { - match kc { - 2 => '1', - 3 => '2', - 4 => '3', - 5 => '4', - 6 => '5', - 7 => '6', - 8 => '7', - 9 => '8', - 10 => '9', - 11 => '0', - 12 => '-', - 13 => '=', - 26 => '[', - 27 => ']', - 43 => '\\', - 39 => ';', - 40 => '\'', - 51 => ',', - 52 => '.', - 53 => '/', - 41 => '`', - 16 => 'q', - 17 => 'w', - 18 => 'e', - 19 => 'r', - 20 => 't', - 21 => 'y', - 22 => 'u', - 23 => 'i', - 24 => 'o', - 25 => 'p', - 30 => 'a', - 31 => 's', - 32 => 'd', - 33 => 'f', - 34 => 'g', - 35 => 'h', - 36 => 'j', - 37 => 'k', - 38 => 'l', - 44 => 'z', - 45 => 'x', - 46 => 'c', - 47 => 'v', - 48 => 'b', - 49 => 'n', - 50 => 'm', - _ => return None, - } - }; + let state = xkb_state?; + let utf32 = state.key_get_utf32(xkb::Keycode::new(kc + 8)); + let ch = char::from_u32(utf32)?; + if ch.is_control() { + return None; + } Some(Key::Char(ch)) } + +/// build an xkb_state from the user's current X11 layout by reading +/// `_XKB_RULES_NAMES` off the root window. without it, character keys +/// won't resolve. +fn load_xkb_state(conn: &RustConnection, root: Window) -> Option { + enable_xkb(conn)?; + + let names_atom = conn + .intern_atom(false, b"_XKB_RULES_NAMES") + .ok()? + .reply() + .ok()? + .atom; + let reply = conn + .get_property(false, root, names_atom, AtomEnum::STRING, 0, 1024) + .ok()? + .reply() + .ok()?; + + let mut parts = reply + .value + .split(|b| *b == 0) + .filter_map(|s| std::str::from_utf8(s).ok()); + let rules = parts.next().unwrap_or(""); + let model = parts.next().unwrap_or(""); + let layout = parts.next().unwrap_or(""); + let variant = parts.next().unwrap_or(""); + let options = parts.next().unwrap_or(""); + + let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS); + let keymap = xkb::Keymap::new_from_names( + &context, + rules, + model, + layout, + variant, + Some(options.to_string()), + xkb::KEYMAP_COMPILE_NO_FLAGS, + )?; + let mut state = xkb::State::new(&keymap); + sync_xkb_state(conn, &mut state); + Some(state) +} + +fn enable_xkb(conn: &RustConnection) -> Option<()> { + let reply = conn.xkb_use_extension(1, 0).ok()?.reply().ok()?; + if !reply.supported { + return None; + } + Some(()) +} + +fn sync_xkb_state(conn: &RustConnection, state: &mut xkb::State) { + let Ok(cookie) = conn.xkb_get_state(XKB_USE_CORE_KBD) else { + return; + }; + let Ok(snapshot) = cookie.reply() else { + return; + }; + + state.update_mask( + u32::from(snapshot.base_mods), + u32::from(snapshot.latched_mods), + u32::from(snapshot.locked_mods), + 0, + 0, + u8::from(snapshot.group) as u32, + ); +}