1use crate::{KeyMapping, KeyTap};
4use std::collections::HashMap;
5use std::process::Command;
6
7#[derive(Debug, Clone)]
8pub struct KeyboardLayout {
9 pub layout: String,
10 pub variant: String,
11}
12
13impl KeyboardLayout {
14 pub fn detect() -> Self {
15 if let Some(kl) = Self::from_hyprland() {
16 return kl;
17 }
18 if let Some(kl) = Self::from_sway() {
19 return kl;
20 }
21 if let Some(kl) = Self::from_env() {
22 return kl;
23 }
24 Self {
25 layout: String::new(),
26 variant: String::new(),
27 }
28 }
29
30 fn from_hyprland() -> Option<Self> {
31 let output = Command::new("hyprctl")
32 .args(["devices", "-j"])
33 .output()
34 .ok()?;
35 if !output.status.success() {
36 return None;
37 }
38 let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
39 let keyboards = json.get("keyboards")?.as_array()?;
40 let kb = keyboards
41 .iter()
42 .find(|k| {
43 let name = k.get("name").and_then(|n| n.as_str()).unwrap_or("");
44 let layout = k.get("layout").and_then(|l| l.as_str()).unwrap_or("");
45 !layout.is_empty() && (name.contains("translated") || name.contains("at-"))
46 })
47 .or_else(|| {
48 keyboards.iter().find(|k| {
49 let layout = k.get("layout").and_then(|l| l.as_str()).unwrap_or("");
50 !layout.is_empty()
51 })
52 })?;
53 let layout = kb.get("layout")?.as_str()?.to_string();
54 let variant = kb
55 .get("variant")
56 .and_then(|v| v.as_str())
57 .unwrap_or("")
58 .to_string();
59 Some(Self { layout, variant })
60 }
61
62 fn from_sway() -> Option<Self> {
77 let output = Command::new("swaymsg")
78 .args(["-t", "get_inputs", "--raw"])
79 .output()
80 .ok()?;
81 if !output.status.success() {
82 return None;
83 }
84
85 let inputs: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).ok()?;
86
87 let kb = inputs.iter().find(|i| {
91 i.get("type").and_then(|t| t.as_str()) == Some("keyboard")
92 && i.get("xkb_active_layout_name").is_some()
93 })?;
94
95 let layout_names = kb.get("xkb_layout_names")?.as_array()?;
98 let active_idx = kb
99 .get("xkb_active_layout_index")
100 .and_then(|i| i.as_u64())
101 .unwrap_or(0) as usize;
102 let _display_name = layout_names.get(active_idx)?.as_str()?;
103
104 None
106 }
107
108 fn from_env() -> Option<Self> {
109 let layout = std::env::var("XKB_DEFAULT_LAYOUT").ok()?;
110 if layout.is_empty() {
111 return None;
112 }
113 let variant = std::env::var("XKB_DEFAULT_VARIANT").unwrap_or_default();
114 Some(Self { layout, variant })
115 }
116}
117
118pub struct XkbKeymap {
119 map: HashMap<char, KeyMapping>,
120}
121
122impl XkbKeymap {
123 pub fn from_layout(detected: &KeyboardLayout) -> anyhow::Result<Self> {
124 let context = xkbcommon::xkb::Context::new(xkbcommon::xkb::CONTEXT_NO_FLAGS);
125 let keymap = xkbcommon::xkb::Keymap::new_from_names(
126 &context,
127 "",
128 "",
129 &detected.layout,
130 &detected.variant,
131 None,
132 xkbcommon::xkb::KEYMAP_COMPILE_NO_FLAGS,
133 )
134 .ok_or_else(|| {
135 anyhow::anyhow!(
136 "failed to create XKB keymap for layout '{}' variant '{}'",
137 detected.layout,
138 detected.variant
139 )
140 })?;
141 let map = build_reverse_map(&keymap);
142 Ok(Self { map })
143 }
144
145 pub fn lookup(&self, ch: char) -> Option<&KeyMapping> {
146 self.map.get(&ch)
147 }
148 pub fn len(&self) -> usize {
149 self.map.len()
150 }
151 pub fn is_empty(&self) -> bool {
152 self.map.is_empty()
153 }
154}
155
156use xkbcommon::xkb::keysyms::{
157 KEY_dead_acute, KEY_dead_cedilla, KEY_dead_circumflex, KEY_dead_diaeresis, KEY_dead_grave,
158 KEY_dead_tilde,
159};
160
161const ACCENTED_VIA_DEAD_KEY: &[(char, char, u32)] = &[
162 ('ã', 'a', KEY_dead_tilde),
163 ('õ', 'o', KEY_dead_tilde),
164 ('ñ', 'n', KEY_dead_tilde),
165 ('Ã', 'A', KEY_dead_tilde),
166 ('Õ', 'O', KEY_dead_tilde),
167 ('Ñ', 'N', KEY_dead_tilde),
168 ('á', 'a', KEY_dead_acute),
169 ('é', 'e', KEY_dead_acute),
170 ('í', 'i', KEY_dead_acute),
171 ('ó', 'o', KEY_dead_acute),
172 ('ú', 'u', KEY_dead_acute),
173 ('ý', 'y', KEY_dead_acute),
174 ('Á', 'A', KEY_dead_acute),
175 ('É', 'E', KEY_dead_acute),
176 ('Í', 'I', KEY_dead_acute),
177 ('Ó', 'O', KEY_dead_acute),
178 ('Ú', 'U', KEY_dead_acute),
179 ('â', 'a', KEY_dead_circumflex),
180 ('ê', 'e', KEY_dead_circumflex),
181 ('î', 'i', KEY_dead_circumflex),
182 ('ô', 'o', KEY_dead_circumflex),
183 ('û', 'u', KEY_dead_circumflex),
184 ('Â', 'A', KEY_dead_circumflex),
185 ('Ê', 'E', KEY_dead_circumflex),
186 ('Ô', 'O', KEY_dead_circumflex),
187 ('à', 'a', KEY_dead_grave),
188 ('è', 'e', KEY_dead_grave),
189 ('ì', 'i', KEY_dead_grave),
190 ('ò', 'o', KEY_dead_grave),
191 ('ù', 'u', KEY_dead_grave),
192 ('À', 'A', KEY_dead_grave),
193 ('ä', 'a', KEY_dead_diaeresis),
194 ('ë', 'e', KEY_dead_diaeresis),
195 ('ï', 'i', KEY_dead_diaeresis),
196 ('ö', 'o', KEY_dead_diaeresis),
197 ('ü', 'u', KEY_dead_diaeresis),
198 ('ç', 'c', KEY_dead_cedilla),
199 ('Ç', 'C', KEY_dead_cedilla),
200];
201
202#[allow(non_upper_case_globals)]
203pub(crate) fn build_reverse_map(keymap: &xkbcommon::xkb::Keymap) -> HashMap<char, KeyMapping> {
204 let mut map: HashMap<char, KeyMapping> = HashMap::new();
205 let mut dead_keys: HashMap<u32, KeyTap> = HashMap::new();
206 let min = keymap.min_keycode().raw();
207 let max = keymap.max_keycode().raw();
208 let num_layouts = keymap.num_layouts();
209
210 for raw_keycode in min..=max {
211 let keycode = xkbcommon::xkb::Keycode::new(raw_keycode);
212 for layout in 0..num_layouts {
213 let num_levels = keymap.num_levels_for_key(keycode, layout);
214 for level in 0..num_levels {
215 if level > 3 {
216 continue;
217 }
218 let syms = keymap.key_get_syms_by_level(keycode, layout, level);
219 let evdev_keycode: u16 =
220 raw_keycode.saturating_sub(8).try_into().unwrap_or(u16::MAX);
221 let shift = level == 1 || level == 3;
222 let altgr = level == 2 || level == 3;
223
224 for &sym in syms {
225 let raw = sym.raw();
226 if matches!(
227 raw,
228 KEY_dead_grave
229 | KEY_dead_acute
230 | KEY_dead_circumflex
231 | KEY_dead_tilde
232 | KEY_dead_diaeresis
233 | KEY_dead_cedilla
234 ) {
235 dead_keys.entry(raw).or_insert(KeyTap {
236 keycode: evdev_keycode,
237 shift,
238 altgr,
239 });
240 continue;
241 }
242 let unicode = xkbcommon::xkb::keysym_to_utf32(sym);
243 if unicode == 0 {
244 continue;
245 }
246 if let Some(ch) = char::from_u32(unicode) {
247 map.entry(ch).or_insert(KeyMapping {
248 main: KeyTap {
249 keycode: evdev_keycode,
250 shift,
251 altgr,
252 },
253 follow: None,
254 });
255 }
256 }
257 }
258 }
259 }
260
261 for (ch, dead_sym) in [
263 ('\'', KEY_dead_acute),
264 ('"', KEY_dead_diaeresis),
265 ('~', KEY_dead_tilde),
266 ('`', KEY_dead_grave),
267 ('^', KEY_dead_circumflex),
268 ] {
269 if map.contains_key(&ch) {
270 continue;
271 }
272 if let Some(dk) = dead_keys.get(&dead_sym) {
273 map.insert(
274 ch,
275 KeyMapping {
276 main: *dk,
277 follow: Some(KeyTap {
278 keycode: evdev::Key::KEY_SPACE.code(),
279 shift: false,
280 altgr: false,
281 }),
282 },
283 );
284 }
285 }
286
287 for &(ch, base, dead_sym) in ACCENTED_VIA_DEAD_KEY {
289 if map.contains_key(&ch) {
290 continue;
291 }
292 let Some(dk) = dead_keys.get(&dead_sym) else {
293 continue;
294 };
295 let Some(base_map) = map.get(&base).copied() else {
296 continue;
297 };
298 if base_map.follow.is_some() {
299 continue;
300 }
301 map.insert(
302 ch,
303 KeyMapping {
304 main: *dk,
305 follow: Some(base_map.main),
306 },
307 );
308 }
309
310 map
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 fn us_layout() -> KeyboardLayout {
318 KeyboardLayout {
319 layout: "us".to_string(),
320 variant: String::new(),
321 }
322 }
323
324 fn layout(name: &str, variant: &str) -> KeyboardLayout {
325 KeyboardLayout {
326 layout: name.to_string(),
327 variant: variant.to_string(),
328 }
329 }
330
331 fn assert_key(
332 km: &XkbKeymap,
333 ch: char,
334 expected_keycode: u16,
335 expected_shift: bool,
336 label: &str,
337 ) {
338 assert_key_full(km, ch, expected_keycode, expected_shift, false, label);
339 }
340
341 fn assert_key_full(
342 km: &XkbKeymap,
343 ch: char,
344 expected_keycode: u16,
345 expected_shift: bool,
346 expected_altgr: bool,
347 label: &str,
348 ) {
349 let mapping = km
350 .lookup(ch)
351 .unwrap_or_else(|| panic!("'{ch}' should be in {label} keymap"));
352 assert_eq!(
353 mapping.main.keycode, expected_keycode,
354 "'{ch}' should be at evdev {expected_keycode} on {label}, got {}",
355 mapping.main.keycode
356 );
357 assert_eq!(
358 mapping.main.shift, expected_shift,
359 "'{ch}' shift should be {expected_shift} on {label}"
360 );
361 assert_eq!(
362 mapping.main.altgr, expected_altgr,
363 "'{ch}' altgr should be {expected_altgr} on {label}"
364 );
365 }
366
367 #[test]
368 fn build_us_keymap() {
369 let km = XkbKeymap::from_layout(&us_layout());
370 if let Ok(km) = km {
371 assert!(!km.is_empty(), "keymap should not be empty");
372 assert!(km.lookup('a').is_some(), "'a' should be in the keymap");
373 }
374 }
375
376 #[test]
377 fn shift_mapping_for_uppercase() {
378 let km = XkbKeymap::from_layout(&us_layout());
379 if let Ok(km) = km {
380 if let Some(mapping) = km.lookup('A') {
381 assert!(
382 mapping.main.shift,
383 "uppercase 'A' should require shift on standard layouts"
384 );
385 }
386 }
387 }
388
389 #[test]
392 fn german_layout() {
393 let km = XkbKeymap::from_layout(&layout("de", "")).unwrap();
394 assert_key(&km, 'z', 21, false, "German");
395 assert_key(&km, 'y', 44, false, "German");
396 }
397
398 #[test]
399 fn swiss_layout() {
400 let km = XkbKeymap::from_layout(&layout("ch", "")).unwrap();
401 assert_key(&km, 'z', 21, false, "Swiss");
402 assert_key(&km, 'y', 44, false, "Swiss");
403 }
404
405 #[test]
406 fn czech_layout() {
407 let km = XkbKeymap::from_layout(&layout("cz", "")).unwrap();
408 assert_key(&km, 'z', 21, false, "Czech");
409 assert_key(&km, 'y', 44, false, "Czech");
410 assert_key(&km, 'ů', 39, false, "Czech");
411 }
412
413 #[test]
414 fn slovak_layout() {
415 let km = XkbKeymap::from_layout(&layout("sk", "")).unwrap();
416 assert_key(&km, 'z', 21, false, "Slovak");
417 assert_key(&km, 'y', 44, false, "Slovak");
418 assert_key(&km, 'ô', 39, false, "Slovak");
419 }
420
421 #[test]
422 fn hungarian_layout() {
423 let km = XkbKeymap::from_layout(&layout("hu", "")).unwrap();
424 assert_key(&km, 'z', 21, false, "Hungarian");
425 assert_key(&km, 'y', 44, false, "Hungarian");
426 assert_key(&km, 'ö', 11, false, "Hungarian");
427 assert_key(&km, 'ü', 12, false, "Hungarian");
428 }
429
430 #[test]
433 fn french_layout() {
434 let km = XkbKeymap::from_layout(&layout("fr", "")).unwrap();
435 assert_key(&km, 'a', 16, false, "French");
436 assert_key(&km, 'q', 30, false, "French");
437 assert_key(&km, 'z', 17, false, "French");
438 assert_key(&km, 'w', 44, false, "French");
439 }
440
441 #[test]
442 fn belgian_layout() {
443 let km = XkbKeymap::from_layout(&layout("be", "")).unwrap();
444 assert_key(&km, 'a', 16, false, "Belgian");
445 assert_key(&km, 'q', 30, false, "Belgian");
446 assert_key(&km, 'z', 17, false, "Belgian");
447 assert_key(&km, 'w', 44, false, "Belgian");
448 assert_key(&km, 'm', 39, false, "Belgian");
449 }
450
451 #[test]
454 fn spanish_layout() {
455 let km = XkbKeymap::from_layout(&layout("es", "")).unwrap();
456 assert_key(&km, 'ñ', 39, false, "Spanish");
457 }
458
459 #[test]
460 fn portuguese_layout() {
461 let km = XkbKeymap::from_layout(&layout("pt", "")).unwrap();
462 assert_key(&km, 'a', 30, false, "Portuguese");
463 assert_key(&km, 'z', 44, false, "Portuguese");
464 assert_key(&km, 'q', 16, false, "Portuguese");
465 }
466
467 #[test]
468 fn italian_layout() {
469 let km = XkbKeymap::from_layout(&layout("it", "")).unwrap();
470 assert_key(&km, 'a', 30, false, "Italian");
471 assert_key(&km, 'z', 44, false, "Italian");
472 assert_key(&km, 'q', 16, false, "Italian");
473 assert_key(&km, 'w', 17, false, "Italian");
474 }
475
476 #[test]
477 fn uk_layout() {
478 let km = XkbKeymap::from_layout(&layout("gb", "")).unwrap();
479 assert_key(&km, 'a', 30, false, "UK");
480 assert_key(&km, 'z', 44, false, "UK");
481 assert_key(&km, '#', 43, false, "UK");
483 assert_key(&km, '£', 4, true, "UK");
484 }
485
486 #[test]
489 fn swedish_layout() {
490 let km = XkbKeymap::from_layout(&layout("se", "")).unwrap();
491 assert_key(&km, 'ö', 39, false, "Swedish");
492 assert_key(&km, 'ä', 40, false, "Swedish");
493 }
494
495 #[test]
496 fn norwegian_layout() {
497 let km = XkbKeymap::from_layout(&layout("no", "")).unwrap();
498 assert_key(&km, 'ø', 39, false, "Norwegian");
499 assert_key(&km, 'æ', 40, false, "Norwegian");
500 }
501
502 #[test]
503 fn danish_layout() {
504 let km = XkbKeymap::from_layout(&layout("dk", "")).unwrap();
505 assert_key(&km, 'ø', 40, false, "Danish");
506 assert_key(&km, 'æ', 39, false, "Danish");
507 }
508
509 #[test]
510 fn finnish_layout() {
511 let km = XkbKeymap::from_layout(&layout("fi", "")).unwrap();
512 assert_key(&km, 'ö', 39, false, "Finnish");
513 assert_key(&km, 'ä', 40, false, "Finnish");
514 }
515
516 #[test]
519 fn polish_layout() {
520 let km = XkbKeymap::from_layout(&layout("pl", "")).unwrap();
521 assert_key(&km, 'a', 30, false, "Polish");
522 assert_key(&km, 'z', 44, false, "Polish");
523 assert_key_full(&km, 'ą', 30, false, true, "Polish");
526 assert_key_full(&km, 'ę', 18, false, true, "Polish");
527 }
528
529 #[test]
530 fn us_intl_typeable_via_uinput() {
531 let km = XkbKeymap::from_layout(&KeyboardLayout {
532 layout: "us".to_string(),
533 variant: "intl".to_string(),
534 })
535 .unwrap();
536 for ch in [
541 '\'', '"', '~', '`', '^', 'ç', 'á', 'é', 'í', 'ó', 'ú', 'ã', 'ñ',
542 ] {
543 assert!(
544 km.lookup(ch).is_some(),
545 "'{ch}' must be reachable via uinput on us:intl, got no mapping"
546 );
547 }
548 }
549
550 #[test]
560 fn us_intl_pass1_chars_synthesized_routes_end_with_space() {
561 let km = XkbKeymap::from_layout(&KeyboardLayout {
562 layout: "us".to_string(),
563 variant: "intl".to_string(),
564 })
565 .unwrap();
566 for ch in ['\'', '"', '~', '`', '^'] {
567 let mapping = km
568 .lookup(ch)
569 .unwrap_or_else(|| panic!("'{ch}' must be reachable on us:intl"));
570 if let Some(follow) = mapping.follow {
571 assert_eq!(
572 follow.keycode,
573 evdev::Key::KEY_SPACE.code(),
574 "'{ch}' was synthesized; follow tap must be SPACE \
575 (dead_X + space sequence)"
576 );
577 assert!(
578 !follow.shift && !follow.altgr,
579 "'{ch}' synthesis follow tap must be unmodified SPACE"
580 );
581 }
582 }
583 }
584
585 #[test]
592 fn us_intl_tilde_letter_uses_dead_key_synthesis() {
593 let km = XkbKeymap::from_layout(&KeyboardLayout {
594 layout: "us".to_string(),
595 variant: "intl".to_string(),
596 })
597 .unwrap();
598 let a_main = km.lookup('a').expect("'a' must be in keymap").main;
599 let mapping = km.lookup('ã').expect("ã must be reachable on us:intl");
600 let follow = mapping.follow.expect(
601 "ã on us:intl must be synthesized via dead_tilde + a — \
602 the literal char is not at any level on this layout",
603 );
604 assert_eq!(
605 follow.keycode, a_main.keycode,
606 "ã follow tap must target the same evdev keycode as 'a' \
607 (dead_tilde + a sequence)"
608 );
609 }
610
611 #[test]
615 fn polish_accented_letter_is_direct_not_synthesized() {
616 let km = XkbKeymap::from_layout(&layout("pl", "")).unwrap();
617 let mapping = km.lookup('ą').expect("ą must be reachable on Polish");
618 assert!(
619 mapping.follow.is_none(),
620 "ą on Polish must be a direct AltGr tap, not synthesized — \
621 synthesis pass must not overwrite a direct mapping"
622 );
623 assert!(mapping.main.altgr, "ą on Polish must hold AltGr");
624 }
625
626 #[test]
631 fn spanish_enye_is_direct_not_synthesized() {
632 let km = XkbKeymap::from_layout(&layout("es", "")).unwrap();
633 let mapping = km.lookup('ñ').expect("ñ must be reachable on Spanish");
634 assert!(
635 mapping.follow.is_none(),
636 "ñ on Spanish must be a direct level-0 tap, not synthesized — \
637 synthesis pass must not overwrite even when the char is in \
638 the synthesis table"
639 );
640 assert!(
641 !mapping.main.altgr && !mapping.main.shift,
642 "ñ on Spanish is on a dedicated key — no modifiers required"
643 );
644 }
645
646 #[test]
649 fn dvorak_layout() {
650 let km = XkbKeymap::from_layout(&layout("us", "dvorak")).unwrap();
651 assert_key(&km, 'o', 31, false, "Dvorak");
652 assert_key(&km, 'e', 32, false, "Dvorak");
653 assert_key(&km, 's', 39, false, "Dvorak");
654 }
655
656 #[test]
657 fn colemak_layout() {
658 let km = XkbKeymap::from_layout(&layout("us", "colemak")).unwrap();
659 assert_key(&km, 'f', 18, false, "Colemak");
660 assert_key(&km, 'n', 36, false, "Colemak");
661 assert_key(&km, 's', 32, false, "Colemak");
662 }
663
664 #[test]
667 fn russian_layout() {
668 let km = XkbKeymap::from_layout(&layout("ru", "")).unwrap();
669 assert_key(&km, 'ф', 30, false, "Russian");
670 assert_key(&km, 'я', 44, false, "Russian");
671 assert_key(&km, 'й', 16, false, "Russian");
672 assert_key(&km, 'ц', 17, false, "Russian");
673 }
674
675 #[test]
676 fn ukrainian_layout() {
677 let km = XkbKeymap::from_layout(&layout("ua", "")).unwrap();
678 assert_key(&km, 'ф', 30, false, "Ukrainian");
679 assert_key(&km, 'я', 44, false, "Ukrainian");
680 assert_key(&km, 'й', 16, false, "Ukrainian");
681 assert_key(&km, 'і', 31, false, "Ukrainian");
682 }
683
684 #[test]
685 fn greek_layout() {
686 let km = XkbKeymap::from_layout(&layout("gr", "")).unwrap();
687 assert_key(&km, 'α', 30, false, "Greek");
688 assert_key(&km, 'ζ', 44, false, "Greek");
689 assert_key(&km, 'ω', 47, false, "Greek");
690 }
691
692 #[test]
693 fn japanese_layout() {
694 let km = XkbKeymap::from_layout(&layout("jp", "")).unwrap();
696 assert_key(&km, 'a', 30, false, "Japanese");
697 assert_key(&km, 'z', 44, false, "Japanese");
698 }
699
700 #[test]
709 fn from_sway_always_returns_none() {
710 let result = KeyboardLayout::from_sway();
715 assert!(
716 result.is_none(),
717 "from_sway must return None (display names ≠ XKB codes); got {result:?}"
718 );
719 }
720}