use std::collections::HashMap;
use std::io::ErrorKind;
use std::sync::Arc;
use std::sync::Condvar;
use std::sync::Mutex;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use std::time::{Duration, Instant};
use evdev::{Device, EventSummary, KeyCode};
use hyprcorrect_core::{Chord, Key};
use xkbcommon::xkb;
use crate::linux::chord_capture::ChordCaptureSlot;
#[derive(Debug, thiserror::Error)]
pub enum CaptureError {
#[error("no keyboard devices found under /dev/input")]
NoKeyboards,
#[error(
"permission denied reading /dev/input — add your user to the 'input' group (`sudo usermod -aG input $USER`) and log back in"
)]
Permission,
#[error("could not compile the keyboard layout (xkbcommon)")]
Keymap,
}
#[derive(Debug, Clone, Copy)]
struct TriggerSpec {
sym: u32,
alt_sym: u32,
needs_ctrl: bool,
needs_alt: bool,
needs_shift: bool,
needs_super: bool,
}
pub fn start(
chords: &[Chord],
chord_capture: Arc<ChordCaptureSlot>,
) -> Result<Receiver<Key>, CaptureError> {
let keymap_text = {
let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
let keymap = xkb::Keymap::new_from_names(
&context,
"",
"",
"",
"",
None,
xkb::KEYMAP_COMPILE_NO_FLAGS,
)
.ok_or(CaptureError::Keymap)?;
keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1)
};
let triggers: Vec<TriggerSpec> = chords.iter().map(resolve_trigger).collect();
let keyboards = keyboard_devices()?;
let dedupe = Arc::new(Mutex::new(Dedupe::new()));
let hold = Arc::new(HoldTracker::new(query_compositor_repeat()));
let mods = MODS_WATCH
.get_or_init(|| Arc::new(ModsWatch::new()))
.clone();
let suspect = caret_suspect_flag();
for device in mouse_devices() {
let suspect = suspect.clone();
thread::spawn(move || read_mouse(device, suspect));
}
let (tx, rx) = mpsc::channel();
for (idx, device) in keyboards.into_iter().enumerate() {
let tx = tx.clone();
let keymap_text = keymap_text.clone();
let triggers = triggers.clone();
let chord_capture = chord_capture.clone();
let dedupe = dedupe.clone();
let hold = hold.clone();
let mods = mods.clone();
let device_id = idx as u32;
thread::spawn(move || {
read_device(
device,
device_id,
&keymap_text,
&triggers,
&chord_capture,
&dedupe,
&hold,
&mods,
&tx,
)
});
}
Ok(rx)
}
pub fn wait_mods_clear(timeout: Duration) -> bool {
let Some(watch) = MODS_WATCH.get() else {
return true;
};
watch.wait_clear(timeout)
}
pub fn caret_suspect_flag() -> Arc<AtomicBool> {
CARET_SUSPECT
.get_or_init(|| Arc::new(AtomicBool::new(false)))
.clone()
}
#[derive(Debug, Clone, Copy)]
pub struct ResetKeyConfig {
pub enter: bool,
pub tab: bool,
pub escape: bool,
pub up: bool,
pub down: bool,
pub page_up: bool,
pub page_down: bool,
pub delete: bool,
pub insert: bool,
}
impl Default for ResetKeyConfig {
fn default() -> Self {
Self {
enter: true,
tab: false,
escape: false,
up: true,
down: true,
page_up: true,
page_down: true,
delete: true,
insert: true,
}
}
}
pub fn set_reset_keys(cfg: ResetKeyConfig) {
*reset_keys_lock().write().expect("reset-keys poisoned") = cfg;
}
fn reset_keys_lock() -> &'static std::sync::RwLock<ResetKeyConfig> {
RESET_KEY_CONFIG.get_or_init(|| std::sync::RwLock::new(ResetKeyConfig::default()))
}
fn reset_keys() -> ResetKeyConfig {
*reset_keys_lock().read().expect("reset-keys poisoned")
}
static MODS_WATCH: OnceLock<Arc<ModsWatch>> = OnceLock::new();
static CARET_SUSPECT: OnceLock<Arc<AtomicBool>> = OnceLock::new();
static RESET_KEY_CONFIG: OnceLock<std::sync::RwLock<ResetKeyConfig>> = OnceLock::new();
struct ModsWatch {
inner: Mutex<HashMap<u32, u8>>,
cv: Condvar,
}
const MOD_CTRL: u8 = 1 << 0;
const MOD_ALT: u8 = 1 << 1;
const MOD_SHIFT: u8 = 1 << 2;
const MOD_SUPER: u8 = 1 << 3;
impl ModsWatch {
fn new() -> Self {
Self {
inner: Mutex::new(HashMap::new()),
cv: Condvar::new(),
}
}
fn update(&self, device_id: u32, mask: u8) {
let mut guard = self.inner.lock().expect("mods poisoned");
let entry = guard.entry(device_id).or_insert(0);
if *entry != mask {
*entry = mask;
self.cv.notify_all();
}
}
fn wait_clear(&self, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
let mut guard = self.inner.lock().expect("mods poisoned");
loop {
if guard.values().all(|&m| m == 0) {
return true;
}
let now = Instant::now();
if now >= deadline {
return false;
}
let (g, res) = self
.cv
.wait_timeout(guard, deadline - now)
.expect("mods poisoned");
guard = g;
if res.timed_out() && !guard.values().all(|&m| m == 0) {
return false;
}
}
}
}
fn mods_mask(state: &xkb::State) -> u8 {
let active = |m: &str| state.mod_name_is_active(m, xkb::STATE_MODS_EFFECTIVE);
let mut mask = 0;
if active(xkb::MOD_NAME_CTRL) {
mask |= MOD_CTRL;
}
if active(xkb::MOD_NAME_ALT) {
mask |= MOD_ALT;
}
if active(xkb::MOD_NAME_SHIFT) {
mask |= MOD_SHIFT;
}
if active(xkb::MOD_NAME_LOGO) {
mask |= MOD_SUPER;
}
mask
}
#[derive(Debug, Clone, Copy)]
struct RepeatConfig {
delay: Duration,
interval: Duration,
}
impl Default for RepeatConfig {
fn default() -> Self {
Self {
delay: Duration::from_millis(600),
interval: Duration::from_millis(40), }
}
}
fn query_compositor_repeat() -> RepeatConfig {
let mut cfg = RepeatConfig::default();
let read = |key: &str| -> Option<i64> {
let out = std::process::Command::new("hyprctl")
.args(["getoption", "-j", key])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
text.split("\"int\"")
.nth(1)?
.split(':')
.nth(1)?
.split(',')
.next()?
.trim()
.parse::<i64>()
.ok()
};
if let Some(d) = read("input:repeat_delay")
&& d > 0
{
cfg.delay = Duration::from_millis(d as u64);
}
if let Some(r) = read("input:repeat_rate")
&& r > 0
{
cfg.interval = Duration::from_millis(1000 / r as u64);
}
cfg
}
struct HoldTracker {
repeat: RepeatConfig,
active: Mutex<HashMap<u16, Sender<()>>>,
}
impl HoldTracker {
fn new(repeat: RepeatConfig) -> Self {
Self {
repeat,
active: Mutex::new(HashMap::new()),
}
}
fn start(&self, code: u16, emit: Key, tx: Sender<Key>) {
self.stop(code); let (cancel_tx, cancel_rx) = mpsc::channel::<()>();
self.active
.lock()
.expect("hold poisoned")
.insert(code, cancel_tx);
let repeat = self.repeat;
thread::spawn(move || {
use mpsc::RecvTimeoutError;
match cancel_rx.recv_timeout(repeat.delay) {
Ok(()) | Err(RecvTimeoutError::Disconnected) => return,
Err(RecvTimeoutError::Timeout) => {}
}
if tx.send(emit).is_err() {
return;
}
loop {
match cancel_rx.recv_timeout(repeat.interval) {
Ok(()) | Err(RecvTimeoutError::Disconnected) => return,
Err(RecvTimeoutError::Timeout) => {}
}
if tx.send(emit).is_err() {
return;
}
}
});
}
fn stop(&self, code: u16) {
if let Some(cancel_tx) = self.active.lock().expect("hold poisoned").remove(&code) {
let _ = cancel_tx.send(());
}
}
}
struct Dedupe {
last: Option<(u16, i32, Instant)>,
}
impl Dedupe {
fn new() -> Self {
Self { last: None }
}
fn allow(&mut self, code: u16, value: i32) -> bool {
const WINDOW: Duration = Duration::from_millis(8);
let now = Instant::now();
let is_dup = matches!(
self.last,
Some((last_code, last_value, last_time))
if last_code == code && last_value == value && now - last_time < WINDOW
);
let allow = !is_dup;
if allow {
self.last = Some((code, value, now));
}
allow
}
}
fn resolve_trigger(chord: &Chord) -> TriggerSpec {
let sym = xkb::keysym_from_name(&chord.key, xkb::KEYSYM_CASE_INSENSITIVE).raw();
let alt_sym = match sym {
0x61..=0x7A => sym - 0x20,
0x41..=0x5A => sym + 0x20,
_ => 0,
};
TriggerSpec {
sym,
alt_sym,
needs_ctrl: chord.ctrl,
needs_alt: chord.alt,
needs_shift: chord.shift,
needs_super: chord.super_,
}
}
fn keyboard_devices() -> Result<Vec<Device>, CaptureError> {
let entries = match std::fs::read_dir("/dev/input") {
Ok(entries) => entries,
Err(e) if e.kind() == ErrorKind::PermissionDenied => {
return Err(CaptureError::Permission);
}
Err(_) => return Err(CaptureError::NoKeyboards),
};
let mut keyboards = Vec::new();
let mut permission_denied = false;
for entry in entries.flatten() {
let path = entry.path();
let is_event_node = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("event"));
if !is_event_node {
continue;
}
match Device::open(&path) {
Ok(device) if is_keyboard(&device) => keyboards.push(device),
Ok(_) => {}
Err(e) if e.kind() == ErrorKind::PermissionDenied => {
permission_denied = true;
}
Err(_) => {}
}
}
if !keyboards.is_empty() {
Ok(keyboards)
} else if permission_denied {
Err(CaptureError::Permission)
} else {
Err(CaptureError::NoKeyboards)
}
}
fn is_keyboard(device: &Device) -> bool {
device
.supported_keys()
.is_some_and(|keys| keys.contains(KeyCode::KEY_A))
}
fn mouse_devices() -> Vec<Device> {
let Ok(entries) = std::fs::read_dir("/dev/input") else {
return Vec::new();
};
let mut mice = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let is_event_node = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("event"));
if !is_event_node {
continue;
}
if let Ok(device) = Device::open(&path)
&& is_mouse(&device)
{
mice.push(device);
}
}
mice
}
fn is_mouse(device: &Device) -> bool {
device
.supported_keys()
.is_some_and(|keys| keys.contains(KeyCode::BTN_LEFT))
}
fn read_mouse(mut device: Device, suspect: Arc<AtomicBool>) {
loop {
let Ok(events) = device.fetch_events() else {
return;
};
for input in events {
if let EventSummary::Key(_, code, value) = input.destructure()
&& code == KeyCode::BTN_LEFT
&& value == 1
{
suspect.store(true, Ordering::Relaxed);
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn read_device(
mut device: Device,
device_id: u32,
keymap_text: &str,
triggers: &[TriggerSpec],
chord_capture: &ChordCaptureSlot,
dedupe: &Mutex<Dedupe>,
hold: &HoldTracker,
mods: &ModsWatch,
tx: &Sender<Key>,
) {
let _device_name = device
.name()
.map(|s| s.to_string())
.unwrap_or_else(|| "<unnamed>".to_string());
let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
let Some(keymap) = xkb::Keymap::new_from_string(
&context,
keymap_text.to_owned(),
xkb::KEYMAP_FORMAT_TEXT_V1,
xkb::KEYMAP_COMPILE_NO_FLAGS,
) else {
return;
};
let mut state = xkb::State::new(&keymap);
loop {
let Ok(events) = device.fetch_events() else {
return;
};
for input in events {
let EventSummary::Key(_, code, value) = input.destructure() else {
continue;
};
let keycode = xkb::Keycode::new(u32::from(code.0) + 8);
if !dedupe.lock().expect("dedupe poisoned").allow(code.0, value) {
continue;
}
if value == 2 {
continue;
}
if value == 1 {
if chord_capture.is_armed()
&& let Some(chord) = chord_from_state(&state, keycode)
&& chord_capture.try_emit(chord)
{
} else if let Some(key) = translate(&state, keycode, triggers) {
if tx.send(key).is_err() {
return; }
hold.start(code.0, key, tx.clone());
}
} else {
hold.stop(code.0);
}
let direction = if value == 0 {
xkb::KeyDirection::Up
} else {
xkb::KeyDirection::Down
};
state.update_key(keycode, direction);
mods.update(device_id, mods_mask(&state));
}
}
}
fn translate(state: &xkb::State, keycode: xkb::Keycode, triggers: &[TriggerSpec]) -> Option<Key> {
let sym = state.key_get_one_sym(keycode).raw();
if is_modifier_keysym(sym) {
return None;
}
let chord_match = triggers.iter().any(|trigger| {
let letter_match = trigger.sym != 0
&& (sym == trigger.sym || (trigger.alt_sym != 0 && sym == trigger.alt_sym));
letter_match && is_trigger_chord(state, *trigger)
});
if chord_match {
return None;
}
{
use xkb::keysyms::{KEY_Left, KEY_Right};
let active = |m: &str| state.mod_name_is_active(m, xkb::STATE_MODS_EFFECTIVE);
let ctrl_only =
active(xkb::MOD_NAME_CTRL) && !active(xkb::MOD_NAME_ALT) && !active(xkb::MOD_NAME_LOGO);
if ctrl_only {
if sym == KEY_Left {
return Some(Key::WordLeft);
}
if sym == KEY_Right {
return Some(Key::WordRight);
}
}
}
if has_action_modifier(state) {
return Some(Key::Reset);
}
classify(sym, &state.key_get_utf8(keycode))
}
fn chord_from_state(state: &xkb::State, keycode: xkb::Keycode) -> Option<String> {
let sym = state.key_get_one_sym(keycode).raw();
if is_modifier_keysym(sym) {
return None;
}
let key_token = chord_key_token(sym)?;
let active = |m: &str| state.mod_name_is_active(m, xkb::STATE_MODS_EFFECTIVE);
let mut parts: Vec<&str> = Vec::new();
if active(xkb::MOD_NAME_CTRL) {
parts.push("CTRL");
}
if active(xkb::MOD_NAME_SHIFT) {
parts.push("SHIFT");
}
if active(xkb::MOD_NAME_ALT) {
parts.push("ALT");
}
if active(xkb::MOD_NAME_LOGO) {
parts.push("SUPER");
}
Some(if parts.is_empty() {
key_token
} else {
format!("{}+{key_token}", parts.join("+"))
})
}
fn chord_key_token(sym: u32) -> Option<String> {
let named = match sym {
0xff1b => Some("ESC"), 0xff0d | 0xff8d => Some("ENTER"), 0xff09 => Some("TAB"), 0xff08 => Some("BACKSPACE"), 0xffff => Some("DELETE"), 0xff52 => Some("UP"), 0xff54 => Some("DOWN"), 0xff51 => Some("LEFT"), 0xff53 => Some("RIGHT"), 0x20 => Some("SPACE"), 0x2b => Some("PLUS"), 0x2d => Some("MINUS"), 0x3d => Some("EQUAL"), _ => None,
};
if let Some(token) = named {
return Some(token.to_string());
}
if (0x21..=0x7E).contains(&sym) {
let ch = char::from_u32(sym)?.to_ascii_uppercase();
return Some(ch.to_string());
}
let name = xkb::keysym_get_name(xkb::Keysym::from(sym));
if name.is_empty() {
return None;
}
Some(name.to_ascii_uppercase())
}
fn is_trigger_chord(state: &xkb::State, trigger: TriggerSpec) -> bool {
let active = |m: &str| state.mod_name_is_active(m, xkb::STATE_MODS_EFFECTIVE);
active(xkb::MOD_NAME_CTRL) == trigger.needs_ctrl
&& active(xkb::MOD_NAME_ALT) == trigger.needs_alt
&& active(xkb::MOD_NAME_SHIFT) == trigger.needs_shift
&& active(xkb::MOD_NAME_LOGO) == trigger.needs_super
}
fn has_action_modifier(state: &xkb::State) -> bool {
let active = |m: &str| state.mod_name_is_active(m, xkb::STATE_MODS_EFFECTIVE);
active(xkb::MOD_NAME_CTRL) || active(xkb::MOD_NAME_ALT) || active(xkb::MOD_NAME_LOGO)
}
fn is_modifier_keysym(sym: u32) -> bool {
(0xffe1..=0xffee).contains(&sym)
}
fn reset_for_keysym(sym: u32) -> bool {
use xkb::keysyms::{
KEY_Delete, KEY_Down, KEY_Escape, KEY_ISO_Left_Tab, KEY_Insert, KEY_KP_Enter, KEY_Linefeed,
KEY_Next, KEY_Prior, KEY_Return, KEY_Tab, KEY_Up,
};
let cfg = reset_keys();
matches!(
sym,
s if (s == KEY_Return || s == KEY_KP_Enter || s == KEY_Linefeed) && cfg.enter
) || matches!(sym, s if (s == KEY_Tab || s == KEY_ISO_Left_Tab) && cfg.tab)
|| matches!(sym, s if s == KEY_Escape && cfg.escape)
|| matches!(sym, s if s == KEY_Up && cfg.up)
|| matches!(sym, s if s == KEY_Down && cfg.down)
|| matches!(sym, s if s == KEY_Prior && cfg.page_up)
|| matches!(sym, s if s == KEY_Next && cfg.page_down)
|| matches!(sym, s if s == KEY_Delete && cfg.delete)
|| matches!(sym, s if s == KEY_Insert && cfg.insert)
}
fn classify(sym: u32, utf8: &str) -> Option<Key> {
use xkb::keysyms::{KEY_BackSpace, KEY_End, KEY_Home, KEY_Left, KEY_Right};
if sym == KEY_BackSpace {
Some(Key::Backspace)
} else if sym == KEY_Left {
Some(Key::MoveLeft)
} else if sym == KEY_Right {
Some(Key::MoveRight)
} else if sym == KEY_Home {
Some(Key::LineStart)
} else if sym == KEY_End {
Some(Key::LineEnd)
} else if reset_for_keysym(sym) {
Some(Key::Reset)
} else {
let mut chars = utf8.chars();
match (chars.next(), chars.next()) {
(Some(c), None) if !c.is_control() => Some(Key::Char(c)),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use xkb::keysyms::{
KEY_BackSpace, KEY_End, KEY_Escape, KEY_Home, KEY_Left, KEY_Return, KEY_Right, KEY_Tab,
KEY_Up,
};
#[test]
fn backspace_keysym_maps_to_backspace() {
assert_eq!(classify(KEY_BackSpace, ""), Some(Key::Backspace));
}
#[test]
fn left_right_arrows_move_the_caret() {
assert_eq!(classify(KEY_Left, ""), Some(Key::MoveLeft));
assert_eq!(classify(KEY_Right, ""), Some(Key::MoveRight));
}
#[test]
fn home_and_end_jump_to_line_edges() {
assert_eq!(classify(KEY_Home, ""), Some(Key::LineStart));
assert_eq!(classify(KEY_End, ""), Some(Key::LineEnd));
}
#[test]
fn reset_key_classifier_honors_config() {
set_reset_keys(ResetKeyConfig::default());
assert_eq!(classify(KEY_Return, ""), Some(Key::Reset));
assert_eq!(classify(KEY_Up, ""), Some(Key::Reset));
assert_eq!(classify(KEY_Tab, "\t"), None);
assert_eq!(classify(KEY_Escape, "\u{1b}"), None);
set_reset_keys(ResetKeyConfig {
enter: false,
tab: true,
escape: true,
..Default::default()
});
assert_eq!(classify(KEY_Tab, "\t"), Some(Key::Reset));
assert_eq!(classify(KEY_Escape, "\u{1b}"), Some(Key::Reset));
assert_eq!(classify(KEY_Return, ""), None);
set_reset_keys(ResetKeyConfig::default());
}
#[test]
fn a_printable_key_maps_to_a_char() {
assert_eq!(classify(0x0061, "a"), Some(Key::Char('a')));
assert_eq!(classify(0x0020, " "), Some(Key::Char(' ')));
}
#[test]
fn a_bare_modifier_is_ignored() {
assert_eq!(classify(0xffe1, ""), None);
}
}