From a3e84c5c15ac9fa6595d63c36d6ec5daed2648a5 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Thu, 16 Apr 2026 15:32:15 -0300 Subject: [PATCH] lesavka: preserve quick modifier chords --- client/src/input/inputs.rs | 37 +++++++++++----- testing/tests/client_inputs_extra_contract.rs | 42 +++++++++++++++++++ 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/client/src/input/inputs.rs b/client/src/input/inputs.rs index 773ddac..c8a90dd 100644 --- a/client/src/input/inputs.rs +++ b/client/src/input/inputs.rs @@ -473,15 +473,32 @@ impl InputAggregator { fn process_keyboard_updates(&mut self) { for index in 0..self.keyboards.len() { + let mut keyboard_shadow: HashSet = self.keyboards[index] + .pressed_keys_snapshot() + .into_iter() + .collect(); + let other_pressed: HashSet = self + .keyboards + .iter() + .enumerate() + .filter(|(other_index, _)| *other_index != index) + .flat_map(|(_, keyboard)| keyboard.pressed_keys_snapshot()) + .collect(); let updates = { let keyboard = &mut self.keyboards[index]; keyboard.drain_key_updates() }; for update in updates { + update_shadow_pressed_keys(&mut keyboard_shadow, update.code, update.value); if update.swallowed || !self.keyboard_capture_enabled() { continue; } - let report = self.build_combined_keyboard_report(); + let report = build_keyboard_report( + other_pressed + .iter() + .copied() + .chain(keyboard_shadow.iter().copied()), + ); if report == self.last_keyboard_report { continue; } @@ -497,16 +514,6 @@ impl InputAggregator { .any(KeyboardAggregator::sending_enabled) } - fn build_combined_keyboard_report(&self) -> [u8; 8] { - let mut pressed = HashSet::new(); - for keyboard in &self.keyboards { - for key in keyboard.pressed_keys_snapshot() { - pressed.insert(key); - } - } - build_keyboard_report(pressed.into_iter()) - } - fn quick_toggle_active(&mut self) -> bool { self.quick_toggle_key.is_some_and(|key| { self.keyboards @@ -696,6 +703,14 @@ fn should_ignore_keyboard_device(dev: &Device) -> bool { || name.contains("codex-persistent-kbd") } +fn update_shadow_pressed_keys(pressed_keys: &mut HashSet, code: KeyCode, value: i32) { + if value == 0 { + pressed_keys.remove(&code); + } else if value > 0 { + pressed_keys.insert(code); + } +} + /// Resolves the quick-toggle key from env, defaulting to Pause/Break. fn quick_toggle_key_from_env() -> Option { match std::env::var("LESAVKA_INPUT_TOGGLE_KEY") { diff --git a/testing/tests/client_inputs_extra_contract.rs b/testing/tests/client_inputs_extra_contract.rs index 0a81368..722a232 100644 --- a/testing/tests/client_inputs_extra_contract.rs +++ b/testing/tests/client_inputs_extra_contract.rs @@ -153,6 +153,48 @@ mod inputs_contract_extra { ); } + #[test] + #[serial] + fn process_keyboard_updates_keeps_quick_shift_chord_when_full_tap_lands_in_one_poll_cycle() { + let Some((mut vdev, dev)) = build_keyboard_pair_with_keys( + "lesavka-input-shift-batch", + &[evdev::KeyCode::KEY_LEFTSHIFT, evdev::KeyCode::KEY_A], + ) else { + return; + }; + + let (kbd_tx, mut rx) = tokio::sync::broadcast::channel(32); + let (mou_tx, _) = tokio::sync::broadcast::channel(16); + let keyboard = KeyboardAggregator::new(dev, false, kbd_tx.clone(), None); + + let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None); + agg.keyboards.push(keyboard); + + vdev.emit(&[ + evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_LEFTSHIFT.0, 1), + evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 1), + evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 0), + evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_LEFTSHIFT.0, 0), + ]) + .expect("emit quick shifted tap"); + thread::sleep(std::time::Duration::from_millis(25)); + + temp_env::with_var("LESAVKA_LIVE_MODIFIER_DELAY_MS", Some("0"), || { + agg.process_keyboard_updates(); + }); + + let reports: Vec> = + std::iter::from_fn(|| rx.try_recv().ok().map(|pkt| pkt.data)).collect(); + assert!( + reports.contains(&vec![0x02, 0, 0x04, 0, 0, 0, 0, 0]), + "expected shifted A report from one-batch chord, got {reports:?}" + ); + assert!( + reports.contains(&vec![0; 8]), + "expected final empty report after one-batch chord, got {reports:?}" + ); + } + #[test] #[serial] fn process_keyboard_updates_keeps_overlapping_plain_keys_from_sticking_across_keyboards() {