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() {
25 return kl;
26 }
27 if let Some(kl) = Self::from_sway() {
28 return kl;
29 }
30 if let Some(kl) = Self::from_x11() {
31 return kl;
32 }
33 if let Some(kl) = Self::from_env() {
34 return kl;
35 }
36 Self {
37 layout: String::new(),
38 variant: String::new(),
39 }
40 }
41
42 fn from_hyprland() -> Option<Self> {
43 let output = Command::new("hyprctl")
44 .args(["devices", "-j"])
45 .output()
46 .ok()?;
47 if !output.status.success() {
48 return None;
49 }
50 let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
51 let keyboards = json.get("keyboards")?.as_array()?;
52 let kb = keyboards
53 .iter()
54 .find(|k| {
55 let name = k.get("name").and_then(|n| n.as_str()).unwrap_or("");
56 let layout = k.get("layout").and_then(|l| l.as_str()).unwrap_or("");
57 !layout.is_empty() && (name.contains("translated") || name.contains("at-"))
58 })
59 .or_else(|| {
60 keyboards.iter().find(|k| {
61 let layout = k.get("layout").and_then(|l| l.as_str()).unwrap_or("");
62 !layout.is_empty()
63 })
64 })?;
65 let layout = kb.get("layout")?.as_str()?.to_string();
66 let variant = kb
67 .get("variant")
68 .and_then(|v| v.as_str())
69 .unwrap_or("")
70 .to_string();
71 Some(Self { layout, variant })
72 }
73
74 fn from_sway() -> Option<Self> {
89 let output = Command::new("swaymsg")
90 .args(["-t", "get_inputs", "--raw"])
91 .output()
92 .ok()?;
93 if !output.status.success() {
94 return None;
95 }
96
97 let inputs: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).ok()?;
98
99 let kb = inputs.iter().find(|i| {
103 i.get("type").and_then(|t| t.as_str()) == Some("keyboard")
104 && i.get("xkb_active_layout_name").is_some()
105 })?;
106
107 let layout_names = kb.get("xkb_layout_names")?.as_array()?;
110 let active_idx = kb
111 .get("xkb_active_layout_index")
112 .and_then(|i| i.as_u64())
113 .unwrap_or(0) as usize;
114 let _display_name = layout_names.get(active_idx)?.as_str()?;
115
116 None
118 }
119
120 fn from_x11() -> Option<Self> {
121 if std::env::var_os("DISPLAY").is_none() || std::env::var_os("WAYLAND_DISPLAY").is_some() {
122 return None;
123 }
124
125 let output = match Command::new("setxkbmap").arg("-query").output() {
126 Ok(out) => out,
127 Err(_e) => {
128 debug!("setxkbmap probe failed to spawn: {_e}");
129 return None;
130 }
131 };
132 if !output.status.success() {
133 debug!(
134 "setxkbmap -query exited non-zero: status={:?}",
135 output.status.code()
136 );
137 return None;
138 }
139
140 let stdout = match String::from_utf8(output.stdout) {
141 Ok(s) => s,
142 Err(_e) => {
143 debug!("setxkbmap -query stdout was not valid utf-8: {_e}");
144 return None;
145 }
146 };
147 let parsed = parse_setxkbmap_query(&stdout);
148 if let Some(_kl) = parsed.as_ref() {
149 debug!(
150 "x11 layout detected via setxkbmap: layout={} variant={}",
151 _kl.layout, _kl.variant
152 );
153 } else {
154 debug!("setxkbmap -query returned no parseable layout");
155 }
156 parsed
157 }
158
159 fn from_env() -> Option<Self> {
160 let layout = std::env::var("XKB_DEFAULT_LAYOUT").ok()?;
161 if layout.is_empty() {
162 return None;
163 }
164 let variant = std::env::var("XKB_DEFAULT_VARIANT").unwrap_or_default();
165 Some(Self { layout, variant })
166 }
167}
168
169fn parse_setxkbmap_query(output: &str) -> Option<KeyboardLayout> {
170 let mut layout = None;
171 let mut variant = None;
172
173 for line in output.lines() {
174 let Some((key, value)) = line.split_once(':') else {
175 continue;
176 };
177 match key.trim() {
178 "layout" => layout = Some(value.trim().to_string()),
179 "variant" => variant = Some(value.trim().to_string()),
180 _ => {}
181 }
182 }
183
184 let layout = layout.filter(|s| !s.is_empty())?;
185 Some(KeyboardLayout {
186 layout,
187 variant: variant.unwrap_or_default(),
188 })
189}
190
191pub struct XkbKeymap {
192 map: HashMap<char, KeyMapping>,
193 level3_keycode: u16,
194}
195
196impl XkbKeymap {
197 pub fn from_layout(detected: &KeyboardLayout) -> anyhow::Result<Self> {
198 let context = xkbcommon::xkb::Context::new(xkbcommon::xkb::CONTEXT_NO_FLAGS);
199 let keymap = xkbcommon::xkb::Keymap::new_from_names(
200 &context,
201 "",
202 "",
203 &detected.layout,
204 &detected.variant,
205 None,
206 xkbcommon::xkb::KEYMAP_COMPILE_NO_FLAGS,
207 )
208 .ok_or_else(|| {
209 anyhow::anyhow!(
210 "failed to create XKB keymap for layout '{}' variant '{}'",
211 detected.layout,
212 detected.variant
213 )
214 })?;
215 let level3_keycode =
220 find_level3_keycode(&keymap).unwrap_or_else(|| evdev::Key::KEY_RIGHTALT.code());
221 let map = build_reverse_map(&keymap);
222 Ok(Self {
223 map,
224 level3_keycode,
225 })
226 }
227
228 pub fn lookup(&self, ch: char) -> Option<&KeyMapping> {
229 self.map.get(&ch)
230 }
231 pub fn level3_keycode(&self) -> u16 {
232 self.level3_keycode
233 }
234 pub fn len(&self) -> usize {
235 self.map.len()
236 }
237 pub fn is_empty(&self) -> bool {
238 self.map.is_empty()
239 }
240}
241
242use xkbcommon::xkb::keysyms::{
243 KEY_ISO_Level3_Shift, KEY_dead_acute, KEY_dead_cedilla, KEY_dead_circumflex,
244 KEY_dead_diaeresis, KEY_dead_grave, KEY_dead_tilde,
245};
246
247const ACCENTED_VIA_DEAD_KEY: &[(char, char, u32)] = &[
248 ('ã', 'a', KEY_dead_tilde),
249 ('õ', 'o', KEY_dead_tilde),
250 ('ñ', 'n', KEY_dead_tilde),
251 ('Ã', 'A', KEY_dead_tilde),
252 ('Õ', 'O', KEY_dead_tilde),
253 ('Ñ', 'N', KEY_dead_tilde),
254 ('á', 'a', KEY_dead_acute),
255 ('é', 'e', KEY_dead_acute),
256 ('í', 'i', KEY_dead_acute),
257 ('ó', 'o', KEY_dead_acute),
258 ('ú', 'u', KEY_dead_acute),
259 ('ý', 'y', KEY_dead_acute),
260 ('Á', 'A', KEY_dead_acute),
261 ('É', 'E', KEY_dead_acute),
262 ('Í', 'I', KEY_dead_acute),
263 ('Ó', 'O', KEY_dead_acute),
264 ('Ú', 'U', KEY_dead_acute),
265 ('â', 'a', KEY_dead_circumflex),
266 ('ê', 'e', KEY_dead_circumflex),
267 ('î', 'i', KEY_dead_circumflex),
268 ('ô', 'o', KEY_dead_circumflex),
269 ('û', 'u', KEY_dead_circumflex),
270 ('Â', 'A', KEY_dead_circumflex),
271 ('Ê', 'E', KEY_dead_circumflex),
272 ('Ô', 'O', KEY_dead_circumflex),
273 ('à', 'a', KEY_dead_grave),
274 ('è', 'e', KEY_dead_grave),
275 ('ì', 'i', KEY_dead_grave),
276 ('ò', 'o', KEY_dead_grave),
277 ('ù', 'u', KEY_dead_grave),
278 ('À', 'A', KEY_dead_grave),
279 ('ä', 'a', KEY_dead_diaeresis),
280 ('ë', 'e', KEY_dead_diaeresis),
281 ('ï', 'i', KEY_dead_diaeresis),
282 ('ö', 'o', KEY_dead_diaeresis),
283 ('ü', 'u', KEY_dead_diaeresis),
284 ('ç', 'c', KEY_dead_cedilla),
285 ('Ç', 'C', KEY_dead_cedilla),
286];
287
288fn find_level3_keycode(keymap: &xkbcommon::xkb::Keymap) -> Option<u16> {
302 let right_alt = evdev::Key::KEY_RIGHTALT.code();
303
304 for raw_keycode in keymap.min_keycode().raw()..=keymap.max_keycode().raw() {
305 let keycode = xkbcommon::xkb::Keycode::new(raw_keycode);
306 let evdev_keycode: u16 = raw_keycode.saturating_sub(8).try_into().unwrap_or(u16::MAX);
307
308 let syms = keymap.key_get_syms_by_level(keycode, 0, 0);
311 if !syms.iter().any(|sym| sym.raw() == KEY_ISO_Level3_Shift) {
312 continue;
313 }
314
315 if evdev_keycode != right_alt {
316 return Some(evdev_keycode);
317 }
318 }
319
320 None
321}
322
323#[allow(non_upper_case_globals)]
324pub(crate) fn build_reverse_map(keymap: &xkbcommon::xkb::Keymap) -> HashMap<char, KeyMapping> {
325 let mut map: HashMap<char, KeyMapping> = HashMap::new();
326 let mut dead_keys: HashMap<u32, KeyTap> = HashMap::new();
327 let min = keymap.min_keycode().raw();
328 let max = keymap.max_keycode().raw();
329 let num_layouts = keymap.num_layouts();
330
331 for raw_keycode in min..=max {
332 let keycode = xkbcommon::xkb::Keycode::new(raw_keycode);
333 for layout in 0..num_layouts {
334 let num_levels = keymap.num_levels_for_key(keycode, layout);
335 for level in 0..num_levels {
336 if level > 3 {
337 continue;
338 }
339 let syms = keymap.key_get_syms_by_level(keycode, layout, level);
340 let evdev_keycode: u16 =
341 raw_keycode.saturating_sub(8).try_into().unwrap_or(u16::MAX);
342 let shift = level == 1 || level == 3;
343 let altgr = level == 2 || level == 3;
344
345 for &sym in syms {
346 let raw = sym.raw();
347 if matches!(
348 raw,
349 KEY_dead_grave
350 | KEY_dead_acute
351 | KEY_dead_circumflex
352 | KEY_dead_tilde
353 | KEY_dead_diaeresis
354 | KEY_dead_cedilla
355 ) {
356 dead_keys.entry(raw).or_insert(KeyTap {
357 keycode: evdev_keycode,
358 shift,
359 altgr,
360 });
361 continue;
362 }
363 let unicode = xkbcommon::xkb::keysym_to_utf32(sym);
364 if unicode == 0 {
365 continue;
366 }
367 if let Some(ch) = char::from_u32(unicode) {
368 map.entry(ch).or_insert(KeyMapping {
369 main: KeyTap {
370 keycode: evdev_keycode,
371 shift,
372 altgr,
373 },
374 follow: None,
375 });
376 }
377 }
378 }
379 }
380 }
381
382 for (ch, dead_sym) in [
384 ('\'', KEY_dead_acute),
385 ('"', KEY_dead_diaeresis),
386 ('~', KEY_dead_tilde),
387 ('`', KEY_dead_grave),
388 ('^', KEY_dead_circumflex),
389 ] {
390 if map.contains_key(&ch) {
391 continue;
392 }
393 if let Some(dk) = dead_keys.get(&dead_sym) {
394 map.insert(
395 ch,
396 KeyMapping {
397 main: *dk,
398 follow: Some(KeyTap {
399 keycode: evdev::Key::KEY_SPACE.code(),
400 shift: false,
401 altgr: false,
402 }),
403 },
404 );
405 }
406 }
407
408 for &(ch, base, dead_sym) in ACCENTED_VIA_DEAD_KEY {
410 if map.contains_key(&ch) {
411 continue;
412 }
413 let Some(dk) = dead_keys.get(&dead_sym) else {
414 continue;
415 };
416 let Some(base_map) = map.get(&base).copied() else {
417 continue;
418 };
419 if base_map.follow.is_some() {
420 continue;
421 }
422 map.insert(
423 ch,
424 KeyMapping {
425 main: *dk,
426 follow: Some(base_map.main),
427 },
428 );
429 }
430
431 map
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 fn us_layout() -> KeyboardLayout {
439 KeyboardLayout {
440 layout: "us".to_string(),
441 variant: String::new(),
442 }
443 }
444
445 fn layout(name: &str, variant: &str) -> KeyboardLayout {
446 KeyboardLayout {
447 layout: name.to_string(),
448 variant: variant.to_string(),
449 }
450 }
451
452 fn assert_key(
453 km: &XkbKeymap,
454 ch: char,
455 expected_keycode: u16,
456 expected_shift: bool,
457 label: &str,
458 ) {
459 assert_key_full(km, ch, expected_keycode, expected_shift, false, label);
460 }
461
462 fn assert_key_full(
463 km: &XkbKeymap,
464 ch: char,
465 expected_keycode: u16,
466 expected_shift: bool,
467 expected_altgr: bool,
468 label: &str,
469 ) {
470 let mapping = km
471 .lookup(ch)
472 .unwrap_or_else(|| panic!("'{ch}' should be in {label} keymap"));
473 assert_eq!(
474 mapping.main.keycode, expected_keycode,
475 "'{ch}' should be at evdev {expected_keycode} on {label}, got {}",
476 mapping.main.keycode
477 );
478 assert_eq!(
479 mapping.main.shift, expected_shift,
480 "'{ch}' shift should be {expected_shift} on {label}"
481 );
482 assert_eq!(
483 mapping.main.altgr, expected_altgr,
484 "'{ch}' altgr should be {expected_altgr} on {label}"
485 );
486 }
487
488 #[test]
489 fn build_us_keymap() {
490 let km = XkbKeymap::from_layout(&us_layout());
491 if let Ok(km) = km {
492 assert!(!km.is_empty(), "keymap should not be empty");
493 assert!(km.lookup('a').is_some(), "'a' should be in the keymap");
494 }
495 }
496
497 #[test]
498 fn parse_x11_setxkbmap_query() {
499 let layout = parse_setxkbmap_query(
500 "rules: evdev\nmodel: pc104\nlayout: us\nvariant: qwerty-fr\n",
501 )
502 .unwrap();
503
504 assert_eq!(layout.layout, "us");
505 assert_eq!(layout.variant, "qwerty-fr");
506 }
507
508 #[test]
509 fn us_qwerty_fr_typeable_via_uinput() {
510 let km = XkbKeymap::from_layout(&layout("us", "qwerty-fr")).unwrap();
511
512 assert_ne!(
518 km.level3_keycode(),
519 evdev::Key::KEY_RIGHTALT.code(),
520 "us:qwerty-fr must use the dedicated XKB <LVL3> keycode for \
521 AltGr, not KEY_RIGHTALT"
522 );
523 assert_key_full(&km, 'é', 17, false, true, "US qwerty-fr");
524
525 for ch in ['ç', 'à', 'é', 'è', 'ù'] {
526 assert!(
527 km.lookup(ch).is_some(),
528 "'{ch}' must be reachable via uinput on us:qwerty-fr"
529 );
530 }
531 }
532
533 #[test]
534 fn shift_mapping_for_uppercase() {
535 let km = XkbKeymap::from_layout(&us_layout());
536 if let Ok(km) = km {
537 if let Some(mapping) = km.lookup('A') {
538 assert!(
539 mapping.main.shift,
540 "uppercase 'A' should require shift on standard layouts"
541 );
542 }
543 }
544 }
545
546 #[test]
549 fn german_layout() {
550 let km = XkbKeymap::from_layout(&layout("de", "")).unwrap();
551 assert_key(&km, 'z', 21, false, "German");
552 assert_key(&km, 'y', 44, false, "German");
553 }
554
555 #[test]
556 fn swiss_layout() {
557 let km = XkbKeymap::from_layout(&layout("ch", "")).unwrap();
558 assert_key(&km, 'z', 21, false, "Swiss");
559 assert_key(&km, 'y', 44, false, "Swiss");
560 }
561
562 #[test]
563 fn czech_layout() {
564 let km = XkbKeymap::from_layout(&layout("cz", "")).unwrap();
565 assert_key(&km, 'z', 21, false, "Czech");
566 assert_key(&km, 'y', 44, false, "Czech");
567 assert_key(&km, 'ů', 39, false, "Czech");
568 }
569
570 #[test]
571 fn slovak_layout() {
572 let km = XkbKeymap::from_layout(&layout("sk", "")).unwrap();
573 assert_key(&km, 'z', 21, false, "Slovak");
574 assert_key(&km, 'y', 44, false, "Slovak");
575 assert_key(&km, 'ô', 39, false, "Slovak");
576 }
577
578 #[test]
579 fn hungarian_layout() {
580 let km = XkbKeymap::from_layout(&layout("hu", "")).unwrap();
581 assert_key(&km, 'z', 21, false, "Hungarian");
582 assert_key(&km, 'y', 44, false, "Hungarian");
583 assert_key(&km, 'ö', 11, false, "Hungarian");
584 assert_key(&km, 'ü', 12, false, "Hungarian");
585 }
586
587 #[test]
590 fn french_layout() {
591 let km = XkbKeymap::from_layout(&layout("fr", "")).unwrap();
592 assert_key(&km, 'a', 16, false, "French");
593 assert_key(&km, 'q', 30, false, "French");
594 assert_key(&km, 'z', 17, false, "French");
595 assert_key(&km, 'w', 44, false, "French");
596 }
597
598 #[test]
599 fn belgian_layout() {
600 let km = XkbKeymap::from_layout(&layout("be", "")).unwrap();
601 assert_key(&km, 'a', 16, false, "Belgian");
602 assert_key(&km, 'q', 30, false, "Belgian");
603 assert_key(&km, 'z', 17, false, "Belgian");
604 assert_key(&km, 'w', 44, false, "Belgian");
605 assert_key(&km, 'm', 39, false, "Belgian");
606 }
607
608 #[test]
611 fn spanish_layout() {
612 let km = XkbKeymap::from_layout(&layout("es", "")).unwrap();
613 assert_key(&km, 'ñ', 39, false, "Spanish");
614 }
615
616 #[test]
617 fn portuguese_layout() {
618 let km = XkbKeymap::from_layout(&layout("pt", "")).unwrap();
619 assert_key(&km, 'a', 30, false, "Portuguese");
620 assert_key(&km, 'z', 44, false, "Portuguese");
621 assert_key(&km, 'q', 16, false, "Portuguese");
622 }
623
624 #[test]
625 fn italian_layout() {
626 let km = XkbKeymap::from_layout(&layout("it", "")).unwrap();
627 assert_key(&km, 'a', 30, false, "Italian");
628 assert_key(&km, 'z', 44, false, "Italian");
629 assert_key(&km, 'q', 16, false, "Italian");
630 assert_key(&km, 'w', 17, false, "Italian");
631 }
632
633 #[test]
634 fn uk_layout() {
635 let km = XkbKeymap::from_layout(&layout("gb", "")).unwrap();
636 assert_key(&km, 'a', 30, false, "UK");
637 assert_key(&km, 'z', 44, false, "UK");
638 assert_key(&km, '#', 43, false, "UK");
640 assert_key(&km, '£', 4, true, "UK");
641 }
642
643 #[test]
646 fn swedish_layout() {
647 let km = XkbKeymap::from_layout(&layout("se", "")).unwrap();
648 assert_key(&km, 'ö', 39, false, "Swedish");
649 assert_key(&km, 'ä', 40, false, "Swedish");
650 }
651
652 #[test]
653 fn norwegian_layout() {
654 let km = XkbKeymap::from_layout(&layout("no", "")).unwrap();
655 assert_key(&km, 'ø', 39, false, "Norwegian");
656 assert_key(&km, 'æ', 40, false, "Norwegian");
657 }
658
659 #[test]
660 fn danish_layout() {
661 let km = XkbKeymap::from_layout(&layout("dk", "")).unwrap();
662 assert_key(&km, 'ø', 40, false, "Danish");
663 assert_key(&km, 'æ', 39, false, "Danish");
664 }
665
666 #[test]
667 fn finnish_layout() {
668 let km = XkbKeymap::from_layout(&layout("fi", "")).unwrap();
669 assert_key(&km, 'ö', 39, false, "Finnish");
670 assert_key(&km, 'ä', 40, false, "Finnish");
671 }
672
673 #[test]
676 fn polish_layout() {
677 let km = XkbKeymap::from_layout(&layout("pl", "")).unwrap();
678 assert_key(&km, 'a', 30, false, "Polish");
679 assert_key(&km, 'z', 44, false, "Polish");
680 assert_key_full(&km, 'ą', 30, false, true, "Polish");
683 assert_key_full(&km, 'ę', 18, false, true, "Polish");
684 }
685
686 #[test]
687 fn us_intl_typeable_via_uinput() {
688 let km = XkbKeymap::from_layout(&KeyboardLayout {
689 layout: "us".to_string(),
690 variant: "intl".to_string(),
691 })
692 .unwrap();
693 for ch in [
698 '\'', '"', '~', '`', '^', 'ç', 'á', 'é', 'í', 'ó', 'ú', 'ã', 'ñ',
699 ] {
700 assert!(
701 km.lookup(ch).is_some(),
702 "'{ch}' must be reachable via uinput on us:intl, got no mapping"
703 );
704 }
705 }
706
707 #[test]
717 fn us_intl_pass1_chars_synthesized_routes_end_with_space() {
718 let km = XkbKeymap::from_layout(&KeyboardLayout {
719 layout: "us".to_string(),
720 variant: "intl".to_string(),
721 })
722 .unwrap();
723 for ch in ['\'', '"', '~', '`', '^'] {
724 let mapping = km
725 .lookup(ch)
726 .unwrap_or_else(|| panic!("'{ch}' must be reachable on us:intl"));
727 if let Some(follow) = mapping.follow {
728 assert_eq!(
729 follow.keycode,
730 evdev::Key::KEY_SPACE.code(),
731 "'{ch}' was synthesized; follow tap must be SPACE \
732 (dead_X + space sequence)"
733 );
734 assert!(
735 !follow.shift && !follow.altgr,
736 "'{ch}' synthesis follow tap must be unmodified SPACE"
737 );
738 }
739 }
740 }
741
742 #[test]
749 fn us_intl_tilde_letter_uses_dead_key_synthesis() {
750 let km = XkbKeymap::from_layout(&KeyboardLayout {
751 layout: "us".to_string(),
752 variant: "intl".to_string(),
753 })
754 .unwrap();
755 let a_main = km.lookup('a').expect("'a' must be in keymap").main;
756 let mapping = km.lookup('ã').expect("ã must be reachable on us:intl");
757 let follow = mapping.follow.expect(
758 "ã on us:intl must be synthesized via dead_tilde + a — \
759 the literal char is not at any level on this layout",
760 );
761 assert_eq!(
762 follow.keycode, a_main.keycode,
763 "ã follow tap must target the same evdev keycode as 'a' \
764 (dead_tilde + a sequence)"
765 );
766 }
767
768 #[test]
772 fn polish_accented_letter_is_direct_not_synthesized() {
773 let km = XkbKeymap::from_layout(&layout("pl", "")).unwrap();
774 let mapping = km.lookup('ą').expect("ą must be reachable on Polish");
775 assert!(
776 mapping.follow.is_none(),
777 "ą on Polish must be a direct AltGr tap, not synthesized — \
778 synthesis pass must not overwrite a direct mapping"
779 );
780 assert!(mapping.main.altgr, "ą on Polish must hold AltGr");
781 }
782
783 #[test]
788 fn spanish_enye_is_direct_not_synthesized() {
789 let km = XkbKeymap::from_layout(&layout("es", "")).unwrap();
790 let mapping = km.lookup('ñ').expect("ñ must be reachable on Spanish");
791 assert!(
792 mapping.follow.is_none(),
793 "ñ on Spanish must be a direct level-0 tap, not synthesized — \
794 synthesis pass must not overwrite even when the char is in \
795 the synthesis table"
796 );
797 assert!(
798 !mapping.main.altgr && !mapping.main.shift,
799 "ñ on Spanish is on a dedicated key — no modifiers required"
800 );
801 }
802
803 #[test]
806 fn dvorak_layout() {
807 let km = XkbKeymap::from_layout(&layout("us", "dvorak")).unwrap();
808 assert_key(&km, 'o', 31, false, "Dvorak");
809 assert_key(&km, 'e', 32, false, "Dvorak");
810 assert_key(&km, 's', 39, false, "Dvorak");
811 }
812
813 #[test]
814 fn colemak_layout() {
815 let km = XkbKeymap::from_layout(&layout("us", "colemak")).unwrap();
816 assert_key(&km, 'f', 18, false, "Colemak");
817 assert_key(&km, 'n', 36, false, "Colemak");
818 assert_key(&km, 's', 32, false, "Colemak");
819 }
820
821 #[test]
824 fn russian_layout() {
825 let km = XkbKeymap::from_layout(&layout("ru", "")).unwrap();
826 assert_key(&km, 'ф', 30, false, "Russian");
827 assert_key(&km, 'я', 44, false, "Russian");
828 assert_key(&km, 'й', 16, false, "Russian");
829 assert_key(&km, 'ц', 17, false, "Russian");
830 }
831
832 #[test]
833 fn ukrainian_layout() {
834 let km = XkbKeymap::from_layout(&layout("ua", "")).unwrap();
835 assert_key(&km, 'ф', 30, false, "Ukrainian");
836 assert_key(&km, 'я', 44, false, "Ukrainian");
837 assert_key(&km, 'й', 16, false, "Ukrainian");
838 assert_key(&km, 'і', 31, false, "Ukrainian");
839 }
840
841 #[test]
842 fn greek_layout() {
843 let km = XkbKeymap::from_layout(&layout("gr", "")).unwrap();
844 assert_key(&km, 'α', 30, false, "Greek");
845 assert_key(&km, 'ζ', 44, false, "Greek");
846 assert_key(&km, 'ω', 47, false, "Greek");
847 }
848
849 #[test]
850 fn japanese_layout() {
851 let km = XkbKeymap::from_layout(&layout("jp", "")).unwrap();
853 assert_key(&km, 'a', 30, false, "Japanese");
854 assert_key(&km, 'z', 44, false, "Japanese");
855 }
856
857 #[test]
866 fn from_sway_always_returns_none() {
867 let result = KeyboardLayout::from_sway();
872 assert!(
873 result.is_none(),
874 "from_sway must return None (display names ≠ XKB codes); got {result:?}"
875 );
876 }
877}