2025-06-08 22:24:14 -05:00
|
|
|
// client/src/input/inputs.rs
|
|
|
|
|
|
2026-04-13 02:52:32 -03:00
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
use anyhow::bail;
|
2026-04-14 04:02:39 -03:00
|
|
|
use anyhow::{Context, Result};
|
2026-04-08 20:00:14 -03:00
|
|
|
use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
|
2026-04-21 12:48:57 -03:00
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
use std::os::unix::fs::MetadataExt;
|
2026-04-14 20:05:26 -03:00
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
use std::path::{Path, PathBuf};
|
2026-04-17 04:35:41 -03:00
|
|
|
use std::sync::{
|
|
|
|
|
Arc,
|
|
|
|
|
atomic::{AtomicBool, Ordering},
|
|
|
|
|
};
|
2026-04-14 23:03:18 -03:00
|
|
|
use std::time::Instant;
|
2025-12-01 11:38:51 -03:00
|
|
|
use tokio::{
|
|
|
|
|
sync::broadcast::Sender,
|
|
|
|
|
time::{Duration, interval},
|
|
|
|
|
};
|
2025-11-30 23:41:29 -03:00
|
|
|
use tracing::{debug, info, warn};
|
2025-06-08 22:24:14 -05:00
|
|
|
|
2025-06-23 07:18:26 -05:00
|
|
|
use lesavka_common::lesavka::{KeyboardReport, MouseReport};
|
2025-06-11 22:01:16 -05:00
|
|
|
|
2026-04-16 15:07:25 -03:00
|
|
|
use super::{
|
|
|
|
|
keyboard::{KeyboardAggregator, build_keyboard_report, emit_live_keyboard_report},
|
|
|
|
|
mouse::MouseAggregator,
|
|
|
|
|
};
|
2025-06-29 04:54:39 -05:00
|
|
|
use crate::layout::{Layout, apply as apply_layout};
|
2026-04-08 20:00:14 -03:00
|
|
|
use tokio::sync::mpsc::UnboundedSender;
|
2025-06-08 22:24:14 -05:00
|
|
|
|
|
|
|
|
pub struct InputAggregator {
|
2025-06-17 20:54:31 -05:00
|
|
|
kbd_tx: Sender<KeyboardReport>,
|
|
|
|
|
mou_tx: Sender<MouseReport>,
|
|
|
|
|
dev_mode: bool,
|
2025-06-28 15:45:35 -05:00
|
|
|
released: bool,
|
2025-06-28 17:55:15 -05:00
|
|
|
magic_active: bool,
|
2026-04-08 20:00:14 -03:00
|
|
|
pending_release: bool,
|
|
|
|
|
pending_kill: bool,
|
|
|
|
|
pending_keys: HashSet<KeyCode>,
|
2026-04-16 15:07:25 -03:00
|
|
|
last_keyboard_report: [u8; 8],
|
2026-04-08 20:00:14 -03:00
|
|
|
paste_tx: Option<UnboundedSender<String>>,
|
2025-06-17 20:54:31 -05:00
|
|
|
keyboards: Vec<KeyboardAggregator>,
|
|
|
|
|
mice: Vec<MouseAggregator>,
|
2026-04-16 12:58:05 -03:00
|
|
|
selected_keyboard_path: Option<String>,
|
|
|
|
|
selected_mouse_path: Option<String>,
|
2026-04-21 12:46:47 -03:00
|
|
|
#[cfg(not(coverage))]
|
2026-04-21 12:48:57 -03:00
|
|
|
known_input_paths: HashMap<PathBuf, u64>,
|
2026-04-13 23:11:35 -03:00
|
|
|
capture_remote_boot: bool,
|
2026-04-14 04:02:39 -03:00
|
|
|
quick_toggle_key: Option<KeyCode>,
|
|
|
|
|
quick_toggle_down: bool,
|
|
|
|
|
quick_toggle_debounce: Duration,
|
|
|
|
|
last_quick_toggle_at: Option<Instant>,
|
2026-04-20 18:41:48 -03:00
|
|
|
pending_release_started_at: Option<Instant>,
|
|
|
|
|
pending_release_timeout: Duration,
|
2026-04-20 21:11:33 -03:00
|
|
|
remote_failsafe_started_at: Option<Instant>,
|
|
|
|
|
remote_failsafe_timeout: Duration,
|
2026-04-14 20:05:26 -03:00
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
routing_control_path: Option<PathBuf>,
|
|
|
|
|
#[cfg(not(coverage))]
|
2026-04-20 18:41:48 -03:00
|
|
|
last_routing_request_raw: Option<String>,
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
quick_toggle_control_path: Option<PathBuf>,
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
last_quick_toggle_request_raw: Option<String>,
|
2026-04-14 20:05:26 -03:00
|
|
|
#[cfg(not(coverage))]
|
2026-04-16 12:58:05 -03:00
|
|
|
clipboard_control_path: Option<PathBuf>,
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
clipboard_control_marker: u128,
|
|
|
|
|
#[cfg(not(coverage))]
|
2026-04-14 20:05:26 -03:00
|
|
|
routing_state_path: Option<PathBuf>,
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
published_remote_capture: Option<bool>,
|
2026-04-17 04:35:41 -03:00
|
|
|
remote_capture_enabled: Arc<AtomicBool>,
|
2025-06-08 22:24:14 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl InputAggregator {
|
2025-12-01 11:38:51 -03:00
|
|
|
pub fn new(
|
|
|
|
|
dev_mode: bool,
|
|
|
|
|
kbd_tx: Sender<KeyboardReport>,
|
|
|
|
|
mou_tx: Sender<MouseReport>,
|
2026-04-08 20:00:14 -03:00
|
|
|
paste_tx: Option<UnboundedSender<String>>,
|
2026-04-13 23:11:35 -03:00
|
|
|
) -> Self {
|
|
|
|
|
Self::new_with_capture_mode(dev_mode, kbd_tx, mou_tx, paste_tx, true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn new_with_capture_mode(
|
|
|
|
|
dev_mode: bool,
|
|
|
|
|
kbd_tx: Sender<KeyboardReport>,
|
|
|
|
|
mou_tx: Sender<MouseReport>,
|
|
|
|
|
paste_tx: Option<UnboundedSender<String>>,
|
|
|
|
|
capture_remote_boot: bool,
|
2025-12-01 11:38:51 -03:00
|
|
|
) -> Self {
|
2026-04-14 04:02:39 -03:00
|
|
|
let quick_toggle_key = quick_toggle_key_from_env();
|
2026-04-14 20:05:26 -03:00
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
let routing_control_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_CONTROL");
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
let routing_state_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_STATE");
|
2026-04-16 12:58:05 -03:00
|
|
|
#[cfg(not(coverage))]
|
2026-04-20 18:41:48 -03:00
|
|
|
let quick_toggle_control_path =
|
|
|
|
|
launcher_routing_path_from_env("LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL");
|
|
|
|
|
#[cfg(not(coverage))]
|
2026-04-16 12:58:05 -03:00
|
|
|
let clipboard_control_path =
|
|
|
|
|
launcher_routing_path_from_env("LESAVKA_LAUNCHER_CLIPBOARD_CONTROL");
|
2025-12-01 11:38:51 -03:00
|
|
|
Self {
|
|
|
|
|
kbd_tx,
|
|
|
|
|
mou_tx,
|
|
|
|
|
dev_mode,
|
2026-04-13 23:11:35 -03:00
|
|
|
released: !capture_remote_boot,
|
2025-12-01 11:38:51 -03:00
|
|
|
magic_active: false,
|
2026-04-08 20:00:14 -03:00
|
|
|
pending_release: false,
|
|
|
|
|
pending_kill: false,
|
|
|
|
|
pending_keys: HashSet::new(),
|
2026-04-16 15:07:25 -03:00
|
|
|
last_keyboard_report: [0; 8],
|
2026-04-08 20:00:14 -03:00
|
|
|
paste_tx,
|
2025-12-01 11:38:51 -03:00
|
|
|
keyboards: Vec::new(),
|
|
|
|
|
mice: Vec::new(),
|
2026-04-16 12:58:05 -03:00
|
|
|
selected_keyboard_path: input_device_override_from_env("LESAVKA_KEYBOARD_DEVICE"),
|
|
|
|
|
selected_mouse_path: input_device_override_from_env("LESAVKA_MOUSE_DEVICE"),
|
2026-04-21 12:46:47 -03:00
|
|
|
#[cfg(not(coverage))]
|
2026-04-21 12:48:57 -03:00
|
|
|
known_input_paths: HashMap::new(),
|
2026-04-13 23:11:35 -03:00
|
|
|
capture_remote_boot,
|
2026-04-14 04:02:39 -03:00
|
|
|
quick_toggle_key,
|
|
|
|
|
quick_toggle_down: false,
|
|
|
|
|
quick_toggle_debounce: quick_toggle_debounce_from_env(),
|
|
|
|
|
last_quick_toggle_at: None,
|
2026-04-20 18:41:48 -03:00
|
|
|
pending_release_started_at: None,
|
|
|
|
|
pending_release_timeout: pending_release_timeout_from_env(),
|
2026-04-20 21:11:33 -03:00
|
|
|
remote_failsafe_started_at: capture_remote_boot.then(Instant::now),
|
|
|
|
|
remote_failsafe_timeout: remote_failsafe_timeout_from_env(),
|
2026-04-14 20:05:26 -03:00
|
|
|
#[cfg(not(coverage))]
|
2026-04-20 18:41:48 -03:00
|
|
|
last_routing_request_raw: routing_control_path
|
2026-04-14 20:05:26 -03:00
|
|
|
.as_deref()
|
2026-04-20 18:41:48 -03:00
|
|
|
.and_then(read_launcher_control_snapshot),
|
2026-04-14 20:05:26 -03:00
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
routing_control_path,
|
|
|
|
|
#[cfg(not(coverage))]
|
2026-04-20 18:41:48 -03:00
|
|
|
last_quick_toggle_request_raw: quick_toggle_control_path
|
|
|
|
|
.as_deref()
|
|
|
|
|
.and_then(read_launcher_control_snapshot),
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
quick_toggle_control_path,
|
|
|
|
|
#[cfg(not(coverage))]
|
2026-04-16 12:58:05 -03:00
|
|
|
clipboard_control_marker: clipboard_control_path
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(path_marker)
|
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
clipboard_control_path,
|
|
|
|
|
#[cfg(not(coverage))]
|
2026-04-14 20:05:26 -03:00
|
|
|
routing_state_path,
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
published_remote_capture: None,
|
2026-04-17 04:35:41 -03:00
|
|
|
remote_capture_enabled: Arc::new(AtomicBool::new(capture_remote_boot)),
|
2025-12-01 11:38:51 -03:00
|
|
|
}
|
2025-06-08 22:24:14 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-17 04:35:41 -03:00
|
|
|
pub fn remote_capture_enabled_handle(&self) -> Arc<AtomicBool> {
|
|
|
|
|
Arc::clone(&self.remote_capture_enabled)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 02:52:32 -03:00
|
|
|
#[cfg(coverage)]
|
|
|
|
|
pub fn init(&mut self) -> Result<()> {
|
|
|
|
|
let paths = std::fs::read_dir("/dev/input").context("Failed to read /dev/input")?;
|
|
|
|
|
for path in paths.flatten().map(|entry| entry.path()) {
|
|
|
|
|
if !path
|
|
|
|
|
.file_name()
|
|
|
|
|
.map(|f| f.to_string_lossy().starts_with("event"))
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(dev) = Device::open(&path) {
|
|
|
|
|
let _ = dev.set_nonblocking(true);
|
|
|
|
|
match classify_device(&dev) {
|
|
|
|
|
DeviceKind::Keyboard => {
|
2026-04-16 12:58:05 -03:00
|
|
|
if !matches_selected_input_device(
|
|
|
|
|
&path,
|
|
|
|
|
self.selected_keyboard_path.as_deref(),
|
|
|
|
|
) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-04-13 23:11:35 -03:00
|
|
|
let mut aggregator = KeyboardAggregator::new(
|
2026-04-13 02:52:32 -03:00
|
|
|
dev,
|
|
|
|
|
self.dev_mode,
|
|
|
|
|
self.kbd_tx.clone(),
|
|
|
|
|
self.paste_tx.clone(),
|
2026-04-13 23:11:35 -03:00
|
|
|
);
|
|
|
|
|
aggregator.set_send(self.capture_remote_boot);
|
|
|
|
|
if !self.capture_remote_boot {
|
|
|
|
|
aggregator.set_grab(false);
|
|
|
|
|
}
|
|
|
|
|
self.keyboards.push(aggregator);
|
2026-04-13 02:52:32 -03:00
|
|
|
}
|
|
|
|
|
DeviceKind::Mouse => {
|
2026-04-16 12:58:05 -03:00
|
|
|
if !matches_selected_input_device(
|
|
|
|
|
&path,
|
|
|
|
|
self.selected_mouse_path.as_deref(),
|
|
|
|
|
) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-04-13 23:11:35 -03:00
|
|
|
let mut aggregator =
|
|
|
|
|
MouseAggregator::new(dev, self.dev_mode, self.mou_tx.clone());
|
|
|
|
|
aggregator.set_send(self.capture_remote_boot);
|
|
|
|
|
if !self.capture_remote_boot {
|
|
|
|
|
aggregator.set_grab(false);
|
|
|
|
|
}
|
|
|
|
|
self.mice.push(aggregator);
|
2026-04-13 02:52:32 -03:00
|
|
|
}
|
|
|
|
|
DeviceKind::Other => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
2025-06-08 22:24:14 -05:00
|
|
|
pub fn init(&mut self) -> Result<()> {
|
2026-04-21 12:46:47 -03:00
|
|
|
let found_any = self.scan_input_devices(self.capture_remote_boot, true)?;
|
|
|
|
|
|
|
|
|
|
if !found_any {
|
|
|
|
|
bail!("No suitable keyboard/mouse devices found or none grabbed.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
2025-06-08 22:24:14 -05:00
|
|
|
|
2026-04-21 12:46:47 -03:00
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn scan_input_devices(&mut self, remote_active: bool, fail_grab: bool) -> Result<bool> {
|
|
|
|
|
let paths = std::fs::read_dir("/dev/input").context("Failed to read /dev/input")?;
|
2025-06-08 22:24:14 -05:00
|
|
|
let mut found_any = false;
|
|
|
|
|
|
|
|
|
|
for entry in paths {
|
|
|
|
|
let entry = entry?;
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
|
2025-12-01 11:38:51 -03:00
|
|
|
if !path
|
|
|
|
|
.file_name()
|
|
|
|
|
.map_or(false, |f| f.to_string_lossy().starts_with("event"))
|
|
|
|
|
{
|
2025-06-08 22:24:14 -05:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-04-21 12:48:57 -03:00
|
|
|
let identity = input_device_identity(&path).unwrap_or_default();
|
|
|
|
|
if self
|
|
|
|
|
.known_input_paths
|
|
|
|
|
.get(&path)
|
|
|
|
|
.is_some_and(|known| *known == identity)
|
|
|
|
|
{
|
2026-04-21 12:46:47 -03:00
|
|
|
continue;
|
|
|
|
|
}
|
2025-06-08 22:24:14 -05:00
|
|
|
|
2025-06-29 04:54:39 -05:00
|
|
|
let mut dev = match Device::open(&path) {
|
2025-06-08 22:24:14 -05:00
|
|
|
Ok(d) => d,
|
2025-06-29 04:17:44 -05:00
|
|
|
Err(e) => {
|
2025-06-29 04:54:39 -05:00
|
|
|
warn!("❌ open {}: {e}", path.display());
|
2025-06-29 04:17:44 -05:00
|
|
|
continue;
|
|
|
|
|
}
|
2025-06-08 22:24:14 -05:00
|
|
|
};
|
|
|
|
|
|
2025-12-01 11:38:51 -03:00
|
|
|
dev.set_nonblocking(true)
|
|
|
|
|
.with_context(|| format!("set_non_blocking {:?}", path))?;
|
2025-06-11 00:37:01 -05:00
|
|
|
|
2025-06-08 22:24:14 -05:00
|
|
|
match classify_device(&dev) {
|
|
|
|
|
DeviceKind::Keyboard => {
|
2026-04-16 12:58:05 -03:00
|
|
|
if !matches_selected_input_device(&path, self.selected_keyboard_path.as_deref())
|
|
|
|
|
{
|
2026-04-21 12:48:57 -03:00
|
|
|
self.known_input_paths.insert(path, identity);
|
2026-04-16 12:58:05 -03:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-04-21 12:46:47 -03:00
|
|
|
if remote_active {
|
|
|
|
|
if let Err(err) = dev.grab() {
|
|
|
|
|
if fail_grab {
|
|
|
|
|
return Err(err)
|
|
|
|
|
.with_context(|| format!("grabbing keyboard {path:?}"));
|
|
|
|
|
}
|
|
|
|
|
warn!("❌ grab keyboard {}: {err}", path.display());
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-04-13 23:11:35 -03:00
|
|
|
info!(
|
|
|
|
|
"🤏🖱️ Grabbed keyboard {:?}",
|
|
|
|
|
dev.name().unwrap_or("UNKNOWN")
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
info!(
|
2026-04-21 12:46:47 -03:00
|
|
|
"⌨️ local-input mode; keyboard staged ungrabbed {:?}",
|
2026-04-13 23:11:35 -03:00
|
|
|
dev.name().unwrap_or("UNKNOWN")
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-06-11 00:37:01 -05:00
|
|
|
|
2026-04-13 23:11:35 -03:00
|
|
|
let mut kbd_agg = KeyboardAggregator::new(
|
2026-04-08 20:00:14 -03:00
|
|
|
dev,
|
|
|
|
|
self.dev_mode,
|
|
|
|
|
self.kbd_tx.clone(),
|
|
|
|
|
self.paste_tx.clone(),
|
|
|
|
|
);
|
2026-04-21 12:46:47 -03:00
|
|
|
kbd_agg.set_send(remote_active);
|
|
|
|
|
if !remote_active {
|
2026-04-13 23:11:35 -03:00
|
|
|
kbd_agg.set_grab(false);
|
|
|
|
|
}
|
2026-04-21 12:48:57 -03:00
|
|
|
self.known_input_paths.insert(path, identity);
|
2025-06-08 22:24:14 -05:00
|
|
|
self.keyboards.push(kbd_agg);
|
|
|
|
|
found_any = true;
|
|
|
|
|
}
|
|
|
|
|
DeviceKind::Mouse => {
|
2026-04-16 12:58:05 -03:00
|
|
|
if !matches_selected_input_device(&path, self.selected_mouse_path.as_deref()) {
|
2026-04-21 12:48:57 -03:00
|
|
|
self.known_input_paths.insert(path, identity);
|
2026-04-16 12:58:05 -03:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-04-21 12:46:47 -03:00
|
|
|
if remote_active {
|
|
|
|
|
if let Err(err) = dev.grab() {
|
|
|
|
|
if fail_grab {
|
|
|
|
|
return Err(err)
|
|
|
|
|
.with_context(|| format!("grabbing mouse {path:?}"));
|
|
|
|
|
}
|
|
|
|
|
warn!("❌ grab mouse {}: {err}", path.display());
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-04-13 23:11:35 -03:00
|
|
|
info!("🤏⌨️ Grabbed mouse {:?}", dev.name().unwrap_or("UNKNOWN"));
|
|
|
|
|
} else {
|
|
|
|
|
info!(
|
2026-04-21 12:46:47 -03:00
|
|
|
"🖱️ local-input mode; mouse staged ungrabbed {:?}",
|
2026-04-13 23:11:35 -03:00
|
|
|
dev.name().unwrap_or("UNKNOWN")
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-06-11 00:37:01 -05:00
|
|
|
|
2026-04-13 23:11:35 -03:00
|
|
|
let mut mouse_agg =
|
|
|
|
|
MouseAggregator::new(dev, self.dev_mode, self.mou_tx.clone());
|
2026-04-21 12:46:47 -03:00
|
|
|
mouse_agg.set_send(remote_active);
|
|
|
|
|
if !remote_active {
|
2026-04-13 23:11:35 -03:00
|
|
|
mouse_agg.set_grab(false);
|
|
|
|
|
}
|
2026-04-21 12:48:57 -03:00
|
|
|
self.known_input_paths.insert(path, identity);
|
2025-06-08 22:24:14 -05:00
|
|
|
self.mice.push(mouse_agg);
|
|
|
|
|
found_any = true;
|
|
|
|
|
}
|
|
|
|
|
DeviceKind::Other => {
|
2025-12-01 11:38:51 -03:00
|
|
|
debug!(
|
|
|
|
|
"Skipping non-kbd/mouse device: {:?}",
|
|
|
|
|
dev.name().unwrap_or("UNKNOWN")
|
|
|
|
|
);
|
2026-04-21 12:48:57 -03:00
|
|
|
self.known_input_paths.insert(path, identity);
|
2025-06-08 22:24:14 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 12:46:47 -03:00
|
|
|
Ok(found_any)
|
2025-06-08 22:24:14 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 02:52:32 -03:00
|
|
|
#[cfg(coverage)]
|
|
|
|
|
pub async fn run(&mut self) -> Result<()> {
|
|
|
|
|
loop {
|
2026-04-16 15:07:25 -03:00
|
|
|
self.process_keyboard_updates();
|
2026-04-14 04:02:39 -03:00
|
|
|
let quick_toggle_now = self.quick_toggle_active();
|
|
|
|
|
self.observe_quick_toggle(quick_toggle_now);
|
2026-04-13 02:52:32 -03:00
|
|
|
|
|
|
|
|
if self.pending_release || self.pending_kill {
|
|
|
|
|
let chord_released = if self.pending_keys.is_empty() {
|
|
|
|
|
!self
|
|
|
|
|
.keyboards
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|k| k.magic_grab() || k.magic_kill())
|
|
|
|
|
} else {
|
|
|
|
|
self.pending_keys
|
|
|
|
|
.iter()
|
|
|
|
|
.all(|key| !self.keyboards.iter().any(|k| k.has_key(*key)))
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if chord_released {
|
|
|
|
|
for k in &mut self.keyboards {
|
|
|
|
|
k.set_grab(false);
|
|
|
|
|
k.reset_state();
|
|
|
|
|
}
|
|
|
|
|
for m in &mut self.mice {
|
|
|
|
|
m.set_grab(false);
|
|
|
|
|
m.reset_state();
|
|
|
|
|
}
|
|
|
|
|
self.released = true;
|
|
|
|
|
self.pending_release = false;
|
|
|
|
|
self.pending_keys.clear();
|
|
|
|
|
if self.pending_kill {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for mouse in &mut self.mice {
|
|
|
|
|
mouse.process_events();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tokio::task::yield_now().await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
2025-06-08 22:24:14 -05:00
|
|
|
pub async fn run(&mut self) -> Result<()> {
|
|
|
|
|
// Example approach: poll each aggregator in a simple loop
|
|
|
|
|
let mut tick = interval(Duration::from_millis(10));
|
2025-06-29 04:54:39 -05:00
|
|
|
let mut current = Layout::SideBySide;
|
2026-04-21 12:46:47 -03:00
|
|
|
let input_rescan_interval = input_rescan_interval_from_env();
|
|
|
|
|
let mut last_input_rescan_at = Instant::now();
|
2026-04-14 20:05:26 -03:00
|
|
|
self.publish_routing_state_if_changed();
|
2025-06-08 22:24:14 -05:00
|
|
|
loop {
|
2025-12-01 11:38:51 -03:00
|
|
|
let mut want_kill = false;
|
2026-04-16 15:07:25 -03:00
|
|
|
self.process_keyboard_updates();
|
2026-04-21 12:46:47 -03:00
|
|
|
if !input_rescan_interval.is_zero()
|
|
|
|
|
&& last_input_rescan_at.elapsed() >= input_rescan_interval
|
|
|
|
|
{
|
|
|
|
|
last_input_rescan_at = Instant::now();
|
|
|
|
|
let remote_active = self.remote_capture_active();
|
|
|
|
|
if let Err(err) = self.scan_input_devices(remote_active, false) {
|
|
|
|
|
warn!("⚠️ input device rescan failed: {err:#}");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-16 15:07:25 -03:00
|
|
|
for kbd in &self.keyboards {
|
2025-12-01 11:38:51 -03:00
|
|
|
want_kill |= kbd.magic_kill();
|
2025-06-08 22:24:14 -05:00
|
|
|
}
|
2026-04-14 20:05:26 -03:00
|
|
|
self.poll_launcher_routing_request();
|
2026-04-20 18:41:48 -03:00
|
|
|
self.poll_launcher_quick_toggle_request();
|
2026-04-16 12:58:05 -03:00
|
|
|
self.poll_launcher_clipboard_request();
|
2026-04-14 04:02:39 -03:00
|
|
|
let quick_toggle_now = self.quick_toggle_active();
|
|
|
|
|
self.observe_quick_toggle(quick_toggle_now);
|
2026-04-08 20:00:14 -03:00
|
|
|
let magic_now = self.keyboards.iter().any(|k| k.magic_grab());
|
|
|
|
|
let magic_left = self.keyboards.iter().any(|k| k.magic_left());
|
|
|
|
|
let magic_right = self.keyboards.iter().any(|k| k.magic_right());
|
2025-06-28 15:45:35 -05:00
|
|
|
|
2025-12-01 11:38:51 -03:00
|
|
|
if magic_now && !self.magic_active {
|
|
|
|
|
self.toggle_grab();
|
|
|
|
|
}
|
2025-06-29 04:54:39 -05:00
|
|
|
if (magic_left || magic_right) && self.magic_active {
|
|
|
|
|
current = match current {
|
|
|
|
|
Layout::SideBySide => Layout::FullLeft,
|
2025-12-01 11:38:51 -03:00
|
|
|
Layout::FullLeft => Layout::FullRight,
|
|
|
|
|
Layout::FullRight => Layout::SideBySide,
|
2025-06-29 04:54:39 -05:00
|
|
|
};
|
|
|
|
|
apply_layout(current);
|
|
|
|
|
}
|
2026-04-08 20:00:14 -03:00
|
|
|
if want_kill && !self.pending_kill {
|
2025-06-28 15:45:35 -05:00
|
|
|
warn!("🧙 magic chord - killing 🪄 AVADA KEDAVRA!!! 💥💀⚰️");
|
2026-04-17 04:35:41 -03:00
|
|
|
self.remote_capture_enabled.store(false, Ordering::Relaxed);
|
2026-04-08 20:00:14 -03:00
|
|
|
for k in &mut self.keyboards {
|
|
|
|
|
k.send_empty_report();
|
|
|
|
|
k.set_send(false);
|
|
|
|
|
}
|
|
|
|
|
for m in &mut self.mice {
|
|
|
|
|
m.reset_state();
|
|
|
|
|
m.set_send(false);
|
|
|
|
|
}
|
|
|
|
|
self.pending_kill = true;
|
|
|
|
|
self.capture_pending_keys();
|
2026-04-20 18:41:48 -03:00
|
|
|
self.pending_release_started_at = Some(Instant::now());
|
2026-04-08 20:00:14 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-20 21:11:33 -03:00
|
|
|
if self.remote_failsafe_expired() {
|
|
|
|
|
warn!(
|
|
|
|
|
"🛟 remote input failsafe expired after {} ms; returning control to this machine",
|
|
|
|
|
self.remote_failsafe_timeout.as_millis()
|
|
|
|
|
);
|
|
|
|
|
self.begin_local_release();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 20:00:14 -03:00
|
|
|
if self.pending_release || self.pending_kill {
|
|
|
|
|
let chord_released = if self.pending_keys.is_empty() {
|
|
|
|
|
!self
|
|
|
|
|
.keyboards
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|k| k.magic_grab() || k.magic_kill())
|
|
|
|
|
} else {
|
|
|
|
|
self.pending_keys
|
|
|
|
|
.iter()
|
|
|
|
|
.all(|key| !self.keyboards.iter().any(|k| k.has_key(*key)))
|
|
|
|
|
};
|
2026-04-20 18:41:48 -03:00
|
|
|
let timed_out = self.pending_release_timed_out();
|
|
|
|
|
if chord_released || timed_out {
|
|
|
|
|
if timed_out {
|
|
|
|
|
warn!(
|
|
|
|
|
"⌛ local release timed out waiting for key-up events; forcing the handoff"
|
|
|
|
|
);
|
2026-04-14 13:09:25 -03:00
|
|
|
}
|
2026-04-20 18:41:48 -03:00
|
|
|
self.finish_local_release(!self.pending_kill);
|
2026-04-08 20:00:14 -03:00
|
|
|
if self.pending_kill {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-28 15:45:35 -05:00
|
|
|
}
|
|
|
|
|
|
2025-06-08 22:24:14 -05:00
|
|
|
for mouse in &mut self.mice {
|
|
|
|
|
mouse.process_events();
|
|
|
|
|
}
|
2025-06-28 17:55:15 -05:00
|
|
|
|
|
|
|
|
self.magic_active = magic_now;
|
2025-06-08 22:24:14 -05:00
|
|
|
tick.tick().await;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-28 15:45:35 -05:00
|
|
|
|
|
|
|
|
fn toggle_grab(&mut self) {
|
2026-04-08 20:00:14 -03:00
|
|
|
if self.pending_release || self.pending_kill {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-06-28 15:45:35 -05:00
|
|
|
if self.released {
|
|
|
|
|
tracing::info!("🧙 magic chord - restricting devices 🪄 IMPERIUS!!! 🎮🔒");
|
|
|
|
|
} else {
|
|
|
|
|
tracing::info!("🧙 magic chord - freeing devices 🪄 EXPELLIARMUS!!! 🔓🕊️");
|
|
|
|
|
}
|
2026-04-08 20:00:14 -03:00
|
|
|
if self.released {
|
2026-04-14 20:05:26 -03:00
|
|
|
self.enable_remote_capture();
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
self.publish_routing_state_if_changed();
|
2026-04-08 20:00:14 -03:00
|
|
|
} else {
|
2026-04-14 20:05:26 -03:00
|
|
|
self.begin_local_release();
|
2025-12-01 11:38:51 -03:00
|
|
|
}
|
2026-04-08 20:00:14 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-14 20:05:26 -03:00
|
|
|
fn enable_remote_capture(&mut self) {
|
2026-04-17 04:35:41 -03:00
|
|
|
self.remote_capture_enabled.store(true, Ordering::Relaxed);
|
2026-04-14 20:05:26 -03:00
|
|
|
for k in &mut self.keyboards {
|
|
|
|
|
k.reset_state();
|
|
|
|
|
k.set_send(true);
|
|
|
|
|
k.set_grab(true);
|
|
|
|
|
}
|
|
|
|
|
for m in &mut self.mice {
|
|
|
|
|
m.reset_state();
|
|
|
|
|
m.set_send(true);
|
|
|
|
|
m.set_grab(true);
|
|
|
|
|
}
|
|
|
|
|
self.released = false;
|
|
|
|
|
self.pending_release = false;
|
2026-04-20 18:41:48 -03:00
|
|
|
self.pending_release_started_at = None;
|
|
|
|
|
self.pending_keys.clear();
|
2026-04-20 21:11:33 -03:00
|
|
|
self.remote_failsafe_started_at =
|
|
|
|
|
(!self.remote_failsafe_timeout.is_zero()).then(Instant::now);
|
|
|
|
|
if !self.remote_failsafe_timeout.is_zero() {
|
|
|
|
|
info!(
|
|
|
|
|
"🛟 remote input failsafe armed for {} ms while the swap key path is being re-validated",
|
|
|
|
|
self.remote_failsafe_timeout.as_millis()
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-16 15:07:25 -03:00
|
|
|
self.last_keyboard_report = [0; 8];
|
2026-04-14 20:05:26 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn begin_local_release(&mut self) {
|
2026-04-20 18:41:48 -03:00
|
|
|
if self.released && !self.pending_release {
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
self.publish_routing_state_if_changed();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-20 21:11:33 -03:00
|
|
|
self.remote_failsafe_started_at = None;
|
2026-04-17 04:35:41 -03:00
|
|
|
self.remote_capture_enabled.store(false, Ordering::Relaxed);
|
2026-04-14 20:05:26 -03:00
|
|
|
for k in &mut self.keyboards {
|
|
|
|
|
k.send_empty_report();
|
|
|
|
|
k.set_send(false);
|
|
|
|
|
}
|
|
|
|
|
for m in &mut self.mice {
|
|
|
|
|
m.reset_state();
|
|
|
|
|
m.set_send(false);
|
|
|
|
|
}
|
|
|
|
|
self.pending_release = true;
|
2026-04-20 18:41:48 -03:00
|
|
|
self.pending_release_started_at = Some(Instant::now());
|
2026-04-16 15:07:25 -03:00
|
|
|
self.last_keyboard_report = [0; 8];
|
2026-04-14 20:05:26 -03:00
|
|
|
self.capture_pending_keys();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 18:41:48 -03:00
|
|
|
fn finish_local_release(&mut self, focus_launcher: bool) {
|
|
|
|
|
for k in &mut self.keyboards {
|
|
|
|
|
k.set_grab(false);
|
|
|
|
|
k.reset_state();
|
|
|
|
|
}
|
|
|
|
|
for m in &mut self.mice {
|
|
|
|
|
m.set_grab(false);
|
|
|
|
|
m.reset_state();
|
|
|
|
|
}
|
|
|
|
|
self.released = true;
|
|
|
|
|
self.pending_release = false;
|
|
|
|
|
self.pending_release_started_at = None;
|
|
|
|
|
self.pending_keys.clear();
|
2026-04-20 21:11:33 -03:00
|
|
|
self.remote_failsafe_started_at = None;
|
2026-04-20 18:41:48 -03:00
|
|
|
if focus_launcher {
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
focus_launcher_on_local_if_enabled();
|
|
|
|
|
}
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
self.publish_routing_state_if_changed();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn pending_release_timed_out(&self) -> bool {
|
|
|
|
|
(self.pending_release || self.pending_kill)
|
|
|
|
|
&& self
|
|
|
|
|
.pending_release_started_at
|
|
|
|
|
.is_some_and(|started_at| started_at.elapsed() >= self.pending_release_timeout)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 21:11:33 -03:00
|
|
|
fn remote_failsafe_expired(&self) -> bool {
|
|
|
|
|
!self.released
|
|
|
|
|
&& !self.pending_release
|
|
|
|
|
&& !self.pending_kill
|
|
|
|
|
&& !self.remote_failsafe_timeout.is_zero()
|
|
|
|
|
&& self
|
|
|
|
|
.remote_failsafe_started_at
|
|
|
|
|
.is_some_and(|started_at| started_at.elapsed() >= self.remote_failsafe_timeout)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 12:46:47 -03:00
|
|
|
fn remote_capture_active(&self) -> bool {
|
|
|
|
|
!self.released
|
|
|
|
|
&& !self.pending_release
|
|
|
|
|
&& !self.pending_kill
|
|
|
|
|
&& self.remote_capture_enabled.load(Ordering::Relaxed)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 20:00:14 -03:00
|
|
|
fn capture_pending_keys(&mut self) {
|
|
|
|
|
self.pending_keys.clear();
|
|
|
|
|
for k in &self.keyboards {
|
|
|
|
|
for key in k.pressed_keys_snapshot() {
|
|
|
|
|
self.pending_keys.insert(key);
|
|
|
|
|
}
|
2025-12-01 11:38:51 -03:00
|
|
|
}
|
2025-06-28 15:45:35 -05:00
|
|
|
}
|
2026-04-14 04:02:39 -03:00
|
|
|
|
2026-04-16 15:07:25 -03:00
|
|
|
fn process_keyboard_updates(&mut self) {
|
|
|
|
|
for index in 0..self.keyboards.len() {
|
2026-04-16 15:32:15 -03:00
|
|
|
let mut keyboard_shadow: HashSet<KeyCode> = self.keyboards[index]
|
|
|
|
|
.pressed_keys_snapshot()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.collect();
|
|
|
|
|
let other_pressed: HashSet<KeyCode> = self
|
|
|
|
|
.keyboards
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.filter(|(other_index, _)| *other_index != index)
|
|
|
|
|
.flat_map(|(_, keyboard)| keyboard.pressed_keys_snapshot())
|
|
|
|
|
.collect();
|
2026-04-16 15:07:25 -03:00
|
|
|
let updates = {
|
|
|
|
|
let keyboard = &mut self.keyboards[index];
|
|
|
|
|
keyboard.drain_key_updates()
|
|
|
|
|
};
|
|
|
|
|
for update in updates {
|
2026-04-16 15:32:15 -03:00
|
|
|
update_shadow_pressed_keys(&mut keyboard_shadow, update.code, update.value);
|
2026-04-16 15:07:25 -03:00
|
|
|
if update.swallowed || !self.keyboard_capture_enabled() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-04-16 15:32:15 -03:00
|
|
|
let report = build_keyboard_report(
|
|
|
|
|
other_pressed
|
|
|
|
|
.iter()
|
|
|
|
|
.copied()
|
|
|
|
|
.chain(keyboard_shadow.iter().copied()),
|
|
|
|
|
);
|
2026-04-16 15:07:25 -03:00
|
|
|
if report == self.last_keyboard_report {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
emit_live_keyboard_report(&self.kbd_tx, update.code, update.value, report);
|
|
|
|
|
self.last_keyboard_report = report;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn keyboard_capture_enabled(&self) -> bool {
|
|
|
|
|
self.keyboards
|
|
|
|
|
.iter()
|
|
|
|
|
.any(KeyboardAggregator::sending_enabled)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
fn quick_toggle_active(&mut self) -> bool {
|
|
|
|
|
self.quick_toggle_key.is_some_and(|key| {
|
|
|
|
|
self.keyboards
|
|
|
|
|
.iter_mut()
|
|
|
|
|
.any(|kbd| kbd.take_key_activation(key))
|
|
|
|
|
})
|
2026-04-14 04:02:39 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn observe_quick_toggle(&mut self, quick_toggle_now: bool) {
|
|
|
|
|
if quick_toggle_now && !self.quick_toggle_down {
|
|
|
|
|
let now = Instant::now();
|
|
|
|
|
let debounced = self
|
|
|
|
|
.last_quick_toggle_at
|
|
|
|
|
.is_none_or(|last| now.duration_since(last) >= self.quick_toggle_debounce);
|
|
|
|
|
if debounced {
|
|
|
|
|
if let Some(key) = self.quick_toggle_key {
|
|
|
|
|
info!(
|
|
|
|
|
"🎛️ quick-toggle {:?} engaged for smooth local/remote handoff",
|
|
|
|
|
key
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
self.toggle_grab();
|
|
|
|
|
self.last_quick_toggle_at = Some(now);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
self.quick_toggle_down = quick_toggle_now;
|
|
|
|
|
}
|
2026-04-14 20:05:26 -03:00
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn poll_launcher_routing_request(&mut self) {
|
|
|
|
|
let Some(path) = self.routing_control_path.as_deref() else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
2026-04-20 18:41:48 -03:00
|
|
|
let Some(raw) = read_launcher_control_snapshot(path) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
if self.last_routing_request_raw.as_deref() == Some(raw.as_str()) {
|
2026-04-14 20:05:26 -03:00
|
|
|
return;
|
|
|
|
|
}
|
2026-04-20 18:41:48 -03:00
|
|
|
self.last_routing_request_raw = Some(raw.clone());
|
|
|
|
|
let Some(remote_capture) = parse_launcher_routing_request(&raw) else {
|
2026-04-14 20:05:26 -03:00
|
|
|
return;
|
|
|
|
|
};
|
2026-04-20 18:41:48 -03:00
|
|
|
if self.pending_kill {
|
2026-04-14 20:05:26 -03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if remote_capture {
|
2026-04-20 18:41:48 -03:00
|
|
|
if !self.released && !self.pending_release {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-14 20:05:26 -03:00
|
|
|
info!("🎛️ launcher requested remote input capture");
|
|
|
|
|
self.enable_remote_capture();
|
|
|
|
|
self.publish_routing_state_if_changed();
|
|
|
|
|
} else {
|
2026-04-20 18:41:48 -03:00
|
|
|
if self.released && !self.pending_release {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-14 20:05:26 -03:00
|
|
|
info!("🎛️ launcher requested local input capture");
|
|
|
|
|
self.begin_local_release();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 18:41:48 -03:00
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn poll_launcher_quick_toggle_request(&mut self) {
|
|
|
|
|
let Some(path) = self.quick_toggle_control_path.as_deref() else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let Some(raw) = read_launcher_control_snapshot(path) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
if self.last_quick_toggle_request_raw.as_deref() == Some(raw.as_str()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
self.last_quick_toggle_request_raw = Some(raw.clone());
|
|
|
|
|
let next_key = raw
|
|
|
|
|
.split_ascii_whitespace()
|
|
|
|
|
.next()
|
|
|
|
|
.and_then(parse_quick_toggle_key);
|
|
|
|
|
self.quick_toggle_key = next_key;
|
|
|
|
|
self.quick_toggle_down = false;
|
|
|
|
|
self.last_quick_toggle_at = None;
|
|
|
|
|
match next_key {
|
|
|
|
|
Some(key) => info!("🎛️ launcher updated the live swap key to {:?}", key),
|
|
|
|
|
None => info!("🎛️ launcher disabled the live swap key"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn poll_launcher_clipboard_request(&mut self) {
|
|
|
|
|
let Some(path) = self.clipboard_control_path.as_deref() else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let marker = path_marker(path);
|
|
|
|
|
if marker <= self.clipboard_control_marker {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
self.clipboard_control_marker = marker;
|
|
|
|
|
let Some(keyboard) = self.keyboards.first_mut() else {
|
|
|
|
|
warn!("📋 launcher requested clipboard paste, but no keyboard is available");
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
info!("📋 launcher requested clipboard paste on the live relay session");
|
|
|
|
|
keyboard.trigger_clipboard_paste();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 20:05:26 -03:00
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn publish_routing_state_if_changed(&mut self) {
|
|
|
|
|
let remote_capture = !self.released;
|
|
|
|
|
if self.published_remote_capture == Some(remote_capture) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if let Some(path) = self.routing_state_path.as_deref() {
|
|
|
|
|
let _ = std::fs::write(
|
|
|
|
|
path,
|
2026-04-14 23:03:18 -03:00
|
|
|
if remote_capture {
|
|
|
|
|
"remote\n"
|
|
|
|
|
} else {
|
|
|
|
|
"local\n"
|
|
|
|
|
},
|
2026-04-14 20:05:26 -03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
self.published_remote_capture = Some(remote_capture);
|
|
|
|
|
}
|
2025-06-08 22:24:14 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The classification function
|
2026-04-13 02:52:32 -03:00
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn classify_device(dev: &Device) -> DeviceKind {
|
|
|
|
|
let evbits = dev.supported_events();
|
|
|
|
|
let keyset = dev.supported_keys();
|
|
|
|
|
|
|
|
|
|
if evbits.contains(EventType::KEY)
|
2026-04-14 04:02:39 -03:00
|
|
|
&& keyset
|
|
|
|
|
.is_some_and(|keys| keys.contains(KeyCode::KEY_A) || keys.contains(KeyCode::KEY_ENTER))
|
2026-04-13 02:52:32 -03:00
|
|
|
{
|
2026-04-16 15:07:25 -03:00
|
|
|
if should_ignore_keyboard_device(dev) {
|
|
|
|
|
return DeviceKind::Other;
|
|
|
|
|
}
|
2026-04-13 02:52:32 -03:00
|
|
|
return DeviceKind::Keyboard;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if evbits.contains(EventType::RELATIVE)
|
|
|
|
|
&& let (Some(rel), Some(keys)) = (dev.supported_relative_axes(), keyset)
|
|
|
|
|
&& rel.contains(RelativeAxisCode::REL_X)
|
|
|
|
|
&& rel.contains(RelativeAxisCode::REL_Y)
|
|
|
|
|
&& (keys.contains(KeyCode::BTN_LEFT) || keys.contains(KeyCode::BTN_RIGHT))
|
|
|
|
|
{
|
|
|
|
|
return DeviceKind::Mouse;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if evbits.contains(EventType::ABSOLUTE)
|
|
|
|
|
&& let (Some(abs), Some(keys)) = (dev.supported_absolute_axes(), keyset)
|
|
|
|
|
&& ((abs.contains(AbsoluteAxisCode::ABS_X) && abs.contains(AbsoluteAxisCode::ABS_Y))
|
|
|
|
|
|| (abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_X)
|
|
|
|
|
&& abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_Y)))
|
|
|
|
|
&& (keys.contains(KeyCode::BTN_TOUCH) || keys.contains(KeyCode::BTN_LEFT))
|
|
|
|
|
{
|
|
|
|
|
return DeviceKind::Mouse;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
DeviceKind::Other
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
2025-06-08 22:24:14 -05:00
|
|
|
fn classify_device(dev: &Device) -> DeviceKind {
|
|
|
|
|
let evbits = dev.supported_events();
|
|
|
|
|
|
2025-06-11 00:37:01 -05:00
|
|
|
// Keyboard logic
|
2025-06-08 22:24:14 -05:00
|
|
|
if evbits.contains(EventType::KEY) {
|
|
|
|
|
if let Some(keys) = dev.supported_keys() {
|
|
|
|
|
if keys.contains(KeyCode::KEY_A) || keys.contains(KeyCode::KEY_ENTER) {
|
2026-04-16 15:07:25 -03:00
|
|
|
if should_ignore_keyboard_device(dev) {
|
|
|
|
|
return DeviceKind::Other;
|
|
|
|
|
}
|
2025-06-08 22:24:14 -05:00
|
|
|
return DeviceKind::Keyboard;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-11 00:37:01 -05:00
|
|
|
|
2026-04-08 20:00:14 -03:00
|
|
|
// Mouse logic (relative)
|
2025-06-11 00:37:01 -05:00
|
|
|
if evbits.contains(EventType::RELATIVE) {
|
2025-12-01 11:38:51 -03:00
|
|
|
if let (Some(rel), Some(keys)) = (dev.supported_relative_axes(), dev.supported_keys()) {
|
|
|
|
|
let has_xy =
|
|
|
|
|
rel.contains(RelativeAxisCode::REL_X) && rel.contains(RelativeAxisCode::REL_Y);
|
|
|
|
|
let has_btn = keys.contains(KeyCode::BTN_LEFT) || keys.contains(KeyCode::BTN_RIGHT);
|
2025-06-11 00:37:01 -05:00
|
|
|
if has_xy && has_btn {
|
2025-06-08 22:24:14 -05:00
|
|
|
return DeviceKind::Mouse;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-08 20:00:14 -03:00
|
|
|
// Touchpad logic (absolute)
|
|
|
|
|
if evbits.contains(EventType::ABSOLUTE) {
|
|
|
|
|
if let (Some(abs), Some(keys)) = (dev.supported_absolute_axes(), dev.supported_keys()) {
|
|
|
|
|
let has_xy = (abs.contains(AbsoluteAxisCode::ABS_X)
|
|
|
|
|
&& abs.contains(AbsoluteAxisCode::ABS_Y))
|
|
|
|
|
|| (abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_X)
|
|
|
|
|
&& abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_Y));
|
|
|
|
|
let has_btn = keys.contains(KeyCode::BTN_TOUCH) || keys.contains(KeyCode::BTN_LEFT);
|
|
|
|
|
if has_xy && has_btn {
|
|
|
|
|
return DeviceKind::Mouse;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-08 22:24:14 -05:00
|
|
|
|
|
|
|
|
DeviceKind::Other
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Internal enum for device classification
|
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
|
|
|
enum DeviceKind {
|
|
|
|
|
Keyboard,
|
|
|
|
|
Mouse,
|
|
|
|
|
Other,
|
|
|
|
|
}
|
2026-04-14 04:02:39 -03:00
|
|
|
|
2026-04-16 15:07:25 -03:00
|
|
|
fn should_ignore_keyboard_device(dev: &Device) -> bool {
|
|
|
|
|
let name = dev.name().unwrap_or_default().to_ascii_lowercase();
|
|
|
|
|
name.contains("lesavka")
|
|
|
|
|
|| name.contains("automation input")
|
|
|
|
|
|| name.contains("codex-persistent-kbd")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 15:32:15 -03:00
|
|
|
fn update_shadow_pressed_keys(pressed_keys: &mut HashSet<KeyCode>, code: KeyCode, value: i32) {
|
|
|
|
|
if value == 0 {
|
|
|
|
|
pressed_keys.remove(&code);
|
|
|
|
|
} else if value > 0 {
|
|
|
|
|
pressed_keys.insert(code);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 13:09:25 -03:00
|
|
|
/// Resolves the quick-toggle key from env, defaulting to Pause/Break.
|
2026-04-14 04:02:39 -03:00
|
|
|
fn quick_toggle_key_from_env() -> Option<KeyCode> {
|
|
|
|
|
match std::env::var("LESAVKA_INPUT_TOGGLE_KEY") {
|
|
|
|
|
Ok(raw) => parse_quick_toggle_key(&raw),
|
2026-04-14 13:09:25 -03:00
|
|
|
Err(_) => Some(KeyCode::KEY_PAUSE),
|
2026-04-14 04:02:39 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parses a launcher/operator key alias into an evdev key code.
|
|
|
|
|
fn parse_quick_toggle_key(raw: &str) -> Option<KeyCode> {
|
|
|
|
|
let normalized = raw.trim().to_ascii_lowercase();
|
2026-04-15 04:44:06 -03:00
|
|
|
if matches!(normalized.as_str(), "" | "off" | "none" | "disabled") {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(letter) = parse_quick_toggle_letter(&normalized) {
|
|
|
|
|
return Some(letter);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(digit) = parse_quick_toggle_digit(&normalized) {
|
|
|
|
|
return Some(digit);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(function) = parse_quick_toggle_function_key(&normalized) {
|
|
|
|
|
return Some(function);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 04:02:39 -03:00
|
|
|
match normalized.as_str() {
|
2026-04-14 13:09:25 -03:00
|
|
|
"scrolllock" | "scroll_lock" | "scroll-lock" => Some(KeyCode::KEY_SCROLLLOCK),
|
|
|
|
|
"sysrq" | "sysreq" | "prtsc" | "printscreen" | "print_screen" | "print-screen" => {
|
|
|
|
|
Some(KeyCode::KEY_SYSRQ)
|
|
|
|
|
}
|
2026-04-14 04:02:39 -03:00
|
|
|
"pause" | "pausebreak" | "pause_break" | "pause-break" => Some(KeyCode::KEY_PAUSE),
|
2026-04-15 04:44:06 -03:00
|
|
|
"escape" | "esc" => Some(KeyCode::KEY_ESC),
|
|
|
|
|
"tab" => Some(KeyCode::KEY_TAB),
|
|
|
|
|
"capslock" | "caps_lock" | "caps-lock" => Some(KeyCode::KEY_CAPSLOCK),
|
|
|
|
|
"backspace" | "back_space" | "back-space" => Some(KeyCode::KEY_BACKSPACE),
|
|
|
|
|
"space" | "spacebar" => Some(KeyCode::KEY_SPACE),
|
|
|
|
|
"enter" | "return" => Some(KeyCode::KEY_ENTER),
|
|
|
|
|
"insert" => Some(KeyCode::KEY_INSERT),
|
|
|
|
|
"delete" | "del" => Some(KeyCode::KEY_DELETE),
|
|
|
|
|
"home" => Some(KeyCode::KEY_HOME),
|
|
|
|
|
"end" => Some(KeyCode::KEY_END),
|
|
|
|
|
"pageup" | "page_up" | "page-up" => Some(KeyCode::KEY_PAGEUP),
|
|
|
|
|
"pagedown" | "page_down" | "page-down" => Some(KeyCode::KEY_PAGEDOWN),
|
|
|
|
|
"left" => Some(KeyCode::KEY_LEFT),
|
|
|
|
|
"right" => Some(KeyCode::KEY_RIGHT),
|
|
|
|
|
"up" => Some(KeyCode::KEY_UP),
|
|
|
|
|
"down" => Some(KeyCode::KEY_DOWN),
|
2026-04-14 13:09:25 -03:00
|
|
|
_ => Some(KeyCode::KEY_PAUSE),
|
2026-04-14 04:02:39 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 04:44:06 -03:00
|
|
|
fn parse_quick_toggle_letter(raw: &str) -> Option<KeyCode> {
|
|
|
|
|
match raw {
|
|
|
|
|
"a" => Some(KeyCode::KEY_A),
|
|
|
|
|
"b" => Some(KeyCode::KEY_B),
|
|
|
|
|
"c" => Some(KeyCode::KEY_C),
|
|
|
|
|
"d" => Some(KeyCode::KEY_D),
|
|
|
|
|
"e" => Some(KeyCode::KEY_E),
|
|
|
|
|
"f" => Some(KeyCode::KEY_F),
|
|
|
|
|
"g" => Some(KeyCode::KEY_G),
|
|
|
|
|
"h" => Some(KeyCode::KEY_H),
|
|
|
|
|
"i" => Some(KeyCode::KEY_I),
|
|
|
|
|
"j" => Some(KeyCode::KEY_J),
|
|
|
|
|
"k" => Some(KeyCode::KEY_K),
|
|
|
|
|
"l" => Some(KeyCode::KEY_L),
|
|
|
|
|
"m" => Some(KeyCode::KEY_M),
|
|
|
|
|
"n" => Some(KeyCode::KEY_N),
|
|
|
|
|
"o" => Some(KeyCode::KEY_O),
|
|
|
|
|
"p" => Some(KeyCode::KEY_P),
|
|
|
|
|
"q" => Some(KeyCode::KEY_Q),
|
|
|
|
|
"r" => Some(KeyCode::KEY_R),
|
|
|
|
|
"s" => Some(KeyCode::KEY_S),
|
|
|
|
|
"t" => Some(KeyCode::KEY_T),
|
|
|
|
|
"u" => Some(KeyCode::KEY_U),
|
|
|
|
|
"v" => Some(KeyCode::KEY_V),
|
|
|
|
|
"w" => Some(KeyCode::KEY_W),
|
|
|
|
|
"x" => Some(KeyCode::KEY_X),
|
|
|
|
|
"y" => Some(KeyCode::KEY_Y),
|
|
|
|
|
"z" => Some(KeyCode::KEY_Z),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_quick_toggle_digit(raw: &str) -> Option<KeyCode> {
|
|
|
|
|
match raw {
|
|
|
|
|
"1" => Some(KeyCode::KEY_1),
|
|
|
|
|
"2" => Some(KeyCode::KEY_2),
|
|
|
|
|
"3" => Some(KeyCode::KEY_3),
|
|
|
|
|
"4" => Some(KeyCode::KEY_4),
|
|
|
|
|
"5" => Some(KeyCode::KEY_5),
|
|
|
|
|
"6" => Some(KeyCode::KEY_6),
|
|
|
|
|
"7" => Some(KeyCode::KEY_7),
|
|
|
|
|
"8" => Some(KeyCode::KEY_8),
|
|
|
|
|
"9" => Some(KeyCode::KEY_9),
|
|
|
|
|
"0" => Some(KeyCode::KEY_0),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_quick_toggle_function_key(raw: &str) -> Option<KeyCode> {
|
|
|
|
|
match raw {
|
|
|
|
|
"f1" => Some(KeyCode::KEY_F1),
|
|
|
|
|
"f2" => Some(KeyCode::KEY_F2),
|
|
|
|
|
"f3" => Some(KeyCode::KEY_F3),
|
|
|
|
|
"f4" => Some(KeyCode::KEY_F4),
|
|
|
|
|
"f5" => Some(KeyCode::KEY_F5),
|
|
|
|
|
"f6" => Some(KeyCode::KEY_F6),
|
|
|
|
|
"f7" => Some(KeyCode::KEY_F7),
|
|
|
|
|
"f8" => Some(KeyCode::KEY_F8),
|
|
|
|
|
"f9" => Some(KeyCode::KEY_F9),
|
|
|
|
|
"f10" => Some(KeyCode::KEY_F10),
|
|
|
|
|
"f11" => Some(KeyCode::KEY_F11),
|
|
|
|
|
"f12" => Some(KeyCode::KEY_F12),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 04:02:39 -03:00
|
|
|
/// Reads debounce window from env, with a safety floor to avoid rapid flapping.
|
|
|
|
|
fn quick_toggle_debounce_from_env() -> Duration {
|
|
|
|
|
let millis = std::env::var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|raw| raw.parse::<u64>().ok())
|
|
|
|
|
.unwrap_or(350);
|
|
|
|
|
Duration::from_millis(millis.max(50))
|
|
|
|
|
}
|
2026-04-14 13:09:25 -03:00
|
|
|
|
2026-04-20 18:41:48 -03:00
|
|
|
fn pending_release_timeout_from_env() -> Duration {
|
|
|
|
|
let millis = std::env::var("LESAVKA_INPUT_RELEASE_TIMEOUT_MS")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|raw| raw.parse::<u64>().ok())
|
|
|
|
|
.unwrap_or(750);
|
|
|
|
|
Duration::from_millis(millis.max(100))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 21:11:33 -03:00
|
|
|
fn remote_failsafe_timeout_from_env() -> Duration {
|
|
|
|
|
let millis = std::env::var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|raw| raw.parse::<u64>().ok())
|
2026-04-21 10:57:57 -03:00
|
|
|
.unwrap_or(60_000);
|
2026-04-20 21:11:33 -03:00
|
|
|
Duration::from_millis(millis)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 12:46:47 -03:00
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn input_rescan_interval_from_env() -> Duration {
|
|
|
|
|
let millis = std::env::var("LESAVKA_INPUT_RESCAN_MS")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|raw| raw.parse::<u64>().ok())
|
|
|
|
|
.unwrap_or(1_000);
|
|
|
|
|
if millis == 0 {
|
|
|
|
|
Duration::ZERO
|
|
|
|
|
} else {
|
|
|
|
|
Duration::from_millis(millis.max(250))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 12:48:57 -03:00
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn input_device_identity(path: &Path) -> Option<u64> {
|
|
|
|
|
std::fs::metadata(path)
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|metadata| metadata.ino() ^ metadata.rdev())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 13:09:25 -03:00
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn focus_launcher_on_local_if_enabled() {
|
|
|
|
|
if std::env::var("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL")
|
|
|
|
|
.map(|raw| raw.trim() == "0")
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-14 18:44:40 -03:00
|
|
|
let focus_signal_path = std::env::var("LESAVKA_LAUNCHER_FOCUS_SIGNAL")
|
|
|
|
|
.unwrap_or_else(|_| "/tmp/lesavka-launcher-focus.signal".to_string());
|
|
|
|
|
let _ = std::fs::write(
|
|
|
|
|
focus_signal_path,
|
|
|
|
|
format!(
|
|
|
|
|
"{}\n",
|
|
|
|
|
std::time::SystemTime::now()
|
|
|
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
|
|
|
.map(|duration| duration.as_millis())
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-04-16 12:58:05 -03:00
|
|
|
let title =
|
|
|
|
|
std::env::var("LESAVKA_LAUNCHER_WINDOW_TITLE").unwrap_or_else(|_| "Lesavka".to_string());
|
2026-04-14 13:09:25 -03:00
|
|
|
let _ = std::process::Command::new("wmctrl")
|
|
|
|
|
.args(["-a", &title])
|
|
|
|
|
.status();
|
|
|
|
|
}
|
2026-04-14 20:05:26 -03:00
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn launcher_routing_path_from_env(key: &str) -> Option<PathBuf> {
|
|
|
|
|
std::env::var(key)
|
|
|
|
|
.ok()
|
|
|
|
|
.map(PathBuf::from)
|
|
|
|
|
.filter(|path| !path.as_os_str().is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
fn input_device_override_from_env(key: &str) -> Option<String> {
|
|
|
|
|
std::env::var(key)
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|raw| raw.trim().to_string())
|
|
|
|
|
.filter(|raw| !raw.is_empty() && !raw.eq_ignore_ascii_case("all"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn matches_selected_input_device(path: &std::path::Path, selected: Option<&str>) -> bool {
|
|
|
|
|
selected.is_none_or(|selected| path.to_string_lossy() == selected)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 20:05:26 -03:00
|
|
|
#[cfg(not(coverage))]
|
2026-04-20 18:41:48 -03:00
|
|
|
fn read_launcher_control_snapshot(path: &Path) -> Option<String> {
|
2026-04-14 20:05:26 -03:00
|
|
|
let raw = std::fs::read_to_string(path).ok()?;
|
2026-04-20 18:41:48 -03:00
|
|
|
let trimmed = raw.trim();
|
|
|
|
|
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn parse_launcher_routing_request(raw: &str) -> Option<bool> {
|
|
|
|
|
match raw
|
|
|
|
|
.split_ascii_whitespace()
|
|
|
|
|
.next()?
|
|
|
|
|
.to_ascii_lowercase()
|
|
|
|
|
.as_str()
|
|
|
|
|
{
|
2026-04-14 20:05:26 -03:00
|
|
|
"remote" => Some(true),
|
|
|
|
|
"local" => Some(false),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn path_marker(path: &Path) -> u128 {
|
|
|
|
|
std::fs::metadata(path)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|meta| meta.modified().ok())
|
|
|
|
|
.and_then(|stamp| stamp.duration_since(std::time::UNIX_EPOCH).ok())
|
|
|
|
|
.map(|duration| duration.as_millis())
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
}
|
2026-04-15 04:44:06 -03:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::parse_quick_toggle_key;
|
|
|
|
|
use evdev::KeyCode;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_quick_toggle_key_supports_letters_digits_and_function_keys() {
|
|
|
|
|
assert_eq!(parse_quick_toggle_key("a"), Some(KeyCode::KEY_A));
|
|
|
|
|
assert_eq!(parse_quick_toggle_key("7"), Some(KeyCode::KEY_7));
|
|
|
|
|
assert_eq!(parse_quick_toggle_key("f12"), Some(KeyCode::KEY_F12));
|
|
|
|
|
assert_eq!(parse_quick_toggle_key("F3"), Some(KeyCode::KEY_F3));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_quick_toggle_key_supports_navigation_and_special_aliases() {
|
|
|
|
|
assert_eq!(parse_quick_toggle_key("page_up"), Some(KeyCode::KEY_PAGEUP));
|
|
|
|
|
assert_eq!(parse_quick_toggle_key("delete"), Some(KeyCode::KEY_DELETE));
|
|
|
|
|
assert_eq!(parse_quick_toggle_key("spacebar"), Some(KeyCode::KEY_SPACE));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_quick_toggle_key("print-screen"),
|
|
|
|
|
Some(KeyCode::KEY_SYSRQ)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_quick_toggle_key_can_disable_or_fall_back() {
|
|
|
|
|
assert_eq!(parse_quick_toggle_key("off"), None);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_quick_toggle_key("totally-unknown"),
|
|
|
|
|
Some(KeyCode::KEY_PAUSE)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|