1use crate::{KeyMapping, KeyTap};
4use std::collections::HashMap;
5use std::process::Command;
6
7#[cfg(feature = "logging")]
10use log::debug;
11#[cfg(not(feature = "logging"))]
12macro_rules! debug {
13 ($($arg:tt)*) => {};
14}
15
16#[derive(Debug, Clone)]
17pub struct KeyboardLayout {
18 pub layout: String,
19 pub variant: String,
20}
21
22impl KeyboardLayout {
23 pub fn detect() -> Self {
24 if let Some(kl) = Self::from_hyprland() {
30 return kl;
31 }
32 if let Some(kl) = Self::from_sway() {
33 return kl;
34 }
35 if let Some(kl) = Self::from_x11() {
36 return kl;
37 }
38 if let Some(kl) = Self::from_localectl() {
39 return kl;
40 }
41 if let Some(kl) = Self::from_env() {
42 return kl;
43 }
44 Self {
45 layout: String::new(),
46 variant: String::new(),
47 }
48 }
49
50 fn from_hyprland() -> Option<Self> {
51 let output = Command::new("hyprctl")
52 .args(["devices", "-j"])
53 .output()
54 .ok()?;
55 if !output.status.success() {
56 return None;
57 }
58 let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
59 let keyboards = json.get("keyboards")?.as_array()?;
60 let kb = keyboards
61 .iter()
62 .find(|k| {
63 let name = k.get("name").and_then(|n| n.as_str()).unwrap_or("");
64 let layout = k.get("layout").and_then(|l| l.as_str()).unwrap_or("");
65 !layout.is_empty() && (name.contains("translated") || name.contains("at-"))
66 })
67 .or_else(|| {
68 keyboards.iter().find(|k| {
69 let layout = k.get("layout").and_then(|l| l.as_str()).unwrap_or("");
70 !layout.is_empty()
71 })
72 })?;
73 let layout = kb.get("layout")?.as_str()?.to_string();
74 let variant = kb
75 .get("variant")
76 .and_then(|v| v.as_str())
77 .unwrap_or("")
78 .to_string();
79 Some(Self { layout, variant })
80 }
81
82 fn from_sway() -> Option<Self> {
97 let output = Command::new("swaymsg")
98 .args(["-t", "get_inputs", "--raw"])
99 .output()
100 .ok()?;
101 if !output.status.success() {
102 return None;
103 }
104
105 let inputs: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).ok()?;
106
107 let kb = inputs.iter().find(|i| {
111 i.get("type").and_then(|t| t.as_str()) == Some("keyboard")
112 && i.get("xkb_active_layout_name").is_some()
113 })?;
114
115 let layout_names = kb.get("xkb_layout_names")?.as_array()?;
118 let active_idx = kb
119 .get("xkb_active_layout_index")
120 .and_then(|i| i.as_u64())
121 .unwrap_or(0) as usize;
122 let _display_name = layout_names.get(active_idx)?.as_str()?;
123
124 None
126 }
127
128 fn from_x11() -> Option<Self> {
129 if std::env::var_os("DISPLAY").is_none() || std::env::var_os("WAYLAND_DISPLAY").is_some() {
130 return None;
131 }
132
133 let output = match Command::new("setxkbmap").arg("-query").output() {
134 Ok(out) => out,
135 Err(_e) => {
136 debug!("setxkbmap probe failed to spawn: {_e}");
137 return None;
138 }
139 };
140 if !output.status.success() {
141 debug!(
142 "setxkbmap -query exited non-zero: status={:?}",
143 output.status.code()
144 );
145 return None;
146 }
147
148 let stdout = match String::from_utf8(output.stdout) {
149 Ok(s) => s,
150 Err(_e) => {
151 debug!("setxkbmap -query stdout was not valid utf-8: {_e}");
152 return None;
153 }
154 };
155 let parsed = parse_setxkbmap_query(&stdout);
156 if let Some(_kl) = parsed.as_ref() {
157 debug!(
158 "x11 layout detected via setxkbmap: layout={} variant={}",
159 _kl.layout, _kl.variant
160 );
161 } else {
162 debug!("setxkbmap -query returned no parseable layout");
163 }
164 parsed
165 }
166
167 fn from_localectl() -> Option<Self> {
175 let output = match Command::new("localectl").arg("status").output() {
176 Ok(out) => out,
177 Err(_e) => {
178 debug!("localectl probe failed to spawn: {_e}");
179 return None;
180 }
181 };
182 if !output.status.success() {
183 debug!(
184 "localectl status exited non-zero: status={:?}",
185 output.status.code()
186 );
187 return None;
188 }
189
190 let stdout = match String::from_utf8(output.stdout) {
191 Ok(s) => s,
192 Err(_e) => {
193 debug!("localectl status stdout was not valid utf-8: {_e}");
194 return None;
195 }
196 };
197 let parsed = parse_localectl_status(&stdout);
198 if let Some(_kl) = parsed.as_ref() {
199 debug!(
200 "layout detected via localectl: layout={} variant={}",
201 _kl.layout, _kl.variant
202 );
203 } else {
204 debug!("localectl status returned no parseable X11 layout");
205 }
206 parsed
207 }
208
209 fn from_env() -> Option<Self> {
210 let layout = std::env::var("XKB_DEFAULT_LAYOUT").ok()?;
211 if layout.is_empty() {
212 return None;
213 }
214 let variant = std::env::var("XKB_DEFAULT_VARIANT").unwrap_or_default();
215 Some(Self { layout, variant })
216 }
217}
218
219fn parse_setxkbmap_query(output: &str) -> Option<KeyboardLayout> {
220 let mut layout = None;
221 let mut variant = None;
222
223 for line in output.lines() {
224 let Some((key, value)) = line.split_once(':') else {
225 continue;
226 };
227 match key.trim() {
228 "layout" => layout = Some(value.trim().to_string()),
229 "variant" => variant = Some(value.trim().to_string()),
230 _ => {}
231 }
232 }
233
234 let layout = layout.filter(|s| !s.is_empty())?;
235 Some(KeyboardLayout {
236 layout,
237 variant: variant.unwrap_or_default(),
238 })
239}
240
241fn parse_localectl_status(output: &str) -> Option<KeyboardLayout> {
250 let mut layout = None;
251 let mut variant = None;
252
253 for line in output.lines() {
254 let trimmed = line.trim();
255 let Some((key, value)) = trimmed.split_once(':') else {
256 continue;
257 };
258 match key.trim() {
259 "X11 Layout" => layout = Some(value.trim().to_string()),
260 "X11 Variant" => variant = Some(value.trim().to_string()),
261 _ => {}
262 }
263 }
264
265 let layout = layout.filter(|s| !s.is_empty())?;
266 Some(KeyboardLayout {
267 layout,
268 variant: variant.unwrap_or_default(),
269 })
270}
271
272pub struct XkbKeymap {
273 map: HashMap<char, KeyMapping>,
274 level3_keycode: u16,
275}
276
277impl XkbKeymap {
278 pub fn from_layout(detected: &KeyboardLayout) -> anyhow::Result<Self> {
279 let context = xkbcommon::xkb::Context::new(xkbcommon::xkb::CONTEXT_NO_FLAGS);
280 let keymap = xkbcommon::xkb::Keymap::new_from_names(
281 &context,
282 "",
283 "",
284 &detected.layout,
285 &detected.variant,
286 None,
287 xkbcommon::xkb::KEYMAP_COMPILE_NO_FLAGS,
288 )
289 .ok_or_else(|| {
290 anyhow::anyhow!(
291 "failed to create XKB keymap for layout '{}' variant '{}'",
292 detected.layout,
293 detected.variant
294 )
295 })?;
296 let level3_keycode =
301 find_level3_keycode(&keymap).unwrap_or_else(|| evdev::Key::KEY_RIGHTALT.code());
302 let map = build_reverse_map(&keymap);
303 Ok(Self {
304 map,
305 level3_keycode,
306 })
307 }
308
309 pub fn lookup(&self, ch: char) -> Option<&KeyMapping> {
310 self.map.get(&ch)
311 }
312 pub fn level3_keycode(&self) -> u16 {
313 self.level3_keycode
314 }
315 pub fn len(&self) -> usize {
316 self.map.len()
317 }
318 pub fn is_empty(&self) -> bool {
319 self.map.is_empty()
320 }
321}
322
323use xkbcommon::xkb::keysyms::{
324 KEY_ISO_Level3_Shift, KEY_dead_acute, KEY_dead_cedilla, KEY_dead_circumflex,
325 KEY_dead_diaeresis, KEY_dead_grave, KEY_dead_tilde,
326};
327
328const ACCENTED_VIA_DEAD_KEY: &[(char, char, u32)] = &[
329 ('ã', 'a', KEY_dead_tilde),
330 ('õ', 'o', KEY_dead_tilde),
331 ('ñ', 'n', KEY_dead_tilde),
332 ('Ã', 'A', KEY_dead_tilde),
333 ('Õ', 'O', KEY_dead_tilde),
334 ('Ñ', 'N', KEY_dead_tilde),
335 ('á', 'a', KEY_dead_acute),
336 ('é', 'e', KEY_dead_acute),
337 ('í', 'i', KEY_dead_acute),
338 ('ó', 'o', KEY_dead_acute),
339 ('ú', 'u', KEY_dead_acute),
340 ('ý', 'y', KEY_dead_acute),
341 ('Á', 'A', KEY_dead_acute),
342 ('É', 'E', KEY_dead_acute),
343 ('Í', 'I', KEY_dead_acute),
344 ('Ó', 'O', KEY_dead_acute),
345 ('Ú', 'U', KEY_dead_acute),
346 ('â', 'a', KEY_dead_circumflex),
347 ('ê', 'e', KEY_dead_circumflex),
348 ('î', 'i', KEY_dead_circumflex),
349 ('ô', 'o', KEY_dead_circumflex),
350 ('û', 'u', KEY_dead_circumflex),
351 ('Â', 'A', KEY_dead_circumflex),
352 ('Ê', 'E', KEY_dead_circumflex),
353 ('Ô', 'O', KEY_dead_circumflex),
354 ('à', 'a', KEY_dead_grave),
355 ('è', 'e', KEY_dead_grave),
356 ('ì', 'i', KEY_dead_grave),
357 ('ò', 'o', KEY_dead_grave),
358 ('ù', 'u', KEY_dead_grave),
359 ('À', 'A', KEY_dead_grave),
360 ('ä', 'a', KEY_dead_diaeresis),
361 ('ë', 'e', KEY_dead_diaeresis),
362 ('ï', 'i', KEY_dead_diaeresis),
363 ('ö', 'o', KEY_dead_diaeresis),
364 ('ü', 'u', KEY_dead_diaeresis),
365 ('ç', 'c', KEY_dead_cedilla),
366 ('Ç', 'C', KEY_dead_cedilla),
367];
368
369fn find_level3_keycode(keymap: &xkbcommon::xkb::Keymap) -> Option<u16> {
383 let right_alt = evdev::Key::KEY_RIGHTALT.code();
384
385 for raw_keycode in keymap.min_keycode().raw()..=keymap.max_keycode().raw() {
386 let keycode = xkbcommon::xkb::Keycode::new(raw_keycode);
387 let evdev_keycode: u16 = raw_keycode.saturating_sub(8).try_into().unwrap_or(u16::MAX);
388
389 let syms = keymap.key_get_syms_by_level(keycode, 0, 0);
392 if !syms.iter().any(|sym| sym.raw() == KEY_ISO_Level3_Shift) {
393 continue;
394 }
395
396 if evdev_keycode != right_alt {
397 return Some(evdev_keycode);
398 }
399 }
400
401 None
402}
403
404#[allow(non_upper_case_globals)]
405pub(crate) fn build_reverse_map(keymap: &xkbcommon::xkb::Keymap) -> HashMap<char, KeyMapping> {
406 let mut map: HashMap<char, KeyMapping> = HashMap::new();
407 let mut dead_keys: HashMap<u32, KeyTap> = HashMap::new();
408 let min = keymap.min_keycode().raw();
409 let max = keymap.max_keycode().raw();
410 let num_layouts = keymap.num_layouts();
411
412 for raw_keycode in min..=max {
413 let keycode = xkbcommon::xkb::Keycode::new(raw_keycode);
414 for layout in 0..num_layouts {
415 let num_levels = keymap.num_levels_for_key(keycode, layout);
416 for level in 0..num_levels {
417 if level > 3 {
418 continue;
419 }
420 let syms = keymap.key_get_syms_by_level(keycode, layout, level);
421 let evdev_keycode: u16 =
422 raw_keycode.saturating_sub(8).try_into().unwrap_or(u16::MAX);
423 let shift = level == 1 || level == 3;
424 let altgr = level == 2 || level == 3;
425
426 for &sym in syms {
427 let raw = sym.raw();
428 if matches!(
429 raw,
430 KEY_dead_grave
431 | KEY_dead_acute
432 | KEY_dead_circumflex
433 | KEY_dead_tilde
434 | KEY_dead_diaeresis
435 | KEY_dead_cedilla
436 ) {
437 dead_keys.entry(raw).or_insert(KeyTap {
438 keycode: evdev_keycode,
439 shift,
440 altgr,
441 });
442 continue;
443 }
444 let unicode = xkbcommon::xkb::keysym_to_utf32(sym);
445 if unicode == 0 {
446 continue;
447 }
448 if let Some(ch) = char::from_u32(unicode) {
449 map.entry(ch).or_insert(KeyMapping {
450 main: KeyTap {
451 keycode: evdev_keycode,
452 shift,
453 altgr,
454 },
455 follow: None,
456 });
457 }
458 }
459 }
460 }
461 }
462
463 for (ch, dead_sym) in [
465 ('\'', KEY_dead_acute),
466 ('"', KEY_dead_diaeresis),
467 ('~', KEY_dead_tilde),
468 ('`', KEY_dead_grave),
469 ('^', KEY_dead_circumflex),
470 ] {
471 if map.contains_key(&ch) {
472 continue;
473 }
474 if let Some(dk) = dead_keys.get(&dead_sym) {
475 map.insert(
476 ch,
477 KeyMapping {
478 main: *dk,
479 follow: Some(KeyTap {
480 keycode: evdev::Key::KEY_SPACE.code(),
481 shift: false,
482 altgr: false,
483 }),
484 },
485 );
486 }
487 }
488
489 for &(ch, base, dead_sym) in ACCENTED_VIA_DEAD_KEY {
491 if map.contains_key(&ch) {
492 continue;
493 }
494 let Some(dk) = dead_keys.get(&dead_sym) else {
495 continue;
496 };
497 let Some(base_map) = map.get(&base).copied() else {
498 continue;
499 };
500 if base_map.follow.is_some() {
501 continue;
502 }
503 map.insert(
504 ch,
505 KeyMapping {
506 main: *dk,
507 follow: Some(base_map.main),
508 },
509 );
510 }
511
512 map
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 fn us_layout() -> KeyboardLayout {
520 KeyboardLayout {
521 layout: "us".to_string(),
522 variant: String::new(),
523 }
524 }
525
526 fn layout(name: &str, variant: &str) -> KeyboardLayout {
527 KeyboardLayout {
528 layout: name.to_string(),
529 variant: variant.to_string(),
530 }
531 }
532
533 fn assert_key(
534 km: &XkbKeymap,
535 ch: char,
536 expected_keycode: u16,
537 expected_shift: bool,
538 label: &str,
539 ) {
540 assert_key_full(km, ch, expected_keycode, expected_shift, false, label);
541 }
542
543 fn assert_key_full(
544 km: &XkbKeymap,
545 ch: char,
546 expected_keycode: u16,
547 expected_shift: bool,
548 expected_altgr: bool,
549 label: &str,
550 ) {
551 let mapping = km
552 .lookup(ch)
553 .unwrap_or_else(|| panic!("'{ch}' should be in {label} keymap"));
554 assert_eq!(
555 mapping.main.keycode, expected_keycode,
556 "'{ch}' should be at evdev {expected_keycode} on {label}, got {}",
557 mapping.main.keycode
558 );
559 assert_eq!(
560 mapping.main.shift, expected_shift,
561 "'{ch}' shift should be {expected_shift} on {label}"
562 );
563 assert_eq!(
564 mapping.main.altgr, expected_altgr,
565 "'{ch}' altgr should be {expected_altgr} on {label}"
566 );
567 }
568
569 #[test]
570 fn build_us_keymap() {
571 let km = XkbKeymap::from_layout(&us_layout());
572 if let Ok(km) = km {
573 assert!(!km.is_empty(), "keymap should not be empty");
574 assert!(km.lookup('a').is_some(), "'a' should be in the keymap");
575 }
576 }
577
578 #[test]
579 fn parse_x11_setxkbmap_query() {
580 let layout = parse_setxkbmap_query(
581 "rules: evdev\nmodel: pc104\nlayout: us\nvariant: qwerty-fr\n",
582 )
583 .unwrap();
584
585 assert_eq!(layout.layout, "us");
586 assert_eq!(layout.variant, "qwerty-fr");
587 }
588
589 #[test]
590 fn us_qwerty_fr_typeable_via_uinput() {
591 let km = XkbKeymap::from_layout(&layout("us", "qwerty-fr")).unwrap();
592
593 assert_ne!(
599 km.level3_keycode(),
600 evdev::Key::KEY_RIGHTALT.code(),
601 "us:qwerty-fr must use the dedicated XKB <LVL3> keycode for \
602 AltGr, not KEY_RIGHTALT"
603 );
604 assert_key_full(&km, 'é', 17, false, true, "US qwerty-fr");
605
606 for ch in ['ç', 'à', 'é', 'è', 'ù'] {
607 assert!(
608 km.lookup(ch).is_some(),
609 "'{ch}' must be reachable via uinput on us:qwerty-fr"
610 );
611 }
612 }
613
614 #[test]
615 fn shift_mapping_for_uppercase() {
616 let km = XkbKeymap::from_layout(&us_layout());
617 if let Ok(km) = km {
618 if let Some(mapping) = km.lookup('A') {
619 assert!(
620 mapping.main.shift,
621 "uppercase 'A' should require shift on standard layouts"
622 );
623 }
624 }
625 }
626
627 #[test]
630 fn german_layout() {
631 let km = XkbKeymap::from_layout(&layout("de", "")).unwrap();
632 assert_key(&km, 'z', 21, false, "German");
633 assert_key(&km, 'y', 44, false, "German");
634 }
635
636 #[test]
637 fn swiss_layout() {
638 let km = XkbKeymap::from_layout(&layout("ch", "")).unwrap();
639 assert_key(&km, 'z', 21, false, "Swiss");
640 assert_key(&km, 'y', 44, false, "Swiss");
641 }
642
643 #[test]
644 fn czech_layout() {
645 let km = XkbKeymap::from_layout(&layout("cz", "")).unwrap();
646 assert_key(&km, 'z', 21, false, "Czech");
647 assert_key(&km, 'y', 44, false, "Czech");
648 assert_key(&km, 'ů', 39, false, "Czech");
649 }
650
651 #[test]
652 fn slovak_layout() {
653 let km = XkbKeymap::from_layout(&layout("sk", "")).unwrap();
654 assert_key(&km, 'z', 21, false, "Slovak");
655 assert_key(&km, 'y', 44, false, "Slovak");
656 assert_key(&km, 'ô', 39, false, "Slovak");
657 }
658
659 #[test]
660 fn hungarian_layout() {
661 let km = XkbKeymap::from_layout(&layout("hu", "")).unwrap();
662 assert_key(&km, 'z', 21, false, "Hungarian");
663 assert_key(&km, 'y', 44, false, "Hungarian");
664 assert_key(&km, 'ö', 11, false, "Hungarian");
665 assert_key(&km, 'ü', 12, false, "Hungarian");
666 }
667
668 #[test]
671 fn french_layout() {
672 let km = XkbKeymap::from_layout(&layout("fr", "")).unwrap();
673 assert_key(&km, 'a', 16, false, "French");
674 assert_key(&km, 'q', 30, false, "French");
675 assert_key(&km, 'z', 17, false, "French");
676 assert_key(&km, 'w', 44, false, "French");
677 }
678
679 #[test]
680 fn belgian_layout() {
681 let km = XkbKeymap::from_layout(&layout("be", "")).unwrap();
682 assert_key(&km, 'a', 16, false, "Belgian");
683 assert_key(&km, 'q', 30, false, "Belgian");
684 assert_key(&km, 'z', 17, false, "Belgian");
685 assert_key(&km, 'w', 44, false, "Belgian");
686 assert_key(&km, 'm', 39, false, "Belgian");
687 }
688
689 #[test]
692 fn spanish_layout() {
693 let km = XkbKeymap::from_layout(&layout("es", "")).unwrap();
694 assert_key(&km, 'ñ', 39, false, "Spanish");
695 }
696
697 #[test]
698 fn portuguese_layout() {
699 let km = XkbKeymap::from_layout(&layout("pt", "")).unwrap();
700 assert_key(&km, 'a', 30, false, "Portuguese");
701 assert_key(&km, 'z', 44, false, "Portuguese");
702 assert_key(&km, 'q', 16, false, "Portuguese");
703 }
704
705 #[test]
706 fn italian_layout() {
707 let km = XkbKeymap::from_layout(&layout("it", "")).unwrap();
708 assert_key(&km, 'a', 30, false, "Italian");
709 assert_key(&km, 'z', 44, false, "Italian");
710 assert_key(&km, 'q', 16, false, "Italian");
711 assert_key(&km, 'w', 17, false, "Italian");
712 }
713
714 #[test]
715 fn uk_layout() {
716 let km = XkbKeymap::from_layout(&layout("gb", "")).unwrap();
717 assert_key(&km, 'a', 30, false, "UK");
718 assert_key(&km, 'z', 44, false, "UK");
719 assert_key(&km, '#', 43, false, "UK");
721 assert_key(&km, '£', 4, true, "UK");
722 }
723
724 #[test]
727 fn swedish_layout() {
728 let km = XkbKeymap::from_layout(&layout("se", "")).unwrap();
729 assert_key(&km, 'ö', 39, false, "Swedish");
730 assert_key(&km, 'ä', 40, false, "Swedish");
731 }
732
733 #[test]
734 fn norwegian_layout() {
735 let km = XkbKeymap::from_layout(&layout("no", "")).unwrap();
736 assert_key(&km, 'ø', 39, false, "Norwegian");
737 assert_key(&km, 'æ', 40, false, "Norwegian");
738 }
739
740 #[test]
741 fn danish_layout() {
742 let km = XkbKeymap::from_layout(&layout("dk", "")).unwrap();
743 assert_key(&km, 'ø', 40, false, "Danish");
744 assert_key(&km, 'æ', 39, false, "Danish");
745 }
746
747 #[test]
748 fn finnish_layout() {
749 let km = XkbKeymap::from_layout(&layout("fi", "")).unwrap();
750 assert_key(&km, 'ö', 39, false, "Finnish");
751 assert_key(&km, 'ä', 40, false, "Finnish");
752 }
753
754 #[test]
757 fn polish_layout() {
758 let km = XkbKeymap::from_layout(&layout("pl", "")).unwrap();
759 assert_key(&km, 'a', 30, false, "Polish");
760 assert_key(&km, 'z', 44, false, "Polish");
761 assert_key_full(&km, 'ą', 30, false, true, "Polish");
764 assert_key_full(&km, 'ę', 18, false, true, "Polish");
765 }
766
767 #[test]
768 fn us_intl_typeable_via_uinput() {
769 let km = XkbKeymap::from_layout(&KeyboardLayout {
770 layout: "us".to_string(),
771 variant: "intl".to_string(),
772 })
773 .unwrap();
774 for ch in [
779 '\'', '"', '~', '`', '^', 'ç', 'á', 'é', 'í', 'ó', 'ú', 'ã', 'ñ',
780 ] {
781 assert!(
782 km.lookup(ch).is_some(),
783 "'{ch}' must be reachable via uinput on us:intl, got no mapping"
784 );
785 }
786 }
787
788 #[test]
798 fn us_intl_pass1_chars_synthesized_routes_end_with_space() {
799 let km = XkbKeymap::from_layout(&KeyboardLayout {
800 layout: "us".to_string(),
801 variant: "intl".to_string(),
802 })
803 .unwrap();
804 for ch in ['\'', '"', '~', '`', '^'] {
805 let mapping = km
806 .lookup(ch)
807 .unwrap_or_else(|| panic!("'{ch}' must be reachable on us:intl"));
808 if let Some(follow) = mapping.follow {
809 assert_eq!(
810 follow.keycode,
811 evdev::Key::KEY_SPACE.code(),
812 "'{ch}' was synthesized; follow tap must be SPACE \
813 (dead_X + space sequence)"
814 );
815 assert!(
816 !follow.shift && !follow.altgr,
817 "'{ch}' synthesis follow tap must be unmodified SPACE"
818 );
819 }
820 }
821 }
822
823 #[test]
830 fn us_intl_tilde_letter_uses_dead_key_synthesis() {
831 let km = XkbKeymap::from_layout(&KeyboardLayout {
832 layout: "us".to_string(),
833 variant: "intl".to_string(),
834 })
835 .unwrap();
836 let a_main = km.lookup('a').expect("'a' must be in keymap").main;
837 let mapping = km.lookup('ã').expect("ã must be reachable on us:intl");
838 let follow = mapping.follow.expect(
839 "ã on us:intl must be synthesized via dead_tilde + a — \
840 the literal char is not at any level on this layout",
841 );
842 assert_eq!(
843 follow.keycode, a_main.keycode,
844 "ã follow tap must target the same evdev keycode as 'a' \
845 (dead_tilde + a sequence)"
846 );
847 }
848
849 #[test]
853 fn polish_accented_letter_is_direct_not_synthesized() {
854 let km = XkbKeymap::from_layout(&layout("pl", "")).unwrap();
855 let mapping = km.lookup('ą').expect("ą must be reachable on Polish");
856 assert!(
857 mapping.follow.is_none(),
858 "ą on Polish must be a direct AltGr tap, not synthesized — \
859 synthesis pass must not overwrite a direct mapping"
860 );
861 assert!(mapping.main.altgr, "ą on Polish must hold AltGr");
862 }
863
864 #[test]
869 fn spanish_enye_is_direct_not_synthesized() {
870 let km = XkbKeymap::from_layout(&layout("es", "")).unwrap();
871 let mapping = km.lookup('ñ').expect("ñ must be reachable on Spanish");
872 assert!(
873 mapping.follow.is_none(),
874 "ñ on Spanish must be a direct level-0 tap, not synthesized — \
875 synthesis pass must not overwrite even when the char is in \
876 the synthesis table"
877 );
878 assert!(
879 !mapping.main.altgr && !mapping.main.shift,
880 "ñ on Spanish is on a dedicated key — no modifiers required"
881 );
882 }
883
884 #[test]
887 fn dvorak_layout() {
888 let km = XkbKeymap::from_layout(&layout("us", "dvorak")).unwrap();
889 assert_key(&km, 'o', 31, false, "Dvorak");
890 assert_key(&km, 'e', 32, false, "Dvorak");
891 assert_key(&km, 's', 39, false, "Dvorak");
892 }
893
894 #[test]
895 fn colemak_layout() {
896 let km = XkbKeymap::from_layout(&layout("us", "colemak")).unwrap();
897 assert_key(&km, 'f', 18, false, "Colemak");
898 assert_key(&km, 'n', 36, false, "Colemak");
899 assert_key(&km, 's', 32, false, "Colemak");
900 }
901
902 #[test]
905 fn russian_layout() {
906 let km = XkbKeymap::from_layout(&layout("ru", "")).unwrap();
907 assert_key(&km, 'ф', 30, false, "Russian");
908 assert_key(&km, 'я', 44, false, "Russian");
909 assert_key(&km, 'й', 16, false, "Russian");
910 assert_key(&km, 'ц', 17, false, "Russian");
911 }
912
913 #[test]
914 fn ukrainian_layout() {
915 let km = XkbKeymap::from_layout(&layout("ua", "")).unwrap();
916 assert_key(&km, 'ф', 30, false, "Ukrainian");
917 assert_key(&km, 'я', 44, false, "Ukrainian");
918 assert_key(&km, 'й', 16, false, "Ukrainian");
919 assert_key(&km, 'і', 31, false, "Ukrainian");
920 }
921
922 #[test]
923 fn greek_layout() {
924 let km = XkbKeymap::from_layout(&layout("gr", "")).unwrap();
925 assert_key(&km, 'α', 30, false, "Greek");
926 assert_key(&km, 'ζ', 44, false, "Greek");
927 assert_key(&km, 'ω', 47, false, "Greek");
928 }
929
930 #[test]
931 fn japanese_layout() {
932 let km = XkbKeymap::from_layout(&layout("jp", "")).unwrap();
934 assert_key(&km, 'a', 30, false, "Japanese");
935 assert_key(&km, 'z', 44, false, "Japanese");
936 }
937
938 #[test]
947 fn from_sway_always_returns_none() {
948 let result = KeyboardLayout::from_sway();
953 assert!(
954 result.is_none(),
955 "from_sway must return None (display names ≠ XKB codes); got {result:?}"
956 );
957 }
958
959 #[test]
962 fn parse_localectl_us_no_variant() {
963 let output = "\
964System Locale: LANG=en_US.UTF-8
965 VC Keymap: us
966 X11 Layout: us
967 X11 Model: pc105+inet
968 X11 Options: terminate:ctrl_alt_bksp
969";
970 let kl = parse_localectl_status(output).expect("should parse US layout");
971 assert_eq!(kl.layout, "us");
972 assert_eq!(kl.variant, "");
973 }
974
975 #[test]
976 fn parse_localectl_fr_bepo_full() {
977 let output = "\
978System Locale: LANG=en_US.UTF-8
979 VC Keymap: fr-bepo
980 X11 Layout: fr
981 X11 Model: pc104
982 X11 Variant: bepo
983 X11 Options: caps:swapescape,compose:rwin
984";
985 let kl = parse_localectl_status(output).expect("should parse fr(bepo)");
986 assert_eq!(kl.layout, "fr");
987 assert_eq!(kl.variant, "bepo");
988 }
989
990 #[test]
991 fn parse_localectl_empty_layout_returns_none() {
992 let output = "\
993System Locale: LANG=en_US.UTF-8
994 VC Keymap: us
995 X11 Layout:
996 X11 Model: pc105
997";
998 assert!(parse_localectl_status(output).is_none());
999 }
1000
1001 #[test]
1002 fn parse_localectl_missing_x11_section_returns_none() {
1003 let output = "\
1004System Locale: LANG=en_US.UTF-8
1005 VC Keymap: us
1006";
1007 assert!(parse_localectl_status(output).is_none());
1008 }
1009
1010 #[test]
1011 fn parse_localectl_tolerates_whitespace_variants() {
1012 let output = "System Locale: LANG=en_US.UTF-8\n\
1014\t\tVC Keymap: fr-bepo\n\
1015 X11 Layout: fr \n\
1016 X11 Model: pc104\n\
1017\tX11 Variant:\tbepo\n";
1018 let kl = parse_localectl_status(output).expect("should tolerate whitespace mix");
1019 assert_eq!(kl.layout, "fr");
1020 assert_eq!(kl.variant, "bepo");
1021 }
1022
1023 #[test]
1024 fn parse_localectl_ignores_unknown_keys() {
1025 let output = "\
1026System Locale: LANG=en_US.UTF-8
1027 VC Keymap: us
1028 Foo: bar
1029 X11 Layout: us
1030 X11 Model: pc105
1031 Random: line with: multiple colons
1032 X11 Variant: dvorak
1033";
1034 let kl = parse_localectl_status(output).expect("should ignore unknown keys");
1035 assert_eq!(kl.layout, "us");
1036 assert_eq!(kl.variant, "dvorak");
1037 }
1038
1039 #[test]
1049 fn from_localectl_returns_layout_when_available() {
1050 if Command::new("localectl").arg("--version").output().is_err() {
1052 eprintln!("skipping: localectl not on PATH");
1053 return;
1054 }
1055 let result = KeyboardLayout::from_localectl();
1056 let kl = result.expect(
1057 "from_localectl must return Some(_) on a systemd host — \
1058 this is the detection-chain gap that allowed issue #39",
1059 );
1060 assert!(
1061 !kl.layout.is_empty(),
1062 "from_localectl returned Some with empty layout: {kl:?}"
1063 );
1064 }
1065}