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}