Skip to main content

hyprcorrect_platform/linux/
capture.rs

1//! Linux keystroke capture.
2//!
3//! Reads key events from every keyboard under `/dev/input` via `evdev`
4//! and translates them — honoring the keyboard layout and modifiers via
5//! `xkbcommon` — into [`Key`] values for the keystroke buffer.
6//!
7//! The trigger chord itself (Super+Ctrl+Shift+Alt+letter) is *not*
8//! delivered here — Hyprland intercepts it (via the inline keybind set
9//! up by `hotkey`) and signals the daemon over `SIGUSR1`. Capture only
10//! suppresses the would-be [`Key::Reset`] that the chord's letter
11//! press would otherwise emit, so the buffer survives the chord and
12//! the trigger has a word to fix.
13//!
14//! One OS thread per keyboard device runs for the life of the process;
15//! [`start`] returns the channel they feed.
16//!
17//! Layout note: the keymap is the system / `XKB_DEFAULT_*` default. A
18//! layout configured only in the compositor (e.g. `hyprland.conf`) is
19//! not yet read — that is M5 polish.
20
21use std::collections::HashMap;
22use std::io::ErrorKind;
23use std::sync::Arc;
24use std::sync::Condvar;
25use std::sync::Mutex;
26use std::sync::OnceLock;
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::sync::mpsc::{self, Receiver, Sender};
29use std::thread;
30use std::time::{Duration, Instant};
31
32use evdev::{Device, EventSummary, KeyCode};
33use hyprcorrect_core::{Chord, Key};
34use xkbcommon::xkb;
35
36use crate::linux::chord_capture::ChordCaptureSlot;
37
38/// An error starting keystroke capture.
39#[derive(Debug, thiserror::Error)]
40pub enum CaptureError {
41    /// No keyboard devices were found under `/dev/input`.
42    #[error("no keyboard devices found under /dev/input")]
43    NoKeyboards,
44    /// `/dev/input` devices exist but could not be opened.
45    #[error(
46        "permission denied reading /dev/input — add your user to the 'input' group (`sudo usermod -aG input $USER`) and log back in"
47    )]
48    Permission,
49    /// The keyboard layout could not be compiled by xkbcommon.
50    #[error("could not compile the keyboard layout (xkbcommon)")]
51    Keymap,
52}
53
54/// The trigger chord, expanded into the data capture needs: which
55/// modifier flags must match, and the xkb keysyms (upper- and
56/// lower-case) of the non-modifier key. Capture uses this only to
57/// *suppress* the would-be Reset the chord's key press would
58/// otherwise emit; the Hyprland keybind in `hotkey` is what actually
59/// fires the trigger.
60#[derive(Debug, Clone, Copy)]
61struct TriggerSpec {
62    sym: u32,
63    alt_sym: u32,
64    needs_ctrl: bool,
65    needs_alt: bool,
66    needs_shift: bool,
67    needs_super: bool,
68}
69
70/// Start capturing keystrokes from every keyboard under `/dev/input`.
71///
72/// `chords` is the list of trigger chords the daemon has bound.
73/// Capture uses them to suppress the would-be [`Key::Reset`] that
74/// pressing one of those chords would otherwise emit — without this,
75/// pressing e.g. `Super+Ctrl+Shift+Alt+S` would wipe the buffer
76/// before the sentence-fix gets a chance to read it.
77///
78/// Returns a channel of [`Key`] events. One detached OS thread per
79/// keyboard device feeds the channel for the life of the process;
80/// dropping the [`Receiver`] makes those threads exit.
81///
82/// # Errors
83///
84/// See [`CaptureError`].
85pub fn start(
86    chords: &[Chord],
87    chord_capture: Arc<ChordCaptureSlot>,
88) -> Result<Receiver<Key>, CaptureError> {
89    // Compile the keymap once, up front, so a broken layout fails fast
90    // with a clear error rather than a silent no-events daemon.
91    let keymap_text = {
92        let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
93        let keymap = xkb::Keymap::new_from_names(
94            &context,
95            "",
96            "",
97            "",
98            "",
99            None,
100            xkb::KEYMAP_COMPILE_NO_FLAGS,
101        )
102        .ok_or(CaptureError::Keymap)?;
103        keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1)
104    };
105
106    let triggers: Vec<TriggerSpec> = chords.iter().map(resolve_trigger).collect();
107    let keyboards = keyboard_devices()?;
108    let dedupe = Arc::new(Mutex::new(Dedupe::new()));
109    let hold = Arc::new(HoldTracker::new(query_compositor_repeat()));
110    let mods = MODS_WATCH
111        .get_or_init(|| Arc::new(ModsWatch::new()))
112        .clone();
113    let suspect = caret_suspect_flag();
114    for device in mouse_devices() {
115        let suspect = suspect.clone();
116        thread::spawn(move || read_mouse(device, suspect));
117    }
118    let (tx, rx) = mpsc::channel();
119    for (idx, device) in keyboards.into_iter().enumerate() {
120        let tx = tx.clone();
121        let keymap_text = keymap_text.clone();
122        let triggers = triggers.clone();
123        let chord_capture = chord_capture.clone();
124        let dedupe = dedupe.clone();
125        let hold = hold.clone();
126        let mods = mods.clone();
127        let device_id = idx as u32;
128        thread::spawn(move || {
129            read_device(
130                device,
131                device_id,
132                &keymap_text,
133                &triggers,
134                &chord_capture,
135                &dedupe,
136                &hold,
137                &mods,
138                &tx,
139            )
140        });
141    }
142    Ok(rx)
143}
144
145/// Block up to `timeout` for every chord modifier (Ctrl/Shift/Alt/
146/// Super) to be released across every keyboard device the daemon is
147/// watching. Returns `true` if everything cleared in time, `false` on
148/// timeout.
149///
150/// Called by [`crate::linux::emit`] before each wtype burst: many
151/// Wayland compositors deliver wtype's synthetic keys ORed with the
152/// user's physical modifier state, which would turn each `BackSpace`
153/// into `Ctrl+BackSpace` (delete-word in many terminals) while the
154/// chord is still being held. Waiting for release dodges that.
155///
156/// No-op (returns `true` immediately) before [`start`] has been
157/// called — useful for unit tests of the emit path.
158pub fn wait_mods_clear(timeout: Duration) -> bool {
159    let Some(watch) = MODS_WATCH.get() else {
160        return true;
161    };
162    watch.wait_clear(timeout)
163}
164
165/// Shared flag set by the mouse-listener threads whenever the user
166/// clicks a mouse button, and cleared by the daemon after the next
167/// fix-word emit or buffer reset. When `true`, the daemon's
168/// word-fix path widens its nearby-word scan to the entire buffer
169/// — the buffer's caret tracking can't follow a mouse click, but
170/// the buffer's *text* is still accurate, so scanning all of it
171/// for a typo is the best we can do without OS-level cursor
172/// snooping. Returned as an `Arc` so the daemon can both read and
173/// reset it.
174pub fn caret_suspect_flag() -> Arc<AtomicBool> {
175    CARET_SUSPECT
176        .get_or_init(|| Arc::new(AtomicBool::new(false)))
177        .clone()
178}
179
180/// User-configurable view of which "control" keys reset the
181/// per-window buffer. The daemon passes the current settings via
182/// [`set_reset_keys`] at startup and on every config reload;
183/// `classify` reads it under a `RwLock::read` (essentially free)
184/// to decide whether a given key is a [`Key::Reset`] or just
185/// ignored. Defaults match the safest behavior — every
186/// context-changing key resets except for Tab and Escape, which
187/// rarely change typed text and would otherwise drop the buffer
188/// for no gain.
189#[derive(Debug, Clone, Copy)]
190pub struct ResetKeyConfig {
191    pub enter: bool,
192    pub tab: bool,
193    pub escape: bool,
194    pub up: bool,
195    pub down: bool,
196    pub page_up: bool,
197    pub page_down: bool,
198    pub delete: bool,
199    pub insert: bool,
200}
201
202impl Default for ResetKeyConfig {
203    fn default() -> Self {
204        Self {
205            enter: true,
206            tab: false,
207            escape: false,
208            up: true,
209            down: true,
210            page_up: true,
211            page_down: true,
212            delete: true,
213            insert: true,
214        }
215    }
216}
217
218/// Replace the daemon-wide reset-key config. Cheap (one `RwLock`
219/// write); call at startup and on every config reload.
220pub fn set_reset_keys(cfg: ResetKeyConfig) {
221    *reset_keys_lock().write().expect("reset-keys poisoned") = cfg;
222}
223
224fn reset_keys_lock() -> &'static std::sync::RwLock<ResetKeyConfig> {
225    RESET_KEY_CONFIG.get_or_init(|| std::sync::RwLock::new(ResetKeyConfig::default()))
226}
227
228fn reset_keys() -> ResetKeyConfig {
229    *reset_keys_lock().read().expect("reset-keys poisoned")
230}
231
232static MODS_WATCH: OnceLock<Arc<ModsWatch>> = OnceLock::new();
233static CARET_SUSPECT: OnceLock<Arc<AtomicBool>> = OnceLock::new();
234static RESET_KEY_CONFIG: OnceLock<std::sync::RwLock<ResetKeyConfig>> = OnceLock::new();
235
236/// Shared tracker of which chord modifiers are currently held, keyed
237/// by input-device index. The capture thread per device writes its
238/// xkb modifier mask after every key event; [`wait_mods_clear`]
239/// reads the union.
240struct ModsWatch {
241    inner: Mutex<HashMap<u32, u8>>,
242    cv: Condvar,
243}
244
245const MOD_CTRL: u8 = 1 << 0;
246const MOD_ALT: u8 = 1 << 1;
247const MOD_SHIFT: u8 = 1 << 2;
248const MOD_SUPER: u8 = 1 << 3;
249
250impl ModsWatch {
251    fn new() -> Self {
252        Self {
253            inner: Mutex::new(HashMap::new()),
254            cv: Condvar::new(),
255        }
256    }
257
258    /// Update this device's modifier mask. Notifies the condvar so
259    /// any `wait_clear` caller re-checks.
260    fn update(&self, device_id: u32, mask: u8) {
261        let mut guard = self.inner.lock().expect("mods poisoned");
262        let entry = guard.entry(device_id).or_insert(0);
263        if *entry != mask {
264            *entry = mask;
265            self.cv.notify_all();
266        }
267    }
268
269    /// Returns `true` once every recorded device reports a zero
270    /// mask, or `false` if `timeout` elapses first.
271    fn wait_clear(&self, timeout: Duration) -> bool {
272        let deadline = Instant::now() + timeout;
273        let mut guard = self.inner.lock().expect("mods poisoned");
274        loop {
275            if guard.values().all(|&m| m == 0) {
276                return true;
277            }
278            let now = Instant::now();
279            if now >= deadline {
280                return false;
281            }
282            let (g, res) = self
283                .cv
284                .wait_timeout(guard, deadline - now)
285                .expect("mods poisoned");
286            guard = g;
287            if res.timed_out() && !guard.values().all(|&m| m == 0) {
288                return false;
289            }
290        }
291    }
292}
293
294/// Read the chord-modifier mask from `state`.
295fn mods_mask(state: &xkb::State) -> u8 {
296    let active = |m: &str| state.mod_name_is_active(m, xkb::STATE_MODS_EFFECTIVE);
297    let mut mask = 0;
298    if active(xkb::MOD_NAME_CTRL) {
299        mask |= MOD_CTRL;
300    }
301    if active(xkb::MOD_NAME_ALT) {
302        mask |= MOD_ALT;
303    }
304    if active(xkb::MOD_NAME_SHIFT) {
305        mask |= MOD_SHIFT;
306    }
307    if active(xkb::MOD_NAME_LOGO) {
308        mask |= MOD_SUPER;
309    }
310    mask
311}
312
313/// Auto-repeat tuning the compositor uses to drive Wayland clients.
314/// We mimic these inside the daemon so the buffer's caret tracks
315/// what the TUI is doing, not the kernel's slower evdev repeats.
316#[derive(Debug, Clone, Copy)]
317struct RepeatConfig {
318    /// Initial delay before auto-repeat kicks in. Hyprland default
319    /// is ~600 ms; users often crank it down (the test rig has 225).
320    delay: Duration,
321    /// Repeat interval between synthetic emits.
322    interval: Duration,
323}
324
325impl Default for RepeatConfig {
326    fn default() -> Self {
327        // Common Wayland-compositor defaults — close enough for
328        // setups where `hyprctl getoption` isn't available or fails.
329        Self {
330            delay: Duration::from_millis(600),
331            interval: Duration::from_millis(40), // 25 Hz
332        }
333    }
334}
335
336/// Best-effort: pull `input:repeat_delay` and `input:repeat_rate`
337/// out of Hyprland via `hyprctl getoption -j`. Falls back to the
338/// common Wayland defaults when the call fails or we're not on
339/// Hyprland. Called once at daemon startup; the values are
340/// captured for the lifetime of the process.
341fn query_compositor_repeat() -> RepeatConfig {
342    let mut cfg = RepeatConfig::default();
343    let read = |key: &str| -> Option<i64> {
344        let out = std::process::Command::new("hyprctl")
345            .args(["getoption", "-j", key])
346            .output()
347            .ok()?;
348        if !out.status.success() {
349            return None;
350        }
351        // The "int" field is what we want; cheap textual extract.
352        let text = String::from_utf8_lossy(&out.stdout);
353        text.split("\"int\"")
354            .nth(1)?
355            .split(':')
356            .nth(1)?
357            .split(',')
358            .next()?
359            .trim()
360            .parse::<i64>()
361            .ok()
362    };
363    if let Some(d) = read("input:repeat_delay")
364        && d > 0
365    {
366        cfg.delay = Duration::from_millis(d as u64);
367    }
368    if let Some(r) = read("input:repeat_rate")
369        && r > 0
370    {
371        cfg.interval = Duration::from_millis(1000 / r as u64);
372    }
373    cfg
374}
375
376/// Synthesizes auto-repeats inside the daemon to match the
377/// compositor's repeat rate, since evdev's `value=2` events arrive
378/// at the kernel's slower rate (which the compositor often ignores
379/// in favor of its own timer-driven repeats). For each held key
380/// we spawn a thread that waits the initial delay, then emits
381/// `Key` at the configured interval until the release cancels it.
382///
383/// Cancellation is plumbed through an mpsc channel so the thread
384/// can `recv_timeout` on it — the wait returns the *instant* a
385/// cancel arrives, and the loop layout guarantees we never emit
386/// an extra event after cancellation. (The old `AtomicBool` poll
387/// had a "check-then-send" race that leaked one synthetic emit
388/// per hold, throwing post-release manual presses off by one.)
389struct HoldTracker {
390    repeat: RepeatConfig,
391    active: Mutex<HashMap<u16, Sender<()>>>,
392}
393
394impl HoldTracker {
395    fn new(repeat: RepeatConfig) -> Self {
396        Self {
397            repeat,
398            active: Mutex::new(HashMap::new()),
399        }
400    }
401
402    fn start(&self, code: u16, emit: Key, tx: Sender<Key>) {
403        self.stop(code); // cancel any prior hold on the same key
404        let (cancel_tx, cancel_rx) = mpsc::channel::<()>();
405        self.active
406            .lock()
407            .expect("hold poisoned")
408            .insert(code, cancel_tx);
409        let repeat = self.repeat;
410        thread::spawn(move || {
411            use mpsc::RecvTimeoutError;
412            // Initial delay. Returning `Ok(())` means we got the
413            // cancel signal during the wait — return immediately,
414            // no emit. `Disconnected` means the sender was dropped
415            // (the HoldTracker forgot us) — same idea, return.
416            match cancel_rx.recv_timeout(repeat.delay) {
417                Ok(()) | Err(RecvTimeoutError::Disconnected) => return,
418                Err(RecvTimeoutError::Timeout) => {}
419            }
420            // First synthetic fires at t = delay — matching the
421            // compositor's first auto-repeat. Then each subsequent
422            // wait+emit pair keeps step with compositor at
423            // `interval`.
424            if tx.send(emit).is_err() {
425                return;
426            }
427            loop {
428                // Wait first, emit second: if cancellation arrives
429                // during the wait the recv returns instantly and
430                // we exit without firing the next event. There's
431                // still a tiny race for the FIRST synthetic above
432                // (cancel between delay-end and the send), but
433                // that window is sub-millisecond.
434                match cancel_rx.recv_timeout(repeat.interval) {
435                    Ok(()) | Err(RecvTimeoutError::Disconnected) => return,
436                    Err(RecvTimeoutError::Timeout) => {}
437                }
438                if tx.send(emit).is_err() {
439                    return;
440                }
441            }
442        });
443    }
444
445    fn stop(&self, code: u16) {
446        if let Some(cancel_tx) = self.active.lock().expect("hold poisoned").remove(&code) {
447            // Best effort — if the thread has already exited the
448            // receiver is gone and this send just no-ops.
449            let _ = cancel_tx.send(());
450        }
451    }
452}
453
454/// Cross-device duplicate suppressor. Users with `keyd` (or other
455/// input remappers) often have several evdev devices emitting the
456/// same key: the physical keyboard, the keyd-virtual-keyboard, and
457/// sometimes a third passthrough device. Each carries the same
458/// keycode in lockstep, so the daemon sees 2–3× the events the
459/// compositor delivers to the focused app — and the daemon's
460/// caret runs ahead of the TUI's by the same multiple.
461///
462/// `Dedupe` collapses runs of the same `(keycode, value)` event
463/// arriving within a short window across any device. The first
464/// event in the window is forwarded; the rest are dropped.
465struct Dedupe {
466    last: Option<(u16, i32, Instant)>,
467}
468
469impl Dedupe {
470    fn new() -> Self {
471        Self { last: None }
472    }
473
474    /// Returns `true` if this event should be processed (not a
475    /// near-duplicate of the last one we saw). The window is short
476    /// enough that legitimate manual repeats (typing the same key
477    /// twice quickly) still come through.
478    fn allow(&mut self, code: u16, value: i32) -> bool {
479        const WINDOW: Duration = Duration::from_millis(8);
480        let now = Instant::now();
481        let is_dup = matches!(
482            self.last,
483            Some((last_code, last_value, last_time))
484                if last_code == code && last_value == value && now - last_time < WINDOW
485        );
486        let allow = !is_dup;
487        if allow {
488            self.last = Some((code, value, now));
489        }
490        allow
491    }
492}
493
494/// Resolve the trigger spec for the given chord. Bare modifiers (no
495/// non-modifier key) are degenerate; in that case both `sym` fields
496/// are 0 and `letter_match` below never fires.
497fn resolve_trigger(chord: &Chord) -> TriggerSpec {
498    let sym = xkb::keysym_from_name(&chord.key, xkb::KEYSYM_CASE_INSENSITIVE).raw();
499    let alt_sym = match sym {
500        0x61..=0x7A => sym - 0x20,
501        0x41..=0x5A => sym + 0x20,
502        _ => 0,
503    };
504    TriggerSpec {
505        sym,
506        alt_sym,
507        needs_ctrl: chord.ctrl,
508        needs_alt: chord.alt,
509        needs_shift: chord.shift,
510        needs_super: chord.super_,
511    }
512}
513
514/// Enumerate `/dev/input` and return the devices that look like
515/// keyboards (those that can emit letter keys).
516fn keyboard_devices() -> Result<Vec<Device>, CaptureError> {
517    let entries = match std::fs::read_dir("/dev/input") {
518        Ok(entries) => entries,
519        Err(e) if e.kind() == ErrorKind::PermissionDenied => {
520            return Err(CaptureError::Permission);
521        }
522        Err(_) => return Err(CaptureError::NoKeyboards),
523    };
524
525    let mut keyboards = Vec::new();
526    let mut permission_denied = false;
527    for entry in entries.flatten() {
528        let path = entry.path();
529        let is_event_node = path
530            .file_name()
531            .and_then(|n| n.to_str())
532            .is_some_and(|n| n.starts_with("event"));
533        if !is_event_node {
534            continue;
535        }
536        match Device::open(&path) {
537            Ok(device) if is_keyboard(&device) => keyboards.push(device),
538            Ok(_) => {}
539            Err(e) if e.kind() == ErrorKind::PermissionDenied => {
540                permission_denied = true;
541            }
542            Err(_) => {}
543        }
544    }
545
546    if !keyboards.is_empty() {
547        Ok(keyboards)
548    } else if permission_denied {
549        Err(CaptureError::Permission)
550    } else {
551        Err(CaptureError::NoKeyboards)
552    }
553}
554
555/// A device is treated as a keyboard if it can emit letter keys.
556fn is_keyboard(device: &Device) -> bool {
557    device
558        .supported_keys()
559        .is_some_and(|keys| keys.contains(KeyCode::KEY_A))
560}
561
562/// Enumerate `/dev/input` and return the devices that look like
563/// mice — those that can emit `BTN_LEFT`. Best-effort: any error
564/// (permission denied, missing /dev/input, …) yields an empty
565/// list rather than failing the whole daemon, since mouse
566/// listening is a UX-improvement add-on, not a hard requirement.
567fn mouse_devices() -> Vec<Device> {
568    let Ok(entries) = std::fs::read_dir("/dev/input") else {
569        return Vec::new();
570    };
571    let mut mice = Vec::new();
572    for entry in entries.flatten() {
573        let path = entry.path();
574        let is_event_node = path
575            .file_name()
576            .and_then(|n| n.to_str())
577            .is_some_and(|n| n.starts_with("event"));
578        if !is_event_node {
579            continue;
580        }
581        if let Ok(device) = Device::open(&path)
582            && is_mouse(&device)
583        {
584            mice.push(device);
585        }
586    }
587    mice
588}
589
590/// A device is treated as a mouse if it advertises `BTN_LEFT`.
591fn is_mouse(device: &Device) -> bool {
592    device
593        .supported_keys()
594        .is_some_and(|keys| keys.contains(KeyCode::BTN_LEFT))
595}
596
597/// Read one mouse forever; set [`caret_suspect_flag`] on every
598/// `BTN_LEFT` press. The daemon reads the flag in its word-fix
599/// path to widen the nearby-word scan when the buffer caret may
600/// have drifted from the visible cursor (the buffer doesn't see
601/// mouse clicks, so without this signal it has no idea the
602/// cursor moved).
603fn read_mouse(mut device: Device, suspect: Arc<AtomicBool>) {
604    loop {
605        let Ok(events) = device.fetch_events() else {
606            return;
607        };
608        for input in events {
609            if let EventSummary::Key(_, code, value) = input.destructure()
610                && code == KeyCode::BTN_LEFT
611                && value == 1
612            {
613                suspect.store(true, Ordering::Relaxed);
614            }
615        }
616    }
617}
618
619/// Read one device forever, translating key events into [`Key`]s and
620/// sending them to `tx`. Returns — ending the thread — when the device
621/// disappears or the receiver is dropped.
622#[allow(clippy::too_many_arguments)]
623fn read_device(
624    mut device: Device,
625    device_id: u32,
626    keymap_text: &str,
627    triggers: &[TriggerSpec],
628    chord_capture: &ChordCaptureSlot,
629    dedupe: &Mutex<Dedupe>,
630    hold: &HoldTracker,
631    mods: &ModsWatch,
632    tx: &Sender<Key>,
633) {
634    let _device_name = device
635        .name()
636        .map(|s| s.to_string())
637        .unwrap_or_else(|| "<unnamed>".to_string());
638    // Each thread builds its own xkb state: Context/Keymap/State hold
639    // raw pointers and are not Send, so they cannot cross the thread
640    // boundary. The keymap text was already validated by `start`.
641    let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
642    let Some(keymap) = xkb::Keymap::new_from_string(
643        &context,
644        keymap_text.to_owned(),
645        xkb::KEYMAP_FORMAT_TEXT_V1,
646        xkb::KEYMAP_COMPILE_NO_FLAGS,
647    ) else {
648        return;
649    };
650    let mut state = xkb::State::new(&keymap);
651
652    loop {
653        let Ok(events) = device.fetch_events() else {
654            return;
655        };
656        for input in events {
657            let EventSummary::Key(_, code, value) = input.destructure() else {
658                continue;
659            };
660            let keycode = xkb::Keycode::new(u32::from(code.0) + 8);
661
662            // Drop near-duplicate events from sibling devices (keyd
663            // virtual keyboard + physical, etc.) — see `Dedupe`.
664            // Applied to BOTH press and release so xkb state doesn't
665            // double-toggle modifiers either.
666            if !dedupe.lock().expect("dedupe poisoned").allow(code.0, value) {
667                continue;
668            }
669
670            // Drop kernel auto-repeats. We synthesize our own at the
671            // compositor's rate (see `HoldTracker`) so the buffer's
672            // caret tracks what the focused app actually sees,
673            // not what evdev's slower kernel-driven repeat fires.
674            if value == 2 {
675                continue;
676            }
677
678            // value: 0 = release, 1 = press. Read the key from the
679            // *current* state, before this key updates it
680            // (the xkbcommon convention).
681            if value == 1 {
682                // Chord-record mode pre-empts normal Key handling so
683                // pressing the chord doesn't reset the buffer or fire
684                // any trigger while prefs is recording.
685                if chord_capture.is_armed()
686                    && let Some(chord) = chord_from_state(&state, keycode)
687                    && chord_capture.try_emit(chord)
688                {
689                    // Modifier state still needs to update below.
690                } else if let Some(key) = translate(&state, keycode, triggers) {
691                    if tx.send(key).is_err() {
692                        return; // receiver dropped
693                    }
694                    // Start synthetic auto-repeats for this key so
695                    // holding it advances the buffer caret in sync
696                    // with the compositor's repeats.
697                    hold.start(code.0, key, tx.clone());
698                }
699            } else {
700                // value == 0 (release). Stop any hold thread for
701                // this key so the buffer stops auto-advancing.
702                hold.stop(code.0);
703            }
704
705            // Track modifier state changes on press and release.
706            let direction = if value == 0 {
707                xkb::KeyDirection::Up
708            } else {
709                xkb::KeyDirection::Down
710            };
711            state.update_key(keycode, direction);
712
713            // Publish this device's current chord-mod mask so the
714            // emit path can wait for everything to clear before
715            // typing — otherwise wtype's BackSpaces inherit the
716            // held Ctrl/etc. and turn into delete-word.
717            mods.update(device_id, mods_mask(&state));
718        }
719    }
720}
721
722/// Translate a pressed key into a [`Key`] for the buffer, or `None` to
723/// ignore it.
724fn translate(state: &xkb::State, keycode: xkb::Keycode, triggers: &[TriggerSpec]) -> Option<Key> {
725    let sym = state.key_get_one_sym(keycode).raw();
726
727    // Modifier keys themselves are never buffered and never reset —
728    // they only affect xkb state, which `read_device` updates after
729    // this call.
730    if is_modifier_keysym(sym) {
731        return None;
732    }
733
734    // Any of the daemon's bound chords match this key+modifier combo?
735    // Suppress so pressing the chord doesn't ALSO reset the buffer
736    // via the has_action_modifier branch below. Hyprland fires the
737    // trigger separately (via SIGUSR1).
738    let chord_match = triggers.iter().any(|trigger| {
739        let letter_match = trigger.sym != 0
740            && (sym == trigger.sym || (trigger.alt_sym != 0 && sym == trigger.alt_sym));
741        letter_match && is_trigger_chord(state, *trigger)
742    });
743    if chord_match {
744        return None;
745    }
746
747    // Ctrl+Left / Ctrl+Right (with no Alt/Super) is the universal
748    // "jump by word" shortcut. Track those as word-boundary caret
749    // moves rather than treating them as a reset — otherwise the
750    // buffer goes blind every time the user word-jumps to fix a
751    // typo. Shift may also be held (selection extension), but that
752    // doesn't change where the caret ends up.
753    {
754        use xkb::keysyms::{KEY_Left, KEY_Right};
755        let active = |m: &str| state.mod_name_is_active(m, xkb::STATE_MODS_EFFECTIVE);
756        let ctrl_only =
757            active(xkb::MOD_NAME_CTRL) && !active(xkb::MOD_NAME_ALT) && !active(xkb::MOD_NAME_LOGO);
758        if ctrl_only {
759            if sym == KEY_Left {
760                return Some(Key::WordLeft);
761            }
762            if sym == KEY_Right {
763                return Some(Key::WordRight);
764            }
765        }
766    }
767
768    // A non-modifier key pressed while Ctrl/Alt/Super is held is a
769    // shortcut, not typed text — and it may have moved the caret or
770    // edited. Reset.
771    if has_action_modifier(state) {
772        return Some(Key::Reset);
773    }
774
775    classify(sym, &state.key_get_utf8(keycode))
776}
777
778/// Build the chord string for a key pressed in chord-record mode:
779/// the currently-held SUPER/CTRL/SHIFT/ALT modifiers, plus the
780/// canonical name of the non-modifier key. Returns `None` for
781/// modifier-only presses so prefs can keep recording until the user
782/// hits a real key.
783///
784/// Format matches [`hyprcorrect_core::Chord::parse`] exactly and uses
785/// the canonical modifier order, e.g.
786/// `"CTRL+SHIFT+ALT+SUPER+F"` or `"CTRL+SPACE"` or bare `"F1"`.
787fn chord_from_state(state: &xkb::State, keycode: xkb::Keycode) -> Option<String> {
788    let sym = state.key_get_one_sym(keycode).raw();
789    if is_modifier_keysym(sym) {
790        return None;
791    }
792    let key_token = chord_key_token(sym)?;
793
794    let active = |m: &str| state.mod_name_is_active(m, xkb::STATE_MODS_EFFECTIVE);
795    // Canonical order — CTRL, SHIFT, ALT, SUPER — matching
796    // `hyprcorrect_core::Chord::Display` and `hyprland_modifiers` so a
797    // freshly recorded chord round-trips to the same string the rest of
798    // the app renders.
799    let mut parts: Vec<&str> = Vec::new();
800    if active(xkb::MOD_NAME_CTRL) {
801        parts.push("CTRL");
802    }
803    if active(xkb::MOD_NAME_SHIFT) {
804        parts.push("SHIFT");
805    }
806    if active(xkb::MOD_NAME_ALT) {
807        parts.push("ALT");
808    }
809    if active(xkb::MOD_NAME_LOGO) {
810        parts.push("SUPER");
811    }
812    Some(if parts.is_empty() {
813        key_token
814    } else {
815        format!("{}+{key_token}", parts.join("+"))
816    })
817}
818
819/// The token used in chord strings for a non-modifier keysym.
820/// Letters become uppercase ASCII; common named keys (Space, F-row,
821/// arrows, etc.) use the same UPPERCASE tokens
822/// [`hyprcorrect_core::Chord::parse`] accepts; anything else falls
823/// back to xkb's canonical keysym name uppercased.
824fn chord_key_token(sym: u32) -> Option<String> {
825    // Canonical chord tokens. Match the form used by vernier and
826    // the rest of the hyprcorrect UI so the recorded chord round-
827    // trips cleanly and the chip renderer doesn't need a second
828    // translation layer. Hyprland accepts the long xkb keysym
829    // names (Escape, Return, BackSpace, ...) case-insensitively,
830    // so the only tokens that need translation back to xkb names
831    // at hyprctl-bind time are ESC and ENTER — handled in
832    // `Chord::hyprland_key`.
833    let named = match sym {
834        0xff1b => Some("ESC"),            // Escape
835        0xff0d | 0xff8d => Some("ENTER"), // Return / KP_Enter
836        0xff09 => Some("TAB"),            // Tab
837        0xff08 => Some("BACKSPACE"),      // BackSpace
838        0xffff => Some("DELETE"),         // Delete
839        0xff52 => Some("UP"),             // Up
840        0xff54 => Some("DOWN"),           // Down
841        0xff51 => Some("LEFT"),           // Left
842        0xff53 => Some("RIGHT"),          // Right
843        0x20 => Some("SPACE"),            // space
844        0x2b => Some("PLUS"),             // +  (avoid colliding with the modifier separator)
845        0x2d => Some("MINUS"),            // -
846        0x3d => Some("EQUAL"),            // =
847        _ => None,
848    };
849    if let Some(token) = named {
850        return Some(token.to_string());
851    }
852    if (0x21..=0x7E).contains(&sym) {
853        // Printable ASCII keysyms (letters, digits, punctuation) are
854        // identical to their codepoint; lowercase letters get folded
855        // up so `Chord::parse` round-trips.
856        let ch = char::from_u32(sym)?.to_ascii_uppercase();
857        return Some(ch.to_string());
858    }
859    let name = xkb::keysym_get_name(xkb::Keysym::from(sym));
860    if name.is_empty() {
861        return None;
862    }
863    Some(name.to_ascii_uppercase())
864}
865
866/// `true` when the currently-held modifier set matches the trigger
867/// chord's modifier set exactly.
868fn is_trigger_chord(state: &xkb::State, trigger: TriggerSpec) -> bool {
869    let active = |m: &str| state.mod_name_is_active(m, xkb::STATE_MODS_EFFECTIVE);
870    active(xkb::MOD_NAME_CTRL) == trigger.needs_ctrl
871        && active(xkb::MOD_NAME_ALT) == trigger.needs_alt
872        && active(xkb::MOD_NAME_SHIFT) == trigger.needs_shift
873        && active(xkb::MOD_NAME_LOGO) == trigger.needs_super
874}
875
876/// `true` if Ctrl, Alt, or Super is currently held.
877fn has_action_modifier(state: &xkb::State) -> bool {
878    let active = |m: &str| state.mod_name_is_active(m, xkb::STATE_MODS_EFFECTIVE);
879    active(xkb::MOD_NAME_CTRL) || active(xkb::MOD_NAME_ALT) || active(xkb::MOD_NAME_LOGO)
880}
881
882/// `true` if `sym` is one of the modifier keysyms — Shift, Control,
883/// Caps Lock, Meta, Alt, Super, or Hyper (left or right). xkb assigns
884/// these the contiguous range `0xffe1..=0xffee`.
885fn is_modifier_keysym(sym: u32) -> bool {
886    (0xffe1..=0xffee).contains(&sym)
887}
888
889/// Resolve "does this keysym reset the buffer right now" against
890/// the user's prefs-driven [`ResetKeyConfig`]. Cheap (one RwLock
891/// read) so `classify` can call it on every keystroke.
892fn reset_for_keysym(sym: u32) -> bool {
893    use xkb::keysyms::{
894        KEY_Delete, KEY_Down, KEY_Escape, KEY_ISO_Left_Tab, KEY_Insert, KEY_KP_Enter, KEY_Linefeed,
895        KEY_Next, KEY_Prior, KEY_Return, KEY_Tab, KEY_Up,
896    };
897    let cfg = reset_keys();
898    matches!(
899        sym,
900        s if (s == KEY_Return || s == KEY_KP_Enter || s == KEY_Linefeed) && cfg.enter
901    ) || matches!(sym, s if (s == KEY_Tab || s == KEY_ISO_Left_Tab) && cfg.tab)
902        || matches!(sym, s if s == KEY_Escape && cfg.escape)
903        || matches!(sym, s if s == KEY_Up && cfg.up)
904        || matches!(sym, s if s == KEY_Down && cfg.down)
905        || matches!(sym, s if s == KEY_Prior && cfg.page_up)
906        || matches!(sym, s if s == KEY_Next && cfg.page_down)
907        || matches!(sym, s if s == KEY_Delete && cfg.delete)
908        || matches!(sym, s if s == KEY_Insert && cfg.insert)
909}
910
911/// Classify an xkb keysym and the UTF-8 it produces into a buffer
912/// [`Key`]: Backspace and caret-moving keys are matched by keysym; a
913/// single printable character becomes a `Char`; everything else (bare
914/// modifiers, function keys) is ignored.
915fn classify(sym: u32, utf8: &str) -> Option<Key> {
916    use xkb::keysyms::{KEY_BackSpace, KEY_End, KEY_Home, KEY_Left, KEY_Right};
917    // Left/Right arrow press translates to a buffer caret move,
918    // and Home/End jump to the line edges (single-line context: a
919    // safe approximation for the buffer, since we reset on
920    // Return/Enter anyway). Ctrl+arrow word-jumps are detected
921    // upstream in `translate`. The remaining context-changing
922    // keys (Enter/Tab/Esc/Up/Down/PageUp/PageDown/Delete/Insert)
923    // reset the buffer when the user has them toggled on in
924    // prefs — see `ResetKeyConfig`.
925
926    if sym == KEY_BackSpace {
927        Some(Key::Backspace)
928    } else if sym == KEY_Left {
929        Some(Key::MoveLeft)
930    } else if sym == KEY_Right {
931        Some(Key::MoveRight)
932    } else if sym == KEY_Home {
933        Some(Key::LineStart)
934    } else if sym == KEY_End {
935        Some(Key::LineEnd)
936    } else if reset_for_keysym(sym) {
937        Some(Key::Reset)
938    } else {
939        let mut chars = utf8.chars();
940        match (chars.next(), chars.next()) {
941            (Some(c), None) if !c.is_control() => Some(Key::Char(c)),
942            _ => None,
943        }
944    }
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950    use xkb::keysyms::{
951        KEY_BackSpace, KEY_End, KEY_Escape, KEY_Home, KEY_Left, KEY_Return, KEY_Right, KEY_Tab,
952        KEY_Up,
953    };
954
955    #[test]
956    fn backspace_keysym_maps_to_backspace() {
957        assert_eq!(classify(KEY_BackSpace, ""), Some(Key::Backspace));
958    }
959
960    #[test]
961    fn left_right_arrows_move_the_caret() {
962        assert_eq!(classify(KEY_Left, ""), Some(Key::MoveLeft));
963        assert_eq!(classify(KEY_Right, ""), Some(Key::MoveRight));
964    }
965
966    #[test]
967    fn home_and_end_jump_to_line_edges() {
968        assert_eq!(classify(KEY_Home, ""), Some(Key::LineStart));
969        assert_eq!(classify(KEY_End, ""), Some(Key::LineEnd));
970    }
971
972    // The reset-key classifier reads a process-global `RwLock`
973    // (see `set_reset_keys`), so default + toggled assertions
974    // share state across tests. Cargo runs tests in parallel
975    // within a binary, which would race if we used three
976    // separate `#[test]` fns — combine them into one and
977    // explicitly set the config at every checkpoint.
978    #[test]
979    fn reset_key_classifier_honors_config() {
980        // Defaults: Enter/Up reset, Tab/Esc ignored.
981        set_reset_keys(ResetKeyConfig::default());
982        assert_eq!(classify(KEY_Return, ""), Some(Key::Reset));
983        assert_eq!(classify(KEY_Up, ""), Some(Key::Reset));
984        assert_eq!(classify(KEY_Tab, "\t"), None);
985        assert_eq!(classify(KEY_Escape, "\u{1b}"), None);
986
987        // Flip Tab/Esc on, Enter off — classify mirrors the
988        // new config.
989        set_reset_keys(ResetKeyConfig {
990            enter: false,
991            tab: true,
992            escape: true,
993            ..Default::default()
994        });
995        assert_eq!(classify(KEY_Tab, "\t"), Some(Key::Reset));
996        assert_eq!(classify(KEY_Escape, "\u{1b}"), Some(Key::Reset));
997        assert_eq!(classify(KEY_Return, ""), None);
998
999        // Restore defaults so other tests in this module that
1000        // don't touch the global still observe expected behavior.
1001        set_reset_keys(ResetKeyConfig::default());
1002    }
1003
1004    #[test]
1005    fn a_printable_key_maps_to_a_char() {
1006        // 0x0061 / 0x0020 are the keysyms for 'a' and space.
1007        assert_eq!(classify(0x0061, "a"), Some(Key::Char('a')));
1008        assert_eq!(classify(0x0020, " "), Some(Key::Char(' ')));
1009    }
1010
1011    #[test]
1012    fn a_bare_modifier_is_ignored() {
1013        // A modifier key (here Shift_L, keysym 0xffe1) produces no UTF-8.
1014        assert_eq!(classify(0xffe1, ""), None);
1015    }
1016}