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 #[cfg(feature = "xtest")]
277 group1: Group1Keysyms,
278}
279
280impl XkbKeymap {
281 pub fn from_layout(detected: &KeyboardLayout) -> anyhow::Result<Self> {
282 let context = xkbcommon::xkb::Context::new(xkbcommon::xkb::CONTEXT_NO_FLAGS);
283 let keymap = xkbcommon::xkb::Keymap::new_from_names(
284 &context,
285 "",
286 "",
287 &detected.layout,
288 &detected.variant,
289 None,
290 xkbcommon::xkb::KEYMAP_COMPILE_NO_FLAGS,
291 )
292 .ok_or_else(|| {
293 anyhow::anyhow!(
294 "failed to create XKB keymap for layout '{}' variant '{}'",
295 detected.layout,
296 detected.variant
297 )
298 })?;
299 let level3_keycode =
304 find_level3_keycode(&keymap).unwrap_or_else(|| evdev::Key::KEY_RIGHTALT.code());
305 let map = build_reverse_map(&keymap);
306 Ok(Self {
307 map,
308 level3_keycode,
309 #[cfg(feature = "xtest")]
310 group1: Group1Keysyms::from_keymap(&keymap),
311 })
312 }
313
314 pub fn lookup(&self, ch: char) -> Option<&KeyMapping> {
315 self.map.get(&ch)
316 }
317 #[cfg(feature = "xtest")]
319 pub(crate) fn iter(&self) -> impl Iterator<Item = (char, &KeyMapping)> {
320 self.map.iter().map(|(ch, m)| (*ch, m))
321 }
322 #[cfg(feature = "xtest")]
328 pub(crate) fn group1(&self) -> &Group1Keysyms {
329 &self.group1
330 }
331 #[cfg(feature = "xtest")]
334 pub(crate) fn retain(&mut self, mut keep: impl FnMut(&KeyMapping) -> bool) {
335 self.map.retain(|_, m| keep(m));
336 }
337 pub fn level3_keycode(&self) -> u16 {
338 self.level3_keycode
339 }
340 pub fn len(&self) -> usize {
341 self.map.len()
342 }
343 pub fn is_empty(&self) -> bool {
344 self.map.is_empty()
345 }
346}
347
348use xkbcommon::xkb::keysyms::{
349 KEY_ISO_Level3_Shift, KEY_dead_acute, KEY_dead_cedilla, KEY_dead_circumflex,
350 KEY_dead_diaeresis, KEY_dead_grave, KEY_dead_tilde,
351};
352
353const ACCENTED_VIA_DEAD_KEY: &[(char, char, u32)] = &[
354 ('ã', 'a', KEY_dead_tilde),
355 ('õ', 'o', KEY_dead_tilde),
356 ('ñ', 'n', KEY_dead_tilde),
357 ('Ã', 'A', KEY_dead_tilde),
358 ('Õ', 'O', KEY_dead_tilde),
359 ('Ñ', 'N', KEY_dead_tilde),
360 ('á', 'a', KEY_dead_acute),
361 ('é', 'e', KEY_dead_acute),
362 ('í', 'i', KEY_dead_acute),
363 ('ó', 'o', KEY_dead_acute),
364 ('ú', 'u', KEY_dead_acute),
365 ('ý', 'y', KEY_dead_acute),
366 ('Á', 'A', KEY_dead_acute),
367 ('É', 'E', KEY_dead_acute),
368 ('Í', 'I', KEY_dead_acute),
369 ('Ó', 'O', KEY_dead_acute),
370 ('Ú', 'U', KEY_dead_acute),
371 ('â', 'a', KEY_dead_circumflex),
372 ('ê', 'e', KEY_dead_circumflex),
373 ('î', 'i', KEY_dead_circumflex),
374 ('ô', 'o', KEY_dead_circumflex),
375 ('û', 'u', KEY_dead_circumflex),
376 ('Â', 'A', KEY_dead_circumflex),
377 ('Ê', 'E', KEY_dead_circumflex),
378 ('Ô', 'O', KEY_dead_circumflex),
379 ('à', 'a', KEY_dead_grave),
380 ('è', 'e', KEY_dead_grave),
381 ('ì', 'i', KEY_dead_grave),
382 ('ò', 'o', KEY_dead_grave),
383 ('ù', 'u', KEY_dead_grave),
384 ('À', 'A', KEY_dead_grave),
385 ('ä', 'a', KEY_dead_diaeresis),
386 ('ë', 'e', KEY_dead_diaeresis),
387 ('ï', 'i', KEY_dead_diaeresis),
388 ('ö', 'o', KEY_dead_diaeresis),
389 ('ü', 'u', KEY_dead_diaeresis),
390 ('ç', 'c', KEY_dead_cedilla),
391 ('Ç', 'C', KEY_dead_cedilla),
392];
393
394fn find_level3_keycode(keymap: &xkbcommon::xkb::Keymap) -> Option<u16> {
408 let right_alt = evdev::Key::KEY_RIGHTALT.code();
409
410 for raw_keycode in keymap.min_keycode().raw()..=keymap.max_keycode().raw() {
411 let keycode = xkbcommon::xkb::Keycode::new(raw_keycode);
412 let evdev_keycode: u16 = raw_keycode.saturating_sub(8).try_into().unwrap_or(u16::MAX);
413
414 let syms = keymap.key_get_syms_by_level(keycode, 0, 0);
417 if !syms.iter().any(|sym| sym.raw() == KEY_ISO_Level3_Shift) {
418 continue;
419 }
420
421 if evdev_keycode != right_alt {
422 return Some(evdev_keycode);
423 }
424 }
425
426 None
427}
428
429#[cfg(feature = "xtest")]
433pub(crate) struct Group1Keysyms(pub(crate) HashMap<u16, [u32; 4]>);
434
435#[cfg(feature = "xtest")]
436impl Group1Keysyms {
437 pub(crate) fn from_keymap(keymap: &xkbcommon::xkb::Keymap) -> Self {
439 let levels = (keymap.min_keycode().raw()..=keymap.max_keycode().raw())
440 .filter_map(|raw_keycode| {
441 let keycode = xkbcommon::xkb::Keycode::new(raw_keycode);
442 let evdev_keycode = u16::try_from(raw_keycode.checked_sub(8)?).ok()?;
443 let levels = [0, 1, 2, 3].map(|level| {
444 keymap
445 .key_get_syms_by_level(keycode, 0, level)
446 .first()
447 .map_or(0, |sym| sym.raw())
448 });
449 Some((evdev_keycode, levels))
450 })
451 .collect();
452 Self(levels)
453 }
454
455 pub(crate) fn keysym(&self, tap: &KeyTap) -> u32 {
457 let level = usize::from(tap.shift) | usize::from(tap.altgr) << 1;
459 self.0.get(&tap.keycode).map_or(0, |levels| levels[level])
460 }
461}
462
463#[allow(non_upper_case_globals)]
464pub(crate) fn build_reverse_map(keymap: &xkbcommon::xkb::Keymap) -> HashMap<char, KeyMapping> {
465 let mut map: HashMap<char, KeyMapping> = HashMap::new();
466 let mut dead_keys: HashMap<u32, KeyTap> = HashMap::new();
467 let min = keymap.min_keycode().raw();
468 let max = keymap.max_keycode().raw();
469 let num_layouts = keymap.num_layouts();
470
471 for raw_keycode in min..=max {
472 let keycode = xkbcommon::xkb::Keycode::new(raw_keycode);
473 for layout in 0..num_layouts {
474 let num_levels = keymap.num_levels_for_key(keycode, layout);
475 for level in 0..num_levels {
476 if level > 3 {
477 continue;
478 }
479 let syms = keymap.key_get_syms_by_level(keycode, layout, level);
480 let evdev_keycode: u16 =
481 raw_keycode.saturating_sub(8).try_into().unwrap_or(u16::MAX);
482 let shift = level == 1 || level == 3;
483 let altgr = level == 2 || level == 3;
484
485 for &sym in syms {
486 let raw = sym.raw();
487 if matches!(
488 raw,
489 KEY_dead_grave
490 | KEY_dead_acute
491 | KEY_dead_circumflex
492 | KEY_dead_tilde
493 | KEY_dead_diaeresis
494 | KEY_dead_cedilla
495 ) {
496 dead_keys.entry(raw).or_insert(KeyTap {
497 keycode: evdev_keycode,
498 shift,
499 altgr,
500 });
501 continue;
502 }
503 let unicode = xkbcommon::xkb::keysym_to_utf32(sym);
504 if unicode == 0 {
505 continue;
506 }
507 if let Some(ch) = char::from_u32(unicode) {
508 map.entry(ch).or_insert(KeyMapping {
509 main: KeyTap {
510 keycode: evdev_keycode,
511 shift,
512 altgr,
513 },
514 follow: None,
515 });
516 }
517 }
518 }
519 }
520 }
521
522 for (ch, dead_sym) in [
524 ('\'', KEY_dead_acute),
525 ('"', KEY_dead_diaeresis),
526 ('~', KEY_dead_tilde),
527 ('`', KEY_dead_grave),
528 ('^', KEY_dead_circumflex),
529 ] {
530 if map.contains_key(&ch) {
531 continue;
532 }
533 if let Some(dk) = dead_keys.get(&dead_sym) {
534 map.insert(
535 ch,
536 KeyMapping {
537 main: *dk,
538 follow: Some(KeyTap {
539 keycode: evdev::Key::KEY_SPACE.code(),
540 shift: false,
541 altgr: false,
542 }),
543 },
544 );
545 }
546 }
547
548 for &(ch, base, dead_sym) in ACCENTED_VIA_DEAD_KEY {
550 if map.contains_key(&ch) {
551 continue;
552 }
553 let Some(dk) = dead_keys.get(&dead_sym) else {
554 continue;
555 };
556 let Some(base_map) = map.get(&base).copied() else {
557 continue;
558 };
559 if base_map.follow.is_some() {
560 continue;
561 }
562 map.insert(
563 ch,
564 KeyMapping {
565 main: *dk,
566 follow: Some(base_map.main),
567 },
568 );
569 }
570
571 map
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577
578 fn us_layout() -> KeyboardLayout {
579 KeyboardLayout {
580 layout: "us".to_string(),
581 variant: String::new(),
582 }
583 }
584
585 fn layout(name: &str, variant: &str) -> KeyboardLayout {
586 KeyboardLayout {
587 layout: name.to_string(),
588 variant: variant.to_string(),
589 }
590 }
591
592 fn assert_key(
593 km: &XkbKeymap,
594 ch: char,
595 expected_keycode: u16,
596 expected_shift: bool,
597 label: &str,
598 ) {
599 assert_key_full(km, ch, expected_keycode, expected_shift, false, label);
600 }
601
602 fn assert_key_full(
603 km: &XkbKeymap,
604 ch: char,
605 expected_keycode: u16,
606 expected_shift: bool,
607 expected_altgr: bool,
608 label: &str,
609 ) {
610 let mapping = km
611 .lookup(ch)
612 .unwrap_or_else(|| panic!("'{ch}' should be in {label} keymap"));
613 assert_eq!(
614 mapping.main.keycode, expected_keycode,
615 "'{ch}' should be at evdev {expected_keycode} on {label}, got {}",
616 mapping.main.keycode
617 );
618 assert_eq!(
619 mapping.main.shift, expected_shift,
620 "'{ch}' shift should be {expected_shift} on {label}"
621 );
622 assert_eq!(
623 mapping.main.altgr, expected_altgr,
624 "'{ch}' altgr should be {expected_altgr} on {label}"
625 );
626 }
627
628 #[test]
629 fn build_us_keymap() {
630 let km = XkbKeymap::from_layout(&us_layout());
631 if let Ok(km) = km {
632 assert!(!km.is_empty(), "keymap should not be empty");
633 assert!(km.lookup('a').is_some(), "'a' should be in the keymap");
634 }
635 }
636
637 #[test]
638 fn parse_x11_setxkbmap_query() {
639 let layout = parse_setxkbmap_query(
640 "rules: evdev\nmodel: pc104\nlayout: us\nvariant: qwerty-fr\n",
641 )
642 .unwrap();
643
644 assert_eq!(layout.layout, "us");
645 assert_eq!(layout.variant, "qwerty-fr");
646 }
647
648 #[test]
649 fn us_qwerty_fr_typeable_via_uinput() {
650 let km = XkbKeymap::from_layout(&layout("us", "qwerty-fr")).unwrap();
651
652 assert_ne!(
658 km.level3_keycode(),
659 evdev::Key::KEY_RIGHTALT.code(),
660 "us:qwerty-fr must use the dedicated XKB <LVL3> keycode for \
661 AltGr, not KEY_RIGHTALT"
662 );
663 assert_key_full(&km, 'é', 17, false, true, "US qwerty-fr");
664
665 for ch in ['ç', 'à', 'é', 'è', 'ù'] {
666 assert!(
667 km.lookup(ch).is_some(),
668 "'{ch}' must be reachable via uinput on us:qwerty-fr"
669 );
670 }
671 }
672
673 #[test]
674 fn shift_mapping_for_uppercase() {
675 let km = XkbKeymap::from_layout(&us_layout());
676 if let Ok(km) = km {
677 if let Some(mapping) = km.lookup('A') {
678 assert!(
679 mapping.main.shift,
680 "uppercase 'A' should require shift on standard layouts"
681 );
682 }
683 }
684 }
685
686 #[test]
689 fn german_layout() {
690 let km = XkbKeymap::from_layout(&layout("de", "")).unwrap();
691 assert_key(&km, 'z', 21, false, "German");
692 assert_key(&km, 'y', 44, false, "German");
693 }
694
695 #[test]
696 fn swiss_layout() {
697 let km = XkbKeymap::from_layout(&layout("ch", "")).unwrap();
698 assert_key(&km, 'z', 21, false, "Swiss");
699 assert_key(&km, 'y', 44, false, "Swiss");
700 }
701
702 #[test]
703 fn czech_layout() {
704 let km = XkbKeymap::from_layout(&layout("cz", "")).unwrap();
705 assert_key(&km, 'z', 21, false, "Czech");
706 assert_key(&km, 'y', 44, false, "Czech");
707 assert_key(&km, 'ů', 39, false, "Czech");
708 }
709
710 #[test]
711 fn slovak_layout() {
712 let km = XkbKeymap::from_layout(&layout("sk", "")).unwrap();
713 assert_key(&km, 'z', 21, false, "Slovak");
714 assert_key(&km, 'y', 44, false, "Slovak");
715 assert_key(&km, 'ô', 39, false, "Slovak");
716 }
717
718 #[test]
719 fn hungarian_layout() {
720 let km = XkbKeymap::from_layout(&layout("hu", "")).unwrap();
721 assert_key(&km, 'z', 21, false, "Hungarian");
722 assert_key(&km, 'y', 44, false, "Hungarian");
723 assert_key(&km, 'ö', 11, false, "Hungarian");
724 assert_key(&km, 'ü', 12, false, "Hungarian");
725 }
726
727 #[test]
730 fn french_layout() {
731 let km = XkbKeymap::from_layout(&layout("fr", "")).unwrap();
732 assert_key(&km, 'a', 16, false, "French");
733 assert_key(&km, 'q', 30, false, "French");
734 assert_key(&km, 'z', 17, false, "French");
735 assert_key(&km, 'w', 44, false, "French");
736 }
737
738 #[test]
739 fn belgian_layout() {
740 let km = XkbKeymap::from_layout(&layout("be", "")).unwrap();
741 assert_key(&km, 'a', 16, false, "Belgian");
742 assert_key(&km, 'q', 30, false, "Belgian");
743 assert_key(&km, 'z', 17, false, "Belgian");
744 assert_key(&km, 'w', 44, false, "Belgian");
745 assert_key(&km, 'm', 39, false, "Belgian");
746 }
747
748 #[test]
751 fn spanish_layout() {
752 let km = XkbKeymap::from_layout(&layout("es", "")).unwrap();
753 assert_key(&km, 'ñ', 39, false, "Spanish");
754 }
755
756 #[test]
757 fn portuguese_layout() {
758 let km = XkbKeymap::from_layout(&layout("pt", "")).unwrap();
759 assert_key(&km, 'a', 30, false, "Portuguese");
760 assert_key(&km, 'z', 44, false, "Portuguese");
761 assert_key(&km, 'q', 16, false, "Portuguese");
762 }
763
764 #[test]
765 fn italian_layout() {
766 let km = XkbKeymap::from_layout(&layout("it", "")).unwrap();
767 assert_key(&km, 'a', 30, false, "Italian");
768 assert_key(&km, 'z', 44, false, "Italian");
769 assert_key(&km, 'q', 16, false, "Italian");
770 assert_key(&km, 'w', 17, false, "Italian");
771 }
772
773 #[test]
774 fn uk_layout() {
775 let km = XkbKeymap::from_layout(&layout("gb", "")).unwrap();
776 assert_key(&km, 'a', 30, false, "UK");
777 assert_key(&km, 'z', 44, false, "UK");
778 assert_key(&km, '#', 43, false, "UK");
780 assert_key(&km, '£', 4, true, "UK");
781 }
782
783 #[test]
786 fn swedish_layout() {
787 let km = XkbKeymap::from_layout(&layout("se", "")).unwrap();
788 assert_key(&km, 'ö', 39, false, "Swedish");
789 assert_key(&km, 'ä', 40, false, "Swedish");
790 }
791
792 #[test]
793 fn norwegian_layout() {
794 let km = XkbKeymap::from_layout(&layout("no", "")).unwrap();
795 assert_key(&km, 'ø', 39, false, "Norwegian");
796 assert_key(&km, 'æ', 40, false, "Norwegian");
797 }
798
799 #[test]
800 fn danish_layout() {
801 let km = XkbKeymap::from_layout(&layout("dk", "")).unwrap();
802 assert_key(&km, 'ø', 40, false, "Danish");
803 assert_key(&km, 'æ', 39, false, "Danish");
804 }
805
806 #[test]
807 fn finnish_layout() {
808 let km = XkbKeymap::from_layout(&layout("fi", "")).unwrap();
809 assert_key(&km, 'ö', 39, false, "Finnish");
810 assert_key(&km, 'ä', 40, false, "Finnish");
811 }
812
813 #[test]
816 fn polish_layout() {
817 let km = XkbKeymap::from_layout(&layout("pl", "")).unwrap();
818 assert_key(&km, 'a', 30, false, "Polish");
819 assert_key(&km, 'z', 44, false, "Polish");
820 assert_key_full(&km, 'ą', 30, false, true, "Polish");
823 assert_key_full(&km, 'ę', 18, false, true, "Polish");
824 }
825
826 #[test]
827 fn us_intl_typeable_via_uinput() {
828 let km = XkbKeymap::from_layout(&KeyboardLayout {
829 layout: "us".to_string(),
830 variant: "intl".to_string(),
831 })
832 .unwrap();
833 for ch in [
838 '\'', '"', '~', '`', '^', 'ç', 'á', 'é', 'í', 'ó', 'ú', 'ã', 'ñ',
839 ] {
840 assert!(
841 km.lookup(ch).is_some(),
842 "'{ch}' must be reachable via uinput on us:intl, got no mapping"
843 );
844 }
845 }
846
847 #[test]
857 fn us_intl_pass1_chars_synthesized_routes_end_with_space() {
858 let km = XkbKeymap::from_layout(&KeyboardLayout {
859 layout: "us".to_string(),
860 variant: "intl".to_string(),
861 })
862 .unwrap();
863 for ch in ['\'', '"', '~', '`', '^'] {
864 let mapping = km
865 .lookup(ch)
866 .unwrap_or_else(|| panic!("'{ch}' must be reachable on us:intl"));
867 if let Some(follow) = mapping.follow {
868 assert_eq!(
869 follow.keycode,
870 evdev::Key::KEY_SPACE.code(),
871 "'{ch}' was synthesized; follow tap must be SPACE \
872 (dead_X + space sequence)"
873 );
874 assert!(
875 !follow.shift && !follow.altgr,
876 "'{ch}' synthesis follow tap must be unmodified SPACE"
877 );
878 }
879 }
880 }
881
882 #[test]
889 fn us_intl_tilde_letter_uses_dead_key_synthesis() {
890 let km = XkbKeymap::from_layout(&KeyboardLayout {
891 layout: "us".to_string(),
892 variant: "intl".to_string(),
893 })
894 .unwrap();
895 let a_main = km.lookup('a').expect("'a' must be in keymap").main;
896 let mapping = km.lookup('ã').expect("ã must be reachable on us:intl");
897 let follow = mapping.follow.expect(
898 "ã on us:intl must be synthesized via dead_tilde + a — \
899 the literal char is not at any level on this layout",
900 );
901 assert_eq!(
902 follow.keycode, a_main.keycode,
903 "ã follow tap must target the same evdev keycode as 'a' \
904 (dead_tilde + a sequence)"
905 );
906 }
907
908 #[test]
912 fn polish_accented_letter_is_direct_not_synthesized() {
913 let km = XkbKeymap::from_layout(&layout("pl", "")).unwrap();
914 let mapping = km.lookup('ą').expect("ą must be reachable on Polish");
915 assert!(
916 mapping.follow.is_none(),
917 "ą on Polish must be a direct AltGr tap, not synthesized — \
918 synthesis pass must not overwrite a direct mapping"
919 );
920 assert!(mapping.main.altgr, "ą on Polish must hold AltGr");
921 }
922
923 #[test]
928 fn spanish_enye_is_direct_not_synthesized() {
929 let km = XkbKeymap::from_layout(&layout("es", "")).unwrap();
930 let mapping = km.lookup('ñ').expect("ñ must be reachable on Spanish");
931 assert!(
932 mapping.follow.is_none(),
933 "ñ on Spanish must be a direct level-0 tap, not synthesized — \
934 synthesis pass must not overwrite even when the char is in \
935 the synthesis table"
936 );
937 assert!(
938 !mapping.main.altgr && !mapping.main.shift,
939 "ñ on Spanish is on a dedicated key — no modifiers required"
940 );
941 }
942
943 #[test]
946 fn dvorak_layout() {
947 let km = XkbKeymap::from_layout(&layout("us", "dvorak")).unwrap();
948 assert_key(&km, 'o', 31, false, "Dvorak");
949 assert_key(&km, 'e', 32, false, "Dvorak");
950 assert_key(&km, 's', 39, false, "Dvorak");
951 }
952
953 #[test]
954 fn colemak_layout() {
955 let km = XkbKeymap::from_layout(&layout("us", "colemak")).unwrap();
956 assert_key(&km, 'f', 18, false, "Colemak");
957 assert_key(&km, 'n', 36, false, "Colemak");
958 assert_key(&km, 's', 32, false, "Colemak");
959 }
960
961 #[test]
964 fn russian_layout() {
965 let km = XkbKeymap::from_layout(&layout("ru", "")).unwrap();
966 assert_key(&km, 'ф', 30, false, "Russian");
967 assert_key(&km, 'я', 44, false, "Russian");
968 assert_key(&km, 'й', 16, false, "Russian");
969 assert_key(&km, 'ц', 17, false, "Russian");
970 }
971
972 #[test]
973 fn ukrainian_layout() {
974 let km = XkbKeymap::from_layout(&layout("ua", "")).unwrap();
975 assert_key(&km, 'ф', 30, false, "Ukrainian");
976 assert_key(&km, 'я', 44, false, "Ukrainian");
977 assert_key(&km, 'й', 16, false, "Ukrainian");
978 assert_key(&km, 'і', 31, false, "Ukrainian");
979 }
980
981 #[test]
982 fn greek_layout() {
983 let km = XkbKeymap::from_layout(&layout("gr", "")).unwrap();
984 assert_key(&km, 'α', 30, false, "Greek");
985 assert_key(&km, 'ζ', 44, false, "Greek");
986 assert_key(&km, 'ω', 47, false, "Greek");
987 }
988
989 #[test]
990 fn japanese_layout() {
991 let km = XkbKeymap::from_layout(&layout("jp", "")).unwrap();
993 assert_key(&km, 'a', 30, false, "Japanese");
994 assert_key(&km, 'z', 44, false, "Japanese");
995 }
996
997 #[test]
1006 fn from_sway_always_returns_none() {
1007 let result = KeyboardLayout::from_sway();
1012 assert!(
1013 result.is_none(),
1014 "from_sway must return None (display names ≠ XKB codes); got {result:?}"
1015 );
1016 }
1017
1018 #[test]
1021 fn parse_localectl_us_no_variant() {
1022 let output = "\
1023System Locale: LANG=en_US.UTF-8
1024 VC Keymap: us
1025 X11 Layout: us
1026 X11 Model: pc105+inet
1027 X11 Options: terminate:ctrl_alt_bksp
1028";
1029 let kl = parse_localectl_status(output).expect("should parse US layout");
1030 assert_eq!(kl.layout, "us");
1031 assert_eq!(kl.variant, "");
1032 }
1033
1034 #[test]
1035 fn parse_localectl_fr_bepo_full() {
1036 let output = "\
1037System Locale: LANG=en_US.UTF-8
1038 VC Keymap: fr-bepo
1039 X11 Layout: fr
1040 X11 Model: pc104
1041 X11 Variant: bepo
1042 X11 Options: caps:swapescape,compose:rwin
1043";
1044 let kl = parse_localectl_status(output).expect("should parse fr(bepo)");
1045 assert_eq!(kl.layout, "fr");
1046 assert_eq!(kl.variant, "bepo");
1047 }
1048
1049 #[test]
1050 fn parse_localectl_empty_layout_returns_none() {
1051 let output = "\
1052System Locale: LANG=en_US.UTF-8
1053 VC Keymap: us
1054 X11 Layout:
1055 X11 Model: pc105
1056";
1057 assert!(parse_localectl_status(output).is_none());
1058 }
1059
1060 #[test]
1061 fn parse_localectl_missing_x11_section_returns_none() {
1062 let output = "\
1063System Locale: LANG=en_US.UTF-8
1064 VC Keymap: us
1065";
1066 assert!(parse_localectl_status(output).is_none());
1067 }
1068
1069 #[test]
1070 fn parse_localectl_tolerates_whitespace_variants() {
1071 let output = "System Locale: LANG=en_US.UTF-8\n\
1073\t\tVC Keymap: fr-bepo\n\
1074 X11 Layout: fr \n\
1075 X11 Model: pc104\n\
1076\tX11 Variant:\tbepo\n";
1077 let kl = parse_localectl_status(output).expect("should tolerate whitespace mix");
1078 assert_eq!(kl.layout, "fr");
1079 assert_eq!(kl.variant, "bepo");
1080 }
1081
1082 #[test]
1083 fn parse_localectl_ignores_unknown_keys() {
1084 let output = "\
1085System Locale: LANG=en_US.UTF-8
1086 VC Keymap: us
1087 Foo: bar
1088 X11 Layout: us
1089 X11 Model: pc105
1090 Random: line with: multiple colons
1091 X11 Variant: dvorak
1092";
1093 let kl = parse_localectl_status(output).expect("should ignore unknown keys");
1094 assert_eq!(kl.layout, "us");
1095 assert_eq!(kl.variant, "dvorak");
1096 }
1097
1098 #[test]
1108 fn from_localectl_returns_layout_when_available() {
1109 if Command::new("localectl").arg("--version").output().is_err() {
1111 eprintln!("skipping: localectl not on PATH");
1112 return;
1113 }
1114 let result = KeyboardLayout::from_localectl();
1115 let kl = result.expect(
1116 "from_localectl must return Some(_) on a systemd host — \
1117 this is the detection-chain gap that allowed issue #39",
1118 );
1119 assert!(
1120 !kl.layout.is_empty(),
1121 "from_localectl returned Some with empty layout: {kl:?}"
1122 );
1123 }
1124}