Skip to main content

xkb_type/
keymap.rs

1//! XKB reverse lookup table: char → (Keycode, Modifiers).
2
3use crate::{KeyMapping, KeyTap};
4use std::collections::HashMap;
5use std::process::Command;
6
7// Logging via the `log` crate (only active with `logging` feature),
8// matching the conditional shim used in `keyboard.rs`.
9#[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    /// Query Sway for the active keyboard layout via `swaymsg -t get_inputs`.
75    ///
76    /// Sway's `xkb_layout_names` array exposes *display* names like
77    /// `"German"` or `"English (US)"`, not the XKB layout codes (`"de"`,
78    /// `"us"`) that `xkbcommon` understands. Feeding a display name into
79    /// `Keymap::new_from_names` causes xkbcommon to silently fall back to
80    /// the compile-time default layout — which in turn would
81    /// short-circuit the env-var / `/etc/default/keyboard` fallbacks that
82    /// can produce the *correct* code.
83    ///
84    /// We therefore confirm Sway is reachable (so the function is not
85    /// dead code) but always return `None`, letting the caller fall
86    /// through to the next fallback. If a real display-name → XKB-code
87    /// lookup table is added later, this is the place to wire it up.
88    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        // Confirm there is at least one keyboard input with an active
100        // layout — guards against running this on an X11 session where
101        // swaymsg may exist but not return useful data.
102        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        // Confirm the layout-name array is well-formed; we don't use
108        // the value because it's a display name, not an XKB code.
109        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        // Intentionally fall through — see the doc comment above.
117        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        // Fall back to KEY_RIGHTALT when no dedicated `<LVL3>` key exists
216        // — that's the common case on plain `us`, `de`, etc. where AltGr
217        // *is* RightAlt. Dedicated `<LVL3>` keycodes show up on layouts
218        // like `us:qwerty-fr`.
219        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
288/// Scan the keymap for a key whose **base-level** (layout 0, level 0)
289/// keysym is `ISO_Level3_Shift`. Returns the evdev keycode of the first
290/// such key that is **not** `KEY_RIGHTALT` — that's the dedicated
291/// `<LVL3>` key on layouts like `us:qwerty-fr`.
292///
293/// Returns `None` when there is no dedicated `<LVL3>` key (the common
294/// case: `ISO_Level3_Shift` is bound to `KEY_RIGHTALT` itself, or not at
295/// all). Callers fall back to `KEY_RIGHTALT` in that case.
296///
297/// Restricted to layout 0 / level 0 because `ISO_Level3_Shift` is a
298/// modifier and lives at the base level — scanning every level would
299/// occasionally pick up keys that *produce* a Level3 modifier as a
300/// shifted symbol, which is not what we want.
301fn 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        // Restrict to layout 0 / level 0 — `ISO_Level3_Shift` is a
309        // modifier at the base level.
310        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    // Pass 1: dead_X + Space for literal punctuation
383    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    // Pass 2: dead_X + base_letter for accented letters
409    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        // The real contract: on us:qwerty-fr, AltGr lives behind a
513        // dedicated `<LVL3>` keycode that is *not* KEY_RIGHTALT. The
514        // exact evdev keycode (currently 84 / KEY_KP4) is an XKB-
515        // implementation detail and could shift between xkbcommon
516        // versions, so assert the property rather than the number.
517        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    // --- QWERTZ family (y/z swapped) ---
547
548    #[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    // --- AZERTY family (a/q and z/w swapped) ---
588
589    #[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    // --- QWERTY-based with special characters ---
609
610    #[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        // UK has '#' without shift (evdev 43), unlike US where it's Shift+3.
639        assert_key(&km, '#', 43, false, "UK");
640        assert_key(&km, '£', 4, true, "UK");
641    }
642
643    // --- Nordic layouts ---
644
645    #[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    // --- Eastern European ---
674
675    #[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        // Polish accented characters live at level 2 (AltGr) and now go
681        // through uinput directly, no clipboard fallback needed.
682        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        // Every character that previously had to fall back to clipboard
694        // paste (and was therefore broken in terminals like Alacritty)
695        // must now have a direct uinput route — either as a level 2/3
696        // AltGr key, or via the dead-key + Space fallback table.
697        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    // --- Synthesis routing (locks in direct vs dead-key+follow paths) ---
708
709    /// Pass 1 (literal accent via `dead_X + Space`): for each of the
710    /// chars Pass 1 covers, on us:intl the char must be reachable, and
711    /// — if it ended up routed through synthesis rather than a direct
712    /// level mapping — the follow tap must be unmodified Space.
713    /// Whether any specific char is direct vs synthesized is layout-
714    /// dependent (us:intl puts some literal accents at level 2 directly),
715    /// but the invariant holds: synthesized routes always end with Space.
716    #[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    /// Pass 2 (accented letter via `dead_X + base_letter`): `ã` is not
743    /// reachable at any level on us:intl, so it must be synthesized as
744    /// `dead_tilde + a`. This is the deterministic test that proves
745    /// Pass 2 actually fires; the Polish/Spanish tests below cover the
746    /// no-overwrite invariant for layouts that do expose the char
747    /// directly.
748    #[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    /// Polish exposes `ą` at level 2 directly. The synthesis pass MUST
769    /// NOT overwrite that direct entry — `ą` must keep `follow=None`
770    /// and use AltGr, not synthesize via dead_ogonek + a.
771    #[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    /// Spanish exposes `ñ` at level 0 directly (it's on the dedicated
784    /// `ñ` key). Even though `ñ` is in `ACCENTED_VIA_DEAD_KEY`, the
785    /// synthesis pass must skip it because the direct entry already
786    /// exists — locks in the cross-layout no-overwrite invariant.
787    #[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    // --- Alternative Latin layouts ---
804
805    #[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    // --- Non-Latin layouts ---
822
823    #[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        // Japanese (jp) is QWERTY-based for Latin characters.
852        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    // --- Sway detection regression ---
858
859    /// `KeyboardLayout::from_sway` deliberately returns `None` because
860    /// Sway's `xkb_layout_names` exposes display strings (e.g. `"German"`,
861    /// `"English (US)"`) rather than XKB layout codes (`"de"`, `"us"`).
862    /// Returning `Some(display_name)` would make the caller short-circuit
863    /// the env-var fallback and silently compile against the *default*
864    /// layout, which is the bug this regression test guards against.
865    #[test]
866    fn from_sway_always_returns_none() {
867        // `from_sway` shells out to `swaymsg`; on most CI machines that
868        // command doesn't exist, but if it does we must still see `None`
869        // (the function intentionally never returns `Some` regardless of
870        // what swaymsg replies with).
871        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}