use std::collections::HashMap;
use std::process::Command;
use evdev::Key;
use tracing::{debug, warn};
use super::{KeyMapping, KeyTap};
#[derive(Debug, Clone)]
pub struct KeyboardLayout {
pub layout: String,
pub variant: String,
}
impl KeyboardLayout {
pub fn detect() -> Self {
if let Some(kl) = Self::from_hyprland() {
debug!("detected keyboard layout from Hyprland: {kl:?}");
return kl;
}
if let Some(kl) = Self::from_sway() {
debug!("detected keyboard layout from Sway: {kl:?}");
return kl;
}
if let Some(kl) = Self::from_env() {
debug!("detected keyboard layout from environment: {kl:?}");
return kl;
}
warn!("could not detect keyboard layout, falling back to system default");
Self {
layout: String::new(),
variant: String::new(),
}
}
fn from_hyprland() -> Option<Self> {
let output = Command::new("hyprctl")
.args(["devices", "-j"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
let keyboards = json.get("keyboards")?.as_array()?;
let kb = keyboards
.iter()
.find(|k| {
let name = k.get("name").and_then(|n| n.as_str()).unwrap_or("");
let layout = k.get("layout").and_then(|l| l.as_str()).unwrap_or("");
!layout.is_empty() && (name.contains("translated") || name.contains("at-"))
})
.or_else(|| {
keyboards.iter().find(|k| {
let layout = k.get("layout").and_then(|l| l.as_str()).unwrap_or("");
!layout.is_empty()
})
})?;
let layout = kb.get("layout")?.as_str()?.to_string();
let variant = kb
.get("variant")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(Self { layout, variant })
}
fn from_sway() -> Option<Self> {
let output = Command::new("swaymsg")
.args(["-t", "get_inputs", "--raw"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let inputs: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).ok()?;
let kb = inputs.iter().find(|i| {
i.get("type").and_then(|t| t.as_str()) == Some("keyboard")
&& i.get("xkb_active_layout_name").is_some()
})?;
let layout_names = kb.get("xkb_layout_names")?.as_array()?;
let active_idx = kb
.get("xkb_active_layout_index")
.and_then(|i| i.as_u64())
.unwrap_or(0) as usize;
let _active_name = layout_names.get(active_idx)?.as_str()?;
None
}
fn from_env() -> Option<Self> {
let layout = std::env::var("XKB_DEFAULT_LAYOUT").ok()?;
if layout.is_empty() {
return None;
}
let variant = std::env::var("XKB_DEFAULT_VARIANT").unwrap_or_default();
Some(Self { layout, variant })
}
}
pub struct XkbKeymap {
map: HashMap<char, KeyMapping>,
}
impl XkbKeymap {
pub fn from_layout(detected: &KeyboardLayout) -> anyhow::Result<Self> {
let context = xkbcommon::xkb::Context::new(xkbcommon::xkb::CONTEXT_NO_FLAGS);
let keymap = xkbcommon::xkb::Keymap::new_from_names(
&context,
"", "", &detected.layout, &detected.variant, None, xkbcommon::xkb::KEYMAP_COMPILE_NO_FLAGS,
)
.ok_or_else(|| {
anyhow::anyhow!(
"failed to create XKB keymap for layout '{}' variant '{}'",
detected.layout,
detected.variant
)
})?;
let map = build_reverse_map(&keymap);
debug!(
"built XKB reverse keymap with {} entries for layout='{}' variant='{}'",
map.len(),
detected.layout,
detected.variant
);
Ok(Self { map })
}
pub fn lookup(&self, ch: char) -> Option<&KeyMapping> {
self.map.get(&ch)
}
pub fn len(&self) -> usize {
self.map.len()
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
}
use xkbcommon::xkb::keysyms::{
KEY_dead_acute as XK_DEAD_ACUTE, KEY_dead_cedilla as XK_DEAD_CEDILLA,
KEY_dead_circumflex as XK_DEAD_CIRCUMFLEX, KEY_dead_diaeresis as XK_DEAD_DIAERESIS,
KEY_dead_grave as XK_DEAD_GRAVE, KEY_dead_tilde as XK_DEAD_TILDE,
};
const ACCENTED_VIA_DEAD_KEY: &[(char, char, u32)] = &[
('ã', 'a', XK_DEAD_TILDE),
('õ', 'o', XK_DEAD_TILDE),
('ñ', 'n', XK_DEAD_TILDE),
('Ã', 'A', XK_DEAD_TILDE),
('Õ', 'O', XK_DEAD_TILDE),
('Ñ', 'N', XK_DEAD_TILDE),
('á', 'a', XK_DEAD_ACUTE),
('é', 'e', XK_DEAD_ACUTE),
('í', 'i', XK_DEAD_ACUTE),
('ó', 'o', XK_DEAD_ACUTE),
('ú', 'u', XK_DEAD_ACUTE),
('ý', 'y', XK_DEAD_ACUTE),
('Á', 'A', XK_DEAD_ACUTE),
('É', 'E', XK_DEAD_ACUTE),
('Í', 'I', XK_DEAD_ACUTE),
('Ó', 'O', XK_DEAD_ACUTE),
('Ú', 'U', XK_DEAD_ACUTE),
('â', 'a', XK_DEAD_CIRCUMFLEX),
('ê', 'e', XK_DEAD_CIRCUMFLEX),
('î', 'i', XK_DEAD_CIRCUMFLEX),
('ô', 'o', XK_DEAD_CIRCUMFLEX),
('û', 'u', XK_DEAD_CIRCUMFLEX),
('Â', 'A', XK_DEAD_CIRCUMFLEX),
('Ê', 'E', XK_DEAD_CIRCUMFLEX),
('Ô', 'O', XK_DEAD_CIRCUMFLEX),
('à', 'a', XK_DEAD_GRAVE),
('è', 'e', XK_DEAD_GRAVE),
('ì', 'i', XK_DEAD_GRAVE),
('ò', 'o', XK_DEAD_GRAVE),
('ù', 'u', XK_DEAD_GRAVE),
('À', 'A', XK_DEAD_GRAVE),
('ä', 'a', XK_DEAD_DIAERESIS),
('ë', 'e', XK_DEAD_DIAERESIS),
('ï', 'i', XK_DEAD_DIAERESIS),
('ö', 'o', XK_DEAD_DIAERESIS),
('ü', 'u', XK_DEAD_DIAERESIS),
('ç', 'c', XK_DEAD_CEDILLA),
('Ç', 'C', XK_DEAD_CEDILLA),
];
fn build_reverse_map(keymap: &xkbcommon::xkb::Keymap) -> HashMap<char, KeyMapping> {
let mut map: HashMap<char, KeyMapping> = HashMap::new();
let mut dead_keys: HashMap<u32, KeyTap> = HashMap::new();
let min = keymap.min_keycode().raw();
let max = keymap.max_keycode().raw();
let num_layouts = keymap.num_layouts();
for raw_keycode in min..=max {
let keycode = xkbcommon::xkb::Keycode::new(raw_keycode);
for layout in 0..num_layouts {
let num_levels = keymap.num_levels_for_key(keycode, layout);
for level in 0..num_levels {
let syms = keymap.key_get_syms_by_level(keycode, layout, level);
if level > 3 {
continue;
}
let evdev_keycode: u16 =
raw_keycode.saturating_sub(8).try_into().unwrap_or(u16::MAX);
let shift = level == 1 || level == 3;
let altgr = level == 2 || level == 3;
for &sym in syms {
let raw = sym.raw();
if matches!(
raw,
XK_DEAD_GRAVE
| XK_DEAD_ACUTE
| XK_DEAD_CIRCUMFLEX
| XK_DEAD_TILDE
| XK_DEAD_DIAERESIS
| XK_DEAD_CEDILLA
) {
dead_keys.entry(raw).or_insert(KeyTap {
keycode: evdev_keycode,
shift,
altgr,
});
continue;
}
let unicode = xkbcommon::xkb::keysym_to_utf32(sym);
if unicode == 0 {
continue;
}
if let Some(ch) = char::from_u32(unicode) {
let mapping = KeyMapping {
main: KeyTap {
keycode: evdev_keycode,
shift,
altgr,
},
follow: None,
};
map.entry(ch).or_insert(mapping);
}
}
}
}
}
for (ch, dead_sym) in [
('\'', XK_DEAD_ACUTE),
('"', XK_DEAD_DIAERESIS),
('~', XK_DEAD_TILDE),
('`', XK_DEAD_GRAVE),
('^', XK_DEAD_CIRCUMFLEX),
] {
if map.contains_key(&ch) {
continue;
}
if let Some(dk) = dead_keys.get(&dead_sym) {
map.insert(
ch,
KeyMapping {
main: *dk,
follow: Some(KeyTap {
keycode: Key::KEY_SPACE.code(),
shift: false,
altgr: false,
}),
},
);
} else {
debug!(
"dead-key synthesis pass 1: no `{dead_sym:#x}` on this layout; \
'{ch}' will use clipboard fallback"
);
}
}
for &(ch, base, dead_sym) in ACCENTED_VIA_DEAD_KEY {
if map.contains_key(&ch) {
continue;
}
let Some(dk) = dead_keys.get(&dead_sym) else {
debug!(
"dead-key synthesis pass 2: no `{dead_sym:#x}` on this layout; \
'{ch}' will use clipboard fallback"
);
continue;
};
let Some(base_map) = map.get(&base).copied() else {
debug!(
"dead-key synthesis pass 2: base letter '{base}' not in keymap; \
'{ch}' will use clipboard fallback"
);
continue;
};
if base_map.follow.is_some() {
continue;
}
map.insert(
ch,
KeyMapping {
main: *dk,
follow: Some(base_map.main),
},
);
}
map
}
#[cfg(test)]
mod tests {
use super::*;
fn us_layout() -> KeyboardLayout {
KeyboardLayout {
layout: "us".to_string(),
variant: String::new(),
}
}
#[test]
fn build_us_keymap() {
let km = XkbKeymap::from_layout(&us_layout());
if let Ok(km) = km {
assert!(!km.is_empty(), "keymap should not be empty");
assert!(km.lookup('a').is_some(), "'a' should be in the keymap");
}
}
#[test]
fn shift_mapping_for_uppercase() {
let km = XkbKeymap::from_layout(&us_layout());
if let Ok(km) = km {
if let Some(mapping) = km.lookup('A') {
assert!(
mapping.main.shift,
"uppercase 'A' should require shift on standard layouts"
);
}
}
}
fn layout(name: &str, variant: &str) -> KeyboardLayout {
KeyboardLayout {
layout: name.to_string(),
variant: variant.to_string(),
}
}
fn assert_key(
km: &XkbKeymap,
ch: char,
expected_keycode: u16,
expected_shift: bool,
label: &str,
) {
assert_key_full(km, ch, expected_keycode, expected_shift, false, label);
}
fn assert_key_full(
km: &XkbKeymap,
ch: char,
expected_keycode: u16,
expected_shift: bool,
expected_altgr: bool,
label: &str,
) {
let mapping = km
.lookup(ch)
.unwrap_or_else(|| panic!("'{ch}' should be in {label} keymap"));
assert_eq!(
mapping.main.keycode, expected_keycode,
"'{ch}' should be at evdev {expected_keycode} on {label}, got {}",
mapping.main.keycode
);
assert_eq!(
mapping.main.shift, expected_shift,
"'{ch}' shift should be {expected_shift} on {label}"
);
assert_eq!(
mapping.main.altgr, expected_altgr,
"'{ch}' altgr should be {expected_altgr} on {label}"
);
}
#[test]
fn german_layout() {
let km = XkbKeymap::from_layout(&layout("de", "")).unwrap();
assert_key(&km, 'z', 21, false, "German");
assert_key(&km, 'y', 44, false, "German");
}
#[test]
fn swiss_layout() {
let km = XkbKeymap::from_layout(&layout("ch", "")).unwrap();
assert_key(&km, 'z', 21, false, "Swiss");
assert_key(&km, 'y', 44, false, "Swiss");
}
#[test]
fn czech_layout() {
let km = XkbKeymap::from_layout(&layout("cz", "")).unwrap();
assert_key(&km, 'z', 21, false, "Czech");
assert_key(&km, 'y', 44, false, "Czech");
assert_key(&km, 'ů', 39, false, "Czech");
}
#[test]
fn slovak_layout() {
let km = XkbKeymap::from_layout(&layout("sk", "")).unwrap();
assert_key(&km, 'z', 21, false, "Slovak");
assert_key(&km, 'y', 44, false, "Slovak");
assert_key(&km, 'ô', 39, false, "Slovak");
}
#[test]
fn hungarian_layout() {
let km = XkbKeymap::from_layout(&layout("hu", "")).unwrap();
assert_key(&km, 'z', 21, false, "Hungarian");
assert_key(&km, 'y', 44, false, "Hungarian");
assert_key(&km, 'ö', 11, false, "Hungarian");
assert_key(&km, 'ü', 12, false, "Hungarian");
}
#[test]
fn french_layout() {
let km = XkbKeymap::from_layout(&layout("fr", "")).unwrap();
assert_key(&km, 'a', 16, false, "French");
assert_key(&km, 'q', 30, false, "French");
assert_key(&km, 'z', 17, false, "French");
assert_key(&km, 'w', 44, false, "French");
}
#[test]
fn belgian_layout() {
let km = XkbKeymap::from_layout(&layout("be", "")).unwrap();
assert_key(&km, 'a', 16, false, "Belgian");
assert_key(&km, 'q', 30, false, "Belgian");
assert_key(&km, 'z', 17, false, "Belgian");
assert_key(&km, 'w', 44, false, "Belgian");
assert_key(&km, 'm', 39, false, "Belgian");
}
#[test]
fn spanish_layout() {
let km = XkbKeymap::from_layout(&layout("es", "")).unwrap();
assert_key(&km, 'ñ', 39, false, "Spanish");
}
#[test]
fn portuguese_layout() {
let km = XkbKeymap::from_layout(&layout("pt", "")).unwrap();
assert_key(&km, 'a', 30, false, "Portuguese");
assert_key(&km, 'z', 44, false, "Portuguese");
assert_key(&km, 'q', 16, false, "Portuguese");
}
#[test]
fn italian_layout() {
let km = XkbKeymap::from_layout(&layout("it", "")).unwrap();
assert_key(&km, 'a', 30, false, "Italian");
assert_key(&km, 'z', 44, false, "Italian");
assert_key(&km, 'q', 16, false, "Italian");
assert_key(&km, 'w', 17, false, "Italian");
}
#[test]
fn uk_layout() {
let km = XkbKeymap::from_layout(&layout("gb", "")).unwrap();
assert_key(&km, 'a', 30, false, "UK");
assert_key(&km, 'z', 44, false, "UK");
assert_key(&km, '#', 43, false, "UK");
assert_key(&km, '£', 4, true, "UK");
}
#[test]
fn swedish_layout() {
let km = XkbKeymap::from_layout(&layout("se", "")).unwrap();
assert_key(&km, 'ö', 39, false, "Swedish");
assert_key(&km, 'ä', 40, false, "Swedish");
}
#[test]
fn norwegian_layout() {
let km = XkbKeymap::from_layout(&layout("no", "")).unwrap();
assert_key(&km, 'ø', 39, false, "Norwegian");
assert_key(&km, 'æ', 40, false, "Norwegian");
}
#[test]
fn danish_layout() {
let km = XkbKeymap::from_layout(&layout("dk", "")).unwrap();
assert_key(&km, 'ø', 40, false, "Danish");
assert_key(&km, 'æ', 39, false, "Danish");
}
#[test]
fn finnish_layout() {
let km = XkbKeymap::from_layout(&layout("fi", "")).unwrap();
assert_key(&km, 'ö', 39, false, "Finnish");
assert_key(&km, 'ä', 40, false, "Finnish");
}
#[test]
fn polish_layout() {
let km = XkbKeymap::from_layout(&layout("pl", "")).unwrap();
assert_key(&km, 'a', 30, false, "Polish");
assert_key(&km, 'z', 44, false, "Polish");
assert_key_full(&km, 'ą', 30, false, true, "Polish");
assert_key_full(&km, 'ę', 18, false, true, "Polish");
}
#[test]
fn us_intl_typeable_via_uinput() {
let km = XkbKeymap::from_layout(&KeyboardLayout {
layout: "us".to_string(),
variant: "intl".to_string(),
})
.unwrap();
for ch in [
'\'', '"', '~', '`', '^', 'ç', 'á', 'é', 'í', 'ó', 'ú', 'ã', 'ñ',
] {
assert!(
km.lookup(ch).is_some(),
"'{ch}' must be reachable via uinput on us:intl, got no mapping"
);
}
}
#[test]
fn us_intl_pass1_chars_synthesized_routes_end_with_space() {
let km = XkbKeymap::from_layout(&KeyboardLayout {
layout: "us".to_string(),
variant: "intl".to_string(),
})
.unwrap();
for ch in ['\'', '"', '~', '`', '^'] {
let mapping = km
.lookup(ch)
.unwrap_or_else(|| panic!("'{ch}' must be reachable on us:intl"));
if let Some(follow) = mapping.follow {
assert_eq!(
follow.keycode,
evdev::Key::KEY_SPACE.code(),
"'{ch}' was synthesized; follow tap must be SPACE \
(dead_X + space sequence)"
);
assert!(
!follow.shift && !follow.altgr,
"'{ch}' synthesis follow tap must be unmodified SPACE"
);
}
}
}
#[test]
fn us_intl_tilde_letter_uses_dead_key_synthesis() {
let km = XkbKeymap::from_layout(&KeyboardLayout {
layout: "us".to_string(),
variant: "intl".to_string(),
})
.unwrap();
let a_main = km.lookup('a').expect("'a' must be in keymap").main;
let mapping = km.lookup('ã').expect("ã must be reachable on us:intl");
let follow = mapping.follow.expect(
"ã on us:intl must be synthesized via dead_tilde + a — \
the literal char is not at any level on this layout",
);
assert_eq!(
follow.keycode, a_main.keycode,
"ã follow tap must target the same evdev keycode as 'a' \
(dead_tilde + a sequence)"
);
}
#[test]
fn polish_accented_letter_is_direct_not_synthesized() {
let km = XkbKeymap::from_layout(&layout("pl", "")).unwrap();
let mapping = km.lookup('ą').expect("ą must be reachable on Polish");
assert!(
mapping.follow.is_none(),
"ą on Polish must be a direct AltGr tap, not synthesized — \
synthesis pass must not overwrite a direct mapping"
);
assert!(mapping.main.altgr, "ą on Polish must hold AltGr");
}
#[test]
fn spanish_enye_is_direct_not_synthesized() {
let km = XkbKeymap::from_layout(&layout("es", "")).unwrap();
let mapping = km.lookup('ñ').expect("ñ must be reachable on Spanish");
assert!(
mapping.follow.is_none(),
"ñ on Spanish must be a direct level-0 tap, not synthesized — \
synthesis pass must not overwrite even when the char is in \
the synthesis table"
);
assert!(
!mapping.main.altgr && !mapping.main.shift,
"ñ on Spanish is on a dedicated key — no modifiers required"
);
}
#[test]
fn dvorak_layout() {
let km = XkbKeymap::from_layout(&layout("us", "dvorak")).unwrap();
assert_key(&km, 'o', 31, false, "Dvorak");
assert_key(&km, 'e', 32, false, "Dvorak");
assert_key(&km, 's', 39, false, "Dvorak");
}
#[test]
fn colemak_layout() {
let km = XkbKeymap::from_layout(&layout("us", "colemak")).unwrap();
assert_key(&km, 'f', 18, false, "Colemak");
assert_key(&km, 'n', 36, false, "Colemak");
assert_key(&km, 's', 32, false, "Colemak");
}
#[test]
fn russian_layout() {
let km = XkbKeymap::from_layout(&layout("ru", "")).unwrap();
assert_key(&km, 'ф', 30, false, "Russian");
assert_key(&km, 'я', 44, false, "Russian");
assert_key(&km, 'й', 16, false, "Russian");
assert_key(&km, 'ц', 17, false, "Russian");
}
#[test]
fn ukrainian_layout() {
let km = XkbKeymap::from_layout(&layout("ua", "")).unwrap();
assert_key(&km, 'ф', 30, false, "Ukrainian");
assert_key(&km, 'я', 44, false, "Ukrainian");
assert_key(&km, 'й', 16, false, "Ukrainian");
assert_key(&km, 'і', 31, false, "Ukrainian");
}
#[test]
fn greek_layout() {
let km = XkbKeymap::from_layout(&layout("gr", "")).unwrap();
assert_key(&km, 'α', 30, false, "Greek");
assert_key(&km, 'ζ', 44, false, "Greek");
assert_key(&km, 'ω', 47, false, "Greek");
}
#[test]
fn japanese_layout() {
let km = XkbKeymap::from_layout(&layout("jp", "")).unwrap();
assert_key(&km, 'a', 30, false, "Japanese");
assert_key(&km, 'z', 44, false, "Japanese");
}
}