diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index abb0e75d..82b84ea1 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -7,6 +7,7 @@ use enigo::{Axis, Button, Direction, Enigo, Key, Keyboard, Mouse}; use serde_json::Value; use crate::modifier_overlay::ModifierKeyOverlayNotifier; +use crate::mouse_repeat::RepeatKey; use crate::protocol::MouseButton; use crate::state::{normalize_pointer_scale_percent, AppModel, SwitchBinding, SwitchProfile}; @@ -434,6 +435,7 @@ pub struct DesktopInput { pub(crate) injector: I, held_modifiers: HashSet, pending_modifier_releases: HashSet, + pending_key_releases: HashSet, modifier_overlay: Option>, held_button: Option, pointer_scale_percent: u32, @@ -462,6 +464,7 @@ impl DesktopInput { injector, held_modifiers: HashSet::new(), pending_modifier_releases: HashSet::new(), + pending_key_releases: HashSet::new(), modifier_overlay: None, held_button: None, pointer_scale_percent: 100, @@ -1195,6 +1198,27 @@ impl DesktopInput { Ok(()) } + /// Injects one discrete key tap for a repeat tick. + /// + /// A repeat never holds a key down between ticks, so a loop that dies for + /// any reason cannot leave a key stuck. The one remaining gap is a press + /// that succeeds followed by a release that fails; that key is recorded and + /// retried by `release_all`. + pub fn execute_repeat_key(&mut self, key: RepeatKey) -> Result<(), String> { + let name = key.protocol_name(); + self.injector.set_key(name, true)?; + match self.injector.set_key(name, false) { + Ok(()) => { + self.pending_key_releases.remove(&key); + Ok(()) + } + Err(error) => { + self.pending_key_releases.insert(key); + Err(error) + } + } + } + pub fn release_all(&mut self) -> Result<(), String> { let mut first_error = self.stop_switch_session().err(); self.text_streams.clear(); @@ -1204,12 +1228,47 @@ impl DesktopInput { if let Err(error) = self.release_held_modifiers() { first_error.get_or_insert(error); } + if let Err(error) = self.release_pending_keys() { + first_error.get_or_insert(error); + } if let Some(error) = first_error { return Err(error); } Ok(()) } + /// Releases any key left down by a failed repeat-tick release. + /// + /// Called when a repeat ends rather than only on the terminal cleanup + /// paths: a key stuck down keeps the OS's own auto-repeat firing into the + /// focused application, so waiting for a disconnect is far too late. + pub fn release_repeat_keys(&mut self) -> Result<(), String> { + if self.pending_key_releases.is_empty() { + return Ok(()); + } + self.release_pending_keys() + } + + /// Retries key releases that failed during a repeat tick. Every key is + /// attempted even when one fails, so a single bad release cannot strand the + /// others. + fn release_pending_keys(&mut self) -> Result<(), String> { + let mut first_error = None; + for key in RepeatKey::ALL { + if !self.pending_key_releases.remove(&key) { + continue; + } + if let Err(error) = self.injector.set_key(key.protocol_name(), false) { + self.pending_key_releases.insert(key); + first_error.get_or_insert(error); + } + } + match first_error { + Some(error) => Err(error), + None => Ok(()), + } + } + fn release_held_modifiers(&mut self) -> Result<(), String> { for key in ModifierKey::RELEASE_ORDER { let was_held = self.held_modifiers.remove(&key); @@ -2243,6 +2302,211 @@ mod tests { ); } + #[test] + fn repeat_key_taps_press_and_release_without_holding_the_key() { + let mut input = DesktopInput::new(FakeInjector::default()); + for _ in 0..3 { + assert!(input.execute_repeat_key(RepeatKey::ArrowDown).is_ok()); + } + assert_eq!( + input.injector.keys, + vec![ + ("ArrowDown".into(), true), + ("ArrowDown".into(), false), + ("ArrowDown".into(), true), + ("ArrowDown".into(), false), + ("ArrowDown".into(), true), + ("ArrowDown".into(), false), + ] + ); + // Nothing is held between ticks, so cleanup has nothing to release. + assert!(input.pending_key_releases.is_empty()); + assert!(input.release_all().is_ok()); + assert_eq!(input.injector.keys.len(), 6); + } + + #[test] + fn a_failed_repeat_key_release_is_retried_by_release_all() { + let mut input = DesktopInput::new(FakeInjector::default()); + input.injector.fail_key_up = Some("Tab".into()); + assert!(input.execute_repeat_key(RepeatKey::Tab).is_err()); + assert_eq!( + input.pending_key_releases, + HashSet::from([RepeatKey::Tab]), + "a key whose release failed must be remembered" + ); + + // The retry succeeds once injection recovers, and the key is cleared. + input.injector.fail_key_up = None; + assert!(input.release_all().is_ok()); + assert!(input.pending_key_releases.is_empty()); + assert_eq!( + input.injector.keys, + vec![ + ("Tab".into(), true), + ("Tab".into(), false), + ("Tab".into(), false), + ] + ); + } + + #[test] + fn a_still_failing_repeat_key_release_stays_pending_for_the_next_cleanup() { + let mut input = DesktopInput::new(FakeInjector::default()); + input.injector.fail_key_up = Some("Delete".into()); + assert!(input.execute_repeat_key(RepeatKey::Delete).is_err()); + assert!(input.release_all().is_err()); + assert_eq!( + input.pending_key_releases, + HashSet::from([RepeatKey::Delete]), + "an unrecovered key must not be forgotten" + ); + + input.injector.fail_key_up = None; + assert!(input.release_all().is_ok()); + assert!(input.pending_key_releases.is_empty()); + } + + #[test] + fn release_repeat_keys_frees_a_stuck_key_without_waiting_for_a_disconnect() { + let mut input = DesktopInput::new(FakeInjector::default()); + input.injector.fail_key_up = Some("Backspace".into()); + assert!(input.execute_repeat_key(RepeatKey::Backspace).is_err()); + + // Stopping the repeat must free the key: while it is held the OS keeps + // auto-repeating it into the focused application. + input.injector.fail_key_up = None; + assert!(input.release_repeat_keys().is_ok()); + assert!(input.pending_key_releases.is_empty()); + assert_eq!( + input.injector.keys, + vec![ + ("Backspace".into(), true), + ("Backspace".into(), false), + ("Backspace".into(), false), + ] + ); + } + + #[test] + fn a_key_stuck_after_a_failed_retry_is_freed_by_a_later_stop() { + let mut input = DesktopInput::new(FakeInjector::default()); + input.injector.fail_key_up = Some("ArrowLeft".into()); + assert!(input.execute_repeat_key(RepeatKey::ArrowLeft).is_err()); + // The retry at stop time fails too, so the key is still down with no + // repeat left to hang the recovery off. + assert!(input.release_repeat_keys().is_err()); + assert_eq!( + input.pending_key_releases, + HashSet::from([RepeatKey::ArrowLeft]) + ); + + // Stop paths run this unconditionally, so recovery arrives without + // needing another repeat to be active. + input.injector.fail_key_up = None; + assert!(input.release_repeat_keys().is_ok()); + assert!(input.pending_key_releases.is_empty()); + assert_eq!( + input.injector.keys.last().unwrap(), + &("ArrowLeft".to_string(), false) + ); + } + + #[test] + fn release_repeat_keys_injects_nothing_when_no_key_is_stuck() { + let mut input = DesktopInput::new(FakeInjector::default()); + assert!(input.execute_repeat_key(RepeatKey::ArrowUp).is_ok()); + let injected = input.injector.keys.len(); + + // The common case runs on every repeat stop, so it must not inject. + assert!(input.release_repeat_keys().is_ok()); + assert_eq!(input.injector.keys.len(), injected); + } + + #[test] + fn repeat_keys_are_refused_during_a_switch_session() { + // The repeat path bypasses execute(), so the runtimes enforce this guard + // themselves; this pins the message and the underlying predicate. + let mut input = DesktopInput::new(FakeInjector::default()); + assert!(!input.has_active_switch_session()); + let profile = SwitchProfile { + id: "profile".into(), + version: 1, + name: "Profile".into(), + provider: "mapped".into(), + built_in: false, + bindings: Vec::new(), + }; + input + .execute( + "device", + "switch.session.start", + &serde_json::json!({ + "sessionId": uuid::Uuid::new_v4().to_string(), + "profileId": "profile", + "profileVersion": 1, + "switchCount": 1 + }), + std::slice::from_ref(&profile), + ) + .unwrap(); + assert!(input.has_active_switch_session()); + assert_eq!( + input.execute( + "device", + "keyboard.key", + &serde_json::json!({"key": "ArrowDown"}), + &[], + ), + Err("Stop Switch Forwarding before using other PC control commands.".into()) + ); + } + + #[test] + fn a_later_successful_tap_clears_an_earlier_pending_release() { + let mut input = DesktopInput::new(FakeInjector::default()); + input.injector.fail_key_up = Some("PageUp".into()); + assert!(input.execute_repeat_key(RepeatKey::PageUp).is_err()); + assert!(!input.pending_key_releases.is_empty()); + + input.injector.fail_key_up = None; + assert!(input.execute_repeat_key(RepeatKey::PageUp).is_ok()); + assert!(input.pending_key_releases.is_empty()); + } + + #[test] + fn repeat_key_taps_leave_latched_modifiers_held() { + let mut input = DesktopInput::new(FakeInjector::default()); + assert!(input + .execute( + "device", + "keyboard.modifierDown", + &serde_json::json!({"key": "Shift"}), + &[], + ) + .is_ok()); + for _ in 0..2 { + assert!(input.execute_repeat_key(RepeatKey::ArrowRight).is_ok()); + } + // Shift stays down across every tick so a repeating arrow extends a + // selection rather than moving the caret. + assert_eq!( + input.injector.keys, + vec![ + ("Shift".into(), true), + ("ArrowRight".into(), true), + ("ArrowRight".into(), false), + ("ArrowRight".into(), true), + ("ArrowRight".into(), false), + ] + ); + assert!(input.release_all().is_ok()); + assert_eq!( + input.injector.keys.last().unwrap(), + &("Shift".into(), false) + ); + } + #[test] fn modifier_failures_keep_overlay_consistent() { let (mut down_input, down_overlay) = input_with_modifier_overlay(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b5c89cb8..68bac917 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -432,6 +432,8 @@ fn save_settings( previous_consent, previous_dwell_enabled, previous_dwell_delay, + previous_mouse_repeat_enabled, + previous_key_repeat_enabled, ) = { let state = &model .shared @@ -444,6 +446,8 @@ fn save_settings( state.telemetry.consent, state.settings.dwell_click_enabled, state.settings.dwell_click_delay_ms, + state.settings.mouse_repeat_enabled, + state.settings.key_repeat_enabled, ) }; if settings.start_with_system != previous_start_with_system { @@ -464,7 +468,13 @@ fn save_settings( { app.state::().cancel(&app); } - if !settings.mouse_repeat_enabled { + // Turning either repeat off stops whatever is in flight, so the runtime + // loops end deliberately instead of erroring out on their next tick. Only a + // change stops repeats: testing the current value would cancel an active + // mouse repeat on every unrelated save while key repeat is switched off. + if (previous_mouse_repeat_enabled && !settings.mouse_repeat_enabled) + || (previous_key_repeat_enabled && !settings.key_repeat_enabled) + { platform_stop_mouse_repeat(&app); } overlay.apply_settings(settings); diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 482b7888..fdeab741 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -1435,14 +1435,41 @@ impl MacRuntime { self.stop_repeat_for_device(&command.device_id); return Ok(()); } - if !settings.mouse_repeat_enabled { + let enabled = if repeat_command.repeat_key().is_some() { + settings.key_repeat_enabled + } else { + settings.mouse_repeat_enabled + }; + if !enabled { self.stop_repeat_for_device(&command.device_id); - return Err("Mouse repeat is disabled in settings.".into()); + return Err(if repeat_command.repeat_key().is_some() { + "Key repeat is disabled in settings.".to_string() + } else { + "Mouse repeat is disabled in settings.".to_string() + }); } self.stop_repeat_for_device(&command.device_id); if self.input.is_none() && !self.refresh_accessibility(false)? { + return Err(if repeat_command.repeat_key().is_some() { + "Accessibility permission is required before input can be controlled." + .to_string() + } else { + "Accessibility permission is required before the pointer can move.".to_string() + }); + } + // A key repeat injects keystrokes, which `DesktopInput::execute` + // refuses during Switch Forwarding; the repeat path bypasses that + // guard, so it enforces the same rule itself. Scoped to keys + // deliberately: pointer repeats have always been allowed here, and + // changing that is a separate behaviour decision. + if repeat_command.repeat_key().is_some() + && self + .input + .as_ref() + .is_some_and(|input| input.has_active_switch_session()) + { return Err( - "Accessibility permission is required before the pointer can move.".into(), + "Stop Switch Forwarding before using other PC control commands.".to_string(), ); } let active = self.repeats.start( @@ -1457,41 +1484,61 @@ impl MacRuntime { .repeats .initial_move(&command.device_id, active.generation) .unwrap_or((0, 0)); - self.execute_repeat_move(active.generation, dx, dy) + self.execute_repeat_move(active.generation, dx, dy).map(Some) } RepeatCommand::Scroll { dx, dy } => match self.input.as_mut() { - Some(input) => input.execute_repeat_scroll(dx, dy), + Some(input) => input.execute_repeat_scroll(dx, dy).map(Some), None => Err( "Accessibility permission is required before input can be controlled." .to_string(), ), }, + // The first tap fires immediately; the tick then waits the + // initial delay before repeating. Key repeats produce no pointer + // feedback, so the cursor overlay is hidden rather than driven. + RepeatCommand::Key { .. } => { + match self.repeats.initial_key(&command.device_id, active.generation) { + Some(key) => match self.input.as_mut() { + Some(input) => input.execute_repeat_key(key).map(|()| None), + None => Err( + "Accessibility permission is required before input can be controlled." + .to_string(), + ), + }, + None => Ok(None), + } + } }; let initial_feedback = match initial { Ok(feedback) => feedback, Err(error) => { self.repeats .stop_if_current(&command.device_id, active.generation); + // A failed initial key tap can still have left the key down. + self.release_repeat_keys(); return Err(error); } }; - self.app.state::().begin_repeat( - active.generation, - repeat_command, - settings.mouse_repeat_acceleration_duration_ms > 0, - matches!(initial_feedback, PointerFeedback::Drag), - settings.clone(), - ); - self.schedule_repeat_tick( - command.device_id.clone(), - active.generation, - match repeat_command { - RepeatCommand::Move { .. } => MOVE_TICK_INTERVAL_MS, - RepeatCommand::Scroll { .. } => { - u64::from(repeat_command.interval_ms(&settings)) - } - }, - ); + match initial_feedback { + Some(feedback) => self.app.state::().begin_repeat( + active.generation, + repeat_command, + settings.mouse_repeat_acceleration_duration_ms > 0, + matches!(feedback, PointerFeedback::Drag), + settings.clone(), + ), + None => self.app.state::().hide_for_typing(), + } + let delay_ms = match repeat_command { + RepeatCommand::Move { .. } => MOVE_TICK_INTERVAL_MS, + RepeatCommand::Scroll { .. } => u64::from(repeat_command.interval_ms(&settings)), + RepeatCommand::Key { .. } => u64::from( + self.repeats + .key_delay_ms(&command.device_id, active.generation, &settings) + .unwrap_or_else(|| settings.key_repeat_initial_delay_ms.max(1)), + ), + }; + self.schedule_repeat_tick(command.device_id.clone(), active.generation, delay_ms); Ok(()) }); self.complete_repeat_command(command, injection); @@ -1548,7 +1595,13 @@ impl MacRuntime { let Some(active) = self.repeats.current(&device_id, generation) else { return Ok(()); }; - if !settings.mouse_repeat_enabled { + let is_key_repeat = active.command.repeat_key().is_some(); + let enabled = if is_key_repeat { + settings.key_repeat_enabled + } else { + settings.mouse_repeat_enabled + }; + if !enabled { self.stop_repeat_if_current(&device_id, generation, false); return Ok(()); } @@ -1575,7 +1628,7 @@ impl MacRuntime { settings.move_repeat_interval_ms, settings.pointer_scale_percent, ), - RepeatCommand::Scroll { .. } => None, + RepeatCommand::Scroll { .. } | RepeatCommand::Key { .. } => None, }; let result = match active.command { RepeatCommand::Move { .. } => match movement { @@ -1589,16 +1642,36 @@ impl MacRuntime { .to_string(), ), }, + RepeatCommand::Key { .. } => match self.repeats.advance_key(&device_id, generation) { + Some(key) => match self.input.as_mut() { + Some(input) => input.execute_repeat_key(key), + None => Err( + "Accessibility permission is required before input can be controlled." + .to_string(), + ), + }, + None => Ok(()), + }, }; if let Err(error) = result { if self.stop_repeat_if_current(&device_id, generation, false) { - self.report_error(format!("Mouse repeat stopped: {error}")); + self.report_error(format!( + "{} repeat stopped: {error}", + if is_key_repeat { "Key" } else { "Mouse" } + )); } return Ok(()); } let delay_ms = match active.command { RepeatCommand::Move { .. } => MOVE_TICK_INTERVAL_MS, RepeatCommand::Scroll { .. } => u64::from(active.command.interval_ms(&settings)), + RepeatCommand::Key { .. } => { + let Some(delay) = self.repeats.key_delay_ms(&device_id, generation, &settings) + else { + return Ok(()); + }; + u64::from(delay) + } }; self.schedule_repeat_tick(device_id, generation, delay_ms); Ok(()) @@ -1659,6 +1732,9 @@ impl MacRuntime { .current(device_id, generation) .is_some_and(|active| matches!(active.command, RepeatCommand::Move { .. })); let stopped = self.repeats.stop_if_current(device_id, generation); + // Unconditional: a key whose retry also failed is still pending with no + // active repeat, and nothing else would ever free it. + self.release_repeat_keys(); if stopped { self.pending_repeat_moves.remove(&generation); self.app.state::().end_repeat(generation); @@ -1669,11 +1745,21 @@ impl MacRuntime { stopped } + /// Releases a key left down by a failed repeat-tick release. Runs whenever a + /// repeat ends, not only on terminal cleanup, because a stuck key keeps the + /// OS auto-repeating into the focused application. + fn release_repeat_keys(&mut self) { + if let Some(input) = self.input.as_mut() { + let _ = input.release_repeat_keys(); + } + } + fn stop_repeat_for_device( &mut self, device_id: &str, ) -> Option { let active = self.repeats.stop(device_id); + self.release_repeat_keys(); if let Some(active) = active { self.pending_repeat_moves.remove(&active.generation); self.app @@ -1684,7 +1770,9 @@ impl MacRuntime { } fn stop_all_repeats(&mut self) { - for active in self.repeats.stop_all() { + let stopped = self.repeats.stop_all(); + self.release_repeat_keys(); + for active in stopped { self.pending_repeat_moves.remove(&active.generation); self.app .state::() diff --git a/src-tauri/src/mouse_repeat.rs b/src-tauri/src/mouse_repeat.rs index 15f0bff4..bb0aae0d 100644 --- a/src-tauri/src/mouse_repeat.rs +++ b/src-tauri/src/mouse_repeat.rs @@ -10,10 +10,65 @@ pub const MOVE_TICK_INTERVAL_MS: u64 = 8; const MAX_MOVE_ELAPSED_MS: f64 = 16.0; const PIXEL_EPSILON: f64 = 1e-9; +/// Keys that may be driven by a repeat. +/// +/// Deliberately excludes printable characters and Space (a runaway repeat fills +/// documents), modifiers (latching already belongs to `keyboard.modifierDown`), +/// and Enter (repeat-submitting a form is destructive in a way arrow keys are +/// not). The desktop advertises this list so it can widen without a client +/// release. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RepeatKey { + ArrowUp, + ArrowDown, + ArrowLeft, + ArrowRight, + Tab, + Backspace, + Delete, + PageUp, + PageDown, +} + +impl RepeatKey { + pub const ALL: [Self; 9] = [ + Self::ArrowUp, + Self::ArrowDown, + Self::ArrowLeft, + Self::ArrowRight, + Self::Tab, + Self::Backspace, + Self::Delete, + Self::PageUp, + Self::PageDown, + ]; + + pub fn parse(name: &str) -> Option { + Self::ALL + .into_iter() + .find(|key| key.protocol_name() == name) + } + + pub fn protocol_name(self) -> &'static str { + match self { + Self::ArrowUp => "ArrowUp", + Self::ArrowDown => "ArrowDown", + Self::ArrowLeft => "ArrowLeft", + Self::ArrowRight => "ArrowRight", + Self::Tab => "Tab", + Self::Backspace => "Backspace", + Self::Delete => "Delete", + Self::PageUp => "PageUp", + Self::PageDown => "PageDown", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RepeatCommand { Move { dx: i32, dy: i32 }, Scroll { dx: i32, dy: i32 }, + Key { key: RepeatKey }, } impl RepeatCommand { @@ -46,6 +101,14 @@ impl RepeatCommand { dx: value("dx", 50)?, dy: value("dy", 50)?, }), + "keyboard.key" => { + let key = nested + .get("key") + .and_then(Value::as_str) + .and_then(RepeatKey::parse) + .ok_or_else(|| "Key repeat command is invalid.".to_string())?; + Ok(Self::Key { key }) + } _ => Err("Mouse repeat command is invalid.".into()), } } @@ -54,6 +117,14 @@ impl RepeatCommand { match self { Self::Move { .. } => settings.move_repeat_interval_ms, Self::Scroll { .. } => settings.scroll_repeat_interval_ms, + Self::Key { .. } => settings.key_repeat_interval_ms, + } + } + + pub fn repeat_key(self) -> Option { + match self { + Self::Key { key } => Some(key), + _ => None, } } } @@ -64,6 +135,7 @@ pub struct ActiveRepeat { pub command: RepeatCommand, pub acceleration_duration_ms: u32, movement: Option, + key: Option, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -150,6 +222,45 @@ impl MoveRepeatState { } } +/// Cadence for a repeating key. +/// +/// The first tap fires immediately when the repeat starts, the second after +/// `key_repeat_initial_delay_ms`, and every later tap after +/// `key_repeat_interval_ms`. This mirrors how a keyboard's own auto-repeat +/// behaves and is the discrete analogue of the pointer acceleration ramp, which +/// has no meaning for whole key presses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct KeyRepeatState { + taps: u32, +} + +impl KeyRepeatState { + fn new() -> Self { + Self { taps: 0 } + } + + fn initial_tap(&mut self) -> bool { + if self.taps > 0 { + return false; + } + self.taps = 1; + true + } + + fn repeat_tap(&mut self) { + self.taps = self.taps.saturating_add(1); + } + + fn next_delay_ms(self, settings: &AppSettings) -> u32 { + let delay = if self.taps <= 1 { + settings.key_repeat_initial_delay_ms + } else { + settings.key_repeat_interval_ms + }; + delay.max(1) + } +} + #[derive(Debug, Default)] pub struct MouseRepeatController { next_generation: u64, @@ -171,6 +282,7 @@ impl MouseRepeatController { acceleration_duration_ms, movement: matches!(command, RepeatCommand::Move { .. }) .then(|| MoveRepeatState::new(now)), + key: matches!(command, RepeatCommand::Key { .. }).then(KeyRepeatState::new), }; self.active.insert(device_id, active); active @@ -224,6 +336,51 @@ impl MouseRepeatController { }) } + /// Emits the first key tap, which fires immediately when the repeat starts. + pub fn initial_key(&mut self, device_id: &str, generation: u64) -> Option { + let active = self.active.get_mut(device_id)?; + if active.generation != generation { + return None; + } + let RepeatCommand::Key { key } = active.command else { + return None; + }; + active + .key + .as_mut() + .and_then(|state| state.initial_tap().then_some(key)) + } + + /// Emits a subsequent key tap once the current delay has elapsed. + pub fn advance_key(&mut self, device_id: &str, generation: u64) -> Option { + let active = self.active.get_mut(device_id)?; + if active.generation != generation { + return None; + } + let RepeatCommand::Key { key } = active.command else { + return None; + }; + active.key.as_mut().map(|state| { + state.repeat_tap(); + key + }) + } + + /// How long to wait before the next key tap. Settings are read fresh on + /// every tick so a cadence change applies to a repeat already in flight. + pub fn key_delay_ms( + &self, + device_id: &str, + generation: u64, + settings: &AppSettings, + ) -> Option { + let active = self.active.get(device_id)?; + if active.generation != generation { + return None; + } + active.key.map(|state| state.next_delay_ms(settings)) + } + pub fn stop(&mut self, device_id: &str) -> Option { self.active.remove(device_id) } @@ -276,6 +433,176 @@ mod tests { use serde_json::json; use std::time::Duration; + #[test] + fn parses_repeatable_keys_and_rejects_everything_else() { + for key in RepeatKey::ALL { + assert_eq!( + RepeatCommand::parse( + &json!({"command":{"type":"keyboard.key","payload":{"key":key.protocol_name()}}}) + ), + Ok(RepeatCommand::Key { key }) + ); + } + for key in [ + "a", "A", "Space", "Enter", "Escape", "Ctrl", "Home", "F1", "", + ] { + assert_eq!( + RepeatCommand::parse( + &json!({"command":{"type":"keyboard.key","payload":{"key":key}}}) + ), + Err("Key repeat command is invalid.".to_string()) + ); + } + assert_eq!( + RepeatCommand::parse(&json!({"command":{"type":"keyboard.key","payload":{}}})), + Err("Key repeat command is invalid.".to_string()) + ); + } + + #[test] + fn key_repeat_fires_immediately_then_waits_the_initial_delay_then_the_interval() { + let settings = AppSettings { + key_repeat_interval_ms: 250, + key_repeat_initial_delay_ms: 500, + ..AppSettings::default() + }; + let mut controller = MouseRepeatController::default(); + let active = controller.start( + "device".into(), + RepeatCommand::Key { + key: RepeatKey::ArrowDown, + }, + 0, + Instant::now(), + ); + + // The first tap is immediate, and only ever emitted once. + assert_eq!( + controller.initial_key("device", active.generation), + Some(RepeatKey::ArrowDown) + ); + assert_eq!(controller.initial_key("device", active.generation), None); + assert_eq!( + controller.key_delay_ms("device", active.generation, &settings), + Some(500) + ); + + // Every tap after the second one uses the steady-state interval. + assert_eq!( + controller.advance_key("device", active.generation), + Some(RepeatKey::ArrowDown) + ); + assert_eq!( + controller.key_delay_ms("device", active.generation, &settings), + Some(250) + ); + assert_eq!( + controller.advance_key("device", active.generation), + Some(RepeatKey::ArrowDown) + ); + assert_eq!( + controller.key_delay_ms("device", active.generation, &settings), + Some(250) + ); + } + + #[test] + fn key_repeat_delay_never_reaches_zero_and_follows_live_settings_changes() { + let mut controller = MouseRepeatController::default(); + let active = controller.start( + "device".into(), + RepeatCommand::Key { + key: RepeatKey::Tab, + }, + 0, + Instant::now(), + ); + controller.initial_key("device", active.generation); + + // A zero initial delay must not spin the tick loop. + let immediate = AppSettings { + key_repeat_initial_delay_ms: 0, + ..AppSettings::default() + }; + assert_eq!( + controller.key_delay_ms("device", active.generation, &immediate), + Some(1) + ); + + // Settings are read per tick, so a cadence change applies mid-repeat. + controller.advance_key("device", active.generation); + let faster = AppSettings { + key_repeat_interval_ms: 100, + ..AppSettings::default() + }; + assert_eq!( + controller.key_delay_ms("device", active.generation, &faster), + Some(100) + ); + } + + #[test] + fn stale_generations_never_emit_key_taps() { + let mut controller = MouseRepeatController::default(); + let first = controller.start( + "device".into(), + RepeatCommand::Key { + key: RepeatKey::PageDown, + }, + 0, + Instant::now(), + ); + let second = controller.start( + "device".into(), + RepeatCommand::Key { + key: RepeatKey::PageUp, + }, + 0, + Instant::now(), + ); + assert_ne!(first.generation, second.generation); + assert_eq!(controller.initial_key("device", first.generation), None); + assert_eq!(controller.advance_key("device", first.generation), None); + assert_eq!( + controller.key_delay_ms("device", first.generation, &AppSettings::default()), + None + ); + assert_eq!( + controller.initial_key("device", second.generation), + Some(RepeatKey::PageUp) + ); + + controller.stop("device"); + assert_eq!(controller.advance_key("device", second.generation), None); + } + + #[test] + fn pointer_repeats_never_emit_key_taps_and_key_repeats_never_move_the_pointer() { + let mut controller = MouseRepeatController::default(); + let pointer = controller.start( + "device".into(), + RepeatCommand::Move { dx: 10, dy: 0 }, + 0, + Instant::now(), + ); + assert_eq!(controller.initial_key("device", pointer.generation), None); + assert_eq!(controller.advance_key("device", pointer.generation), None); + + let key = controller.start( + "device".into(), + RepeatCommand::Key { + key: RepeatKey::Backspace, + }, + 0, + Instant::now(), + ); + assert_eq!(controller.initial_move("device", key.generation), None); + assert_eq!( + controller.advance_move("device", key.generation, Instant::now(), 250, 100), + None + ); + } + #[test] fn parses_only_bounded_move_and_scroll_commands() { assert_eq!( diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index 5ca92e80..f11889bf 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -287,7 +287,12 @@ impl OverlayEngine { Command::BeginRepeat(generation, command, accelerated, dragging, settings) => { self.typing_suppressed = false; self.settings = settings; - if !self.settings.cursor_overlay_enabled { + // Key repeats carry no pointer feedback: the runtimes hide the + // overlay rather than beginning one. Checked before any state is + // touched so this safety net cannot leave stale feedback behind + // for a later re-render. + if !self.settings.cursor_overlay_enabled || command.repeat_key().is_some() { + self.feedback = None; return self.hide(); } self.control_active = true; @@ -299,6 +304,7 @@ impl OverlayEngine { dragging, }, RepeatCommand::Scroll { dx, dy } => PointerFeedback::RepeatScroll { dx, dy }, + RepeatCommand::Key { .. } => return self.hide(), }; self.feedback = Some(feedback); self.deadline = None; diff --git a/src-tauri/src/protocol.rs b/src-tauri/src/protocol.rs index a633813b..f2486902 100644 --- a/src-tauri/src/protocol.rs +++ b/src-tauri/src/protocol.rs @@ -10,6 +10,7 @@ use serde_json::{json, Map, Value}; use sha2::Sha256; use uuid::Uuid; +use crate::mouse_repeat::RepeatKey; use crate::state::AppSettings; pub const PROTOCOL_VERSION: i64 = 1; @@ -1104,18 +1105,29 @@ fn valid_repeat_start(object: &Map) -> bool { let Some(payload_object) = payload.as_object() else { return false; }; - payload_object.len() == 2 - && match command_type { - "mouse.move" => { - bounded_number(payload, "dx", MAX_POINTER_DELTA).is_some() - && bounded_number(payload, "dy", MAX_POINTER_DELTA).is_some() - } - "mouse.scroll" => { - bounded_number(payload, "dx", 50.0).is_some() - && bounded_number(payload, "dy", 50.0).is_some() - } - _ => false, + match command_type { + "mouse.move" => { + payload_object.len() == 2 + && bounded_number(payload, "dx", MAX_POINTER_DELTA).is_some() + && bounded_number(payload, "dy", MAX_POINTER_DELTA).is_some() } + "mouse.scroll" => { + payload_object.len() == 2 + && bounded_number(payload, "dx", 50.0).is_some() + && bounded_number(payload, "dy", 50.0).is_some() + } + // The repeatable-key allowlist is owned by `RepeatKey` so the validator + // and the runtime can never disagree about what may repeat. + "keyboard.key" => { + payload_object.len() == 1 + && payload_object + .get("key") + .and_then(Value::as_str) + .and_then(RepeatKey::parse) + .is_some() + } + _ => false, + } } fn valid_stream_id(value: Option<&Value>) -> bool { @@ -1423,6 +1435,18 @@ pub fn pointer_profile_response( "accelerationDurationOptionsMs": [0, 500, 1000, 2000], "accelerationInitialScalePercent": 25 }, + "keyRepeat": { + "supported": true, + "enabled": settings.key_repeat_enabled, + "intervalMs": settings.key_repeat_interval_ms, + "initialDelayMs": settings.key_repeat_initial_delay_ms, + "minIntervalMs": 100, + "maxIntervalMs": 1000, + "repeatableKeys": RepeatKey::ALL + .iter() + .map(|key| key.protocol_name()) + .collect::>() + }, "pointerSpeed": { "supported": true, "setSupported": true, @@ -2375,6 +2399,182 @@ mod tests { )); } + #[test] + fn repeat_start_accepts_every_repeatable_key_and_rejects_the_rest() { + for key in RepeatKey::ALL { + assert!( + valid_desktop_payload( + "mouse.repeat.start", + &json!({"command":{"type":"keyboard.key","payload":{"key":key.protocol_name()}}}) + ), + "{} should be repeatable", + key.protocol_name() + ); + } + for payload in [ + // Printable characters and Space would flood text, modifiers belong + // to keyboard.modifierDown, and Enter would re-submit on every tick. + json!({"command":{"type":"keyboard.key","payload":{"key":"a"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":"A"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":"Space"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":"Enter"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":"Escape"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":"Ctrl"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":"Shift"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":"Home"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":"F1"}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":""}}}), + // Shape violations. + json!({"command":{"type":"keyboard.key","payload":{"key":"Tab","extra":true}}}), + json!({"command":{"type":"keyboard.key","payload":{}}}), + json!({"command":{"type":"keyboard.key","payload":{"key":1}}}), + json!({"command":{"type":"keyboard.shortcut","payload":{"keys":["Tab"]}}}), + ] { + assert!( + !valid_desktop_payload("mouse.repeat.start", &payload), + "{payload} should be rejected" + ); + } + } + + #[test] + fn repeat_start_still_accepts_the_v1_pointer_payloads_byte_for_byte() { + // Pins the exact shapes shipped clients send today. Widening the nested + // envelope for keyboard.key must not move these acceptance boundaries. + for payload in [ + json!({"command":{"type":"mouse.move","payload":{"dx":0,"dy":0}}}), + json!({"command":{"type":"mouse.move","payload":{"dx":-64,"dy":128}}}), + json!({"command":{"type":"mouse.move","payload":{"dx":500,"dy":-500}}}), + json!({"command":{"type":"mouse.scroll","payload":{"dx":0,"dy":5}}}), + json!({"command":{"type":"mouse.scroll","payload":{"dx":-50,"dy":50}}}), + ] { + assert!( + valid_desktop_payload("mouse.repeat.start", &payload), + "{payload} is a shipped v1 payload" + ); + } + for payload in [ + json!({"command":{"type":"mouse.move","payload":{"dx":501,"dy":0}}}), + json!({"command":{"type":"mouse.scroll","payload":{"dx":0,"dy":51}}}), + json!({"command":{"type":"mouse.move","payload":{"dx":1}}}), + json!({"command":{"type":"mouse.scroll","payload":{"dx":1,"dy":1,"extra":0}}}), + ] { + assert!( + !valid_desktop_payload("mouse.repeat.start", &payload), + "{payload} was rejected before this change" + ); + } + } + + #[test] + fn pointer_profile_advertises_key_repeat_without_disturbing_mouse_repeat() { + let profile = PointerProfile { + display_id: "display:0:0:1920:1080:1".into(), + scale_factor: 1.0, + x: 0, + y: 0, + width: 1920, + height: 1080, + small_delta: 49, + medium_delta: 130, + large_delta: 281, + display_navigation_supported: true, + display_count: 2, + }; + let settings = AppSettings::default(); + let response: Value = + serde_json::from_str(&pointer_profile_response("profile-1", &profile, &settings)) + .unwrap(); + let capabilities = &response["payload"]["capabilities"]; + + let key_repeat = &capabilities["keyRepeat"]; + assert_eq!(key_repeat["supported"], true); + assert_eq!(key_repeat["enabled"], settings.key_repeat_enabled); + assert_eq!(key_repeat["intervalMs"], settings.key_repeat_interval_ms); + assert_eq!( + key_repeat["initialDelayMs"], + settings.key_repeat_initial_delay_ms + ); + assert_eq!(key_repeat["minIntervalMs"], 100); + assert_eq!(key_repeat["maxIntervalMs"], 1000); + assert_eq!( + key_repeat["repeatableKeys"], + json!([ + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "Tab", + "Backspace", + "Delete", + "PageUp", + "PageDown" + ]) + ); + + // An old client reads only these; adding keyRepeat must not move them. + assert_eq!( + capabilities["mouseRepeat"], + json!({ + "supported": true, + "enabled": settings.mouse_repeat_enabled, + "intervalMs": settings.move_repeat_interval_ms, + "moveIntervalMs": settings.move_repeat_interval_ms, + "scrollIntervalMs": settings.scroll_repeat_interval_ms, + "minIntervalMs": 100, + "maxIntervalMs": 2000, + "accelerationDurationMs": settings.mouse_repeat_acceleration_duration_ms, + "accelerationDurationOptionsMs": [0, 500, 1000, 2000], + "accelerationInitialScalePercent": 25 + }) + ); + } + + #[test] + fn repeat_command_names_stay_out_of_the_parallel_command_lists() { + // Key repeat rides the existing repeat envelope precisely so these + // hand-maintained lists need no new entries. Pinning them here makes an + // accidental future divergence fail loudly. + let profile = PointerProfile { + display_id: "display:0:0:1920:1080:1".into(), + scale_factor: 1.0, + x: 0, + y: 0, + width: 1920, + height: 1080, + small_delta: 49, + medium_delta: 130, + large_delta: 281, + display_navigation_supported: true, + display_count: 2, + }; + let response: Value = serde_json::from_str(&pointer_profile_response( + "profile-1", + &profile, + &AppSettings::default(), + )) + .unwrap(); + let capabilities = &response["payload"]["capabilities"]; + for list in ["supportedCommands", "noAckCommands"] { + let commands = capabilities[list].as_array().unwrap(); + assert!(commands.contains(&json!("mouse.repeat.start")), "{list}"); + assert!(commands.contains(&json!("mouse.repeat.stop")), "{list}"); + for absent in [ + "keyboard.repeat.start", + "keyboard.repeat.stop", + "input.repeat.start", + "input.repeat.stop", + ] { + assert!(!commands.contains(&json!(absent)), "{list} gained {absent}"); + } + } + for absent in ["keyboard.repeat.start", "input.repeat.start"] { + assert!(!is_desktop_command(absent), "{absent}"); + } + assert!(is_desktop_command("mouse.repeat.start")); + assert!(is_desktop_command("mouse.repeat.stop")); + } + #[test] fn pointer_profile_advertises_the_full_desktop_command_set() { let profile = PointerProfile { diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 10b1475d..4ffdbf45 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -63,6 +63,12 @@ pub struct AppSettings { pub scroll_repeat_interval_ms: u32, #[serde(default = "default_mouse_repeat_acceleration")] pub mouse_repeat_acceleration_duration_ms: u32, + #[serde(default = "default_key_repeat_enabled")] + pub key_repeat_enabled: bool, + #[serde(default = "default_key_repeat_interval")] + pub key_repeat_interval_ms: u32, + #[serde(default = "default_key_repeat_initial_delay")] + pub key_repeat_initial_delay_ms: u32, #[serde(default)] pub dwell_click_enabled: bool, #[serde(default = "default_dwell_click_delay")] @@ -85,6 +91,9 @@ impl Default for AppSettings { move_repeat_interval_ms: 250, scroll_repeat_interval_ms: 250, mouse_repeat_acceleration_duration_ms: default_mouse_repeat_acceleration(), + key_repeat_enabled: default_key_repeat_enabled(), + key_repeat_interval_ms: default_key_repeat_interval(), + key_repeat_initial_delay_ms: default_key_repeat_initial_delay(), dwell_click_enabled: false, dwell_click_delay_ms: default_dwell_click_delay(), cursor_overlay_enabled: true, @@ -112,6 +121,12 @@ impl AppSettings { if ![0, 500, 1000, 2000].contains(&self.mouse_repeat_acceleration_duration_ms) { return Err("Mouse repeat acceleration is invalid.".into()); } + if ![100, 250, 500, 1000].contains(&self.key_repeat_interval_ms) { + return Err("Key repeat interval is invalid.".into()); + } + if ![0, 250, 500, 1000].contains(&self.key_repeat_initial_delay_ms) { + return Err("Key repeat initial delay is invalid.".into()); + } if ![500, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 7000, 8000] .contains(&self.dwell_click_delay_ms) { @@ -145,6 +160,18 @@ fn default_mouse_repeat_acceleration() -> u32 { 1000 } +fn default_key_repeat_enabled() -> bool { + true +} + +fn default_key_repeat_interval() -> u32 { + 250 +} + +fn default_key_repeat_initial_delay() -> u32 { + 500 +} + fn default_dwell_click_delay() -> u32 { 1000 } @@ -1289,6 +1316,74 @@ mod tests { ); let _ = fs::remove_dir_all(root); } + #[test] + fn settings_written_before_key_repeat_still_load_with_defaults() { + // A settings blob exactly as shipped before key repeat existed. It must + // keep deserializing, and the new fields must take their defaults rather + // than failing the load and resetting every other preference. + let stored = serde_json::json!({ + "startWithSystem": true, + "pointerScalePercent": 150, + "mouseRepeatEnabled": true, + "moveRepeatIntervalMs": 500, + "scrollRepeatIntervalMs": 100, + "mouseRepeatAccelerationDurationMs": 2000, + "dwellClickEnabled": true, + "dwellClickDelayMs": 1500, + "cursorOverlayEnabled": true, + "cursorOverlaySize": "large", + "cursorOverlayColor": "blue", + "cursorOverlayVisibility": "whileControlling", + "cursorCrosshairs": true, + "shareDiagnostics": true + }); + let settings: AppSettings = serde_json::from_value(stored).unwrap(); + + assert!(settings.key_repeat_enabled); + assert_eq!(settings.key_repeat_interval_ms, 250); + assert_eq!(settings.key_repeat_initial_delay_ms, 500); + + // Everything that was stored survives untouched. + assert_eq!(settings.pointer_scale_percent, 150); + assert_eq!(settings.move_repeat_interval_ms, 500); + assert_eq!(settings.scroll_repeat_interval_ms, 100); + assert_eq!(settings.cursor_overlay_size, "large"); + assert!(settings.clone().normalized().is_ok()); + } + + #[test] + fn settings_reject_invalid_key_repeat_cadence() { + for interval in [0, 50, 200, 2000] { + let value = AppSettings { + key_repeat_interval_ms: interval, + ..AppSettings::default() + }; + assert!(value.normalized().is_err(), "interval {interval}"); + } + for delay in [100, 400, 2000] { + let value = AppSettings { + key_repeat_initial_delay_ms: delay, + ..AppSettings::default() + }; + assert!(value.normalized().is_err(), "delay {delay}"); + } + for interval in [100, 250, 500, 1000] { + let value = AppSettings { + key_repeat_interval_ms: interval, + ..AppSettings::default() + }; + assert!(value.normalized().is_ok(), "interval {interval}"); + } + // Zero is a valid initial delay: it means repeat straight away. + for delay in [0, 250, 500, 1000] { + let value = AppSettings { + key_repeat_initial_delay_ms: delay, + ..AppSettings::default() + }; + assert!(value.normalized().is_ok(), "delay {delay}"); + } + } + #[test] fn settings_reject_unknown_overlay_visibility() { let value = AppSettings { diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index aea9c2b9..40ab955a 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -1701,9 +1701,18 @@ fn complete_repeat_start( let settings = overlay_settings(shared); let repeat_command = RepeatCommand::parse(&command.payload); let result = repeat_command.and_then(|repeat_command| { - if !settings.mouse_repeat_enabled { + let enabled = if repeat_command.repeat_key().is_some() { + settings.key_repeat_enabled + } else { + settings.mouse_repeat_enabled + }; + if !enabled { stop_repeat_for_device(app, &command.device_id); - return Err("Mouse repeat is disabled in settings.".into()); + return Err(if repeat_command.repeat_key().is_some() { + "Key repeat is disabled in settings.".to_string() + } else { + "Mouse repeat is disabled in settings.".to_string() + }); } stop_repeat_for_device(app, &command.device_id); let (active, initial_feedback) = { @@ -1713,6 +1722,16 @@ fn complete_repeat_start( let runtime = guard .as_mut() .ok_or_else(|| "Bluetooth runtime is not ready.".to_string())?; + // A key repeat injects keystrokes, which `DesktopInput::execute` + // refuses during Switch Forwarding; the repeat path bypasses that + // guard, so it enforces the same rule itself. Scoped to keys + // deliberately: pointer repeats have always been allowed here, and + // changing that is a separate behaviour decision. + if repeat_command.repeat_key().is_some() && runtime.input.has_active_switch_session() { + return Err( + "Stop Switch Forwarding before using other PC control commands.".to_string(), + ); + } let active = runtime.repeats.start( command.device_id.clone(), repeat_command, @@ -1730,8 +1749,23 @@ fn complete_repeat_start( } else { runtime.input.move_pointer_pixels(dx, dy) } + .map(Some) + } + RepeatCommand::Scroll { dx, dy } => { + runtime.input.execute_repeat_scroll(dx, dy).map(Some) + } + // The first tap fires immediately; the loop then waits the + // initial delay before repeating. Key repeats produce no pointer + // feedback, so the cursor overlay is hidden rather than driven. + RepeatCommand::Key { .. } => { + match runtime + .repeats + .initial_key(&command.device_id, active.generation) + { + Some(key) => runtime.input.execute_repeat_key(key).map(|()| None), + None => Ok(None), + } } - RepeatCommand::Scroll { dx, dy } => runtime.input.execute_repeat_scroll(dx, dy), }; match initial { Ok(feedback) => (active, feedback), @@ -1739,17 +1773,22 @@ fn complete_repeat_start( runtime .repeats .stop_if_current(&command.device_id, active.generation); + // A failed initial key tap can still have left the key down. + let _ = runtime.input.release_repeat_keys(); return Err(error); } } }; - app.state::().begin_repeat( - active.generation, - repeat_command, - settings.mouse_repeat_acceleration_duration_ms > 0, - matches!(initial_feedback, PointerFeedback::Drag), - settings, - ); + match initial_feedback { + Some(feedback) => app.state::().begin_repeat( + active.generation, + repeat_command, + settings.mouse_repeat_acceleration_duration_ms > 0, + matches!(feedback, PointerFeedback::Drag), + settings, + ), + None => app.state::().hide_for_typing(), + } spawn_repeat_loop( app.clone(), shared.clone(), @@ -1797,13 +1836,32 @@ fn spawn_repeat_loop(app: AppHandle, shared: SharedModel, device_id: String, gen .and_then(|runtime| runtime.repeats.current(&device_id, generation)) }; let Some(active) = active else { return }; - if !settings.mouse_repeat_enabled { + let is_key_repeat = active.command.repeat_key().is_some(); + let enabled = if is_key_repeat { + settings.key_repeat_enabled + } else { + settings.mouse_repeat_enabled + }; + if !enabled { stop_repeat_if_current(&app, &device_id, generation); return; } let delay_ms = match active.command { RepeatCommand::Move { .. } => MOVE_TICK_INTERVAL_MS, RepeatCommand::Scroll { .. } => u64::from(active.command.interval_ms(&settings)), + RepeatCommand::Key { .. } => { + let delay = runtime() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .and_then(|runtime| { + runtime + .repeats + .key_delay_ms(&device_id, generation, &settings) + }); + let Some(delay) = delay else { return }; + u64::from(delay) + } }; tokio::time::sleep(Duration::from_millis(delay_ms)).await; let settings = overlay_settings(&shared); @@ -1817,8 +1875,13 @@ fn spawn_repeat_loop(app: AppHandle, shared: SharedModel, device_id: String, gen let Some(active) = runtime.repeats.current(&device_id, generation) else { return; }; - if !settings.mouse_repeat_enabled { - Err("Mouse repeat was disabled.".to_string()) + let enabled = if active.command.repeat_key().is_some() { + settings.key_repeat_enabled + } else { + settings.mouse_repeat_enabled + }; + if !enabled { + Err("Repeat was disabled.".to_string()) } else { match active.command { RepeatCommand::Move { .. } => { @@ -1840,6 +1903,13 @@ fn spawn_repeat_loop(app: AppHandle, shared: SharedModel, device_id: String, gen RepeatCommand::Scroll { dx, dy } => { runtime.input.execute_repeat_scroll(dx, dy).map(|_| ()) } + RepeatCommand::Key { .. } => { + let Some(key) = runtime.repeats.advance_key(&device_id, generation) + else { + return; + }; + runtime.input.execute_repeat_key(key) + } } } }; @@ -1848,7 +1918,10 @@ fn spawn_repeat_loop(app: AppHandle, shared: SharedModel, device_id: String, gen set_activity( &shared, ActivityKind::Error, - format!("Mouse repeat stopped: {error}"), + format!( + "{} repeat stopped: {error}", + if is_key_repeat { "Key" } else { "Mouse" } + ), ); emit_state(&app, &shared); } @@ -1859,11 +1932,21 @@ fn spawn_repeat_loop(app: AppHandle, shared: SharedModel, device_id: String, gen } fn stop_repeat_if_current(app: &AppHandle, device_id: &str, generation: u64) -> bool { - let stopped = runtime() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_mut() - .is_some_and(|runtime| runtime.repeats.stop_if_current(device_id, generation)); + let stopped = { + let mut guard = runtime() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match guard.as_mut() { + Some(runtime) => { + let stopped = runtime.repeats.stop_if_current(device_id, generation); + // Unconditional: a key whose retry also failed is still pending + // with no active repeat, and nothing else would ever free it. + let _ = runtime.input.release_repeat_keys(); + stopped + } + None => false, + } + }; if stopped { app.state::().end_repeat(generation); } @@ -1874,11 +1957,19 @@ fn stop_repeat_for_device( app: &AppHandle, device_id: &str, ) -> Option { - let active = runtime() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_mut() - .and_then(|runtime| runtime.repeats.stop(device_id)); + let active = { + let mut guard = runtime() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match guard.as_mut() { + Some(runtime) => { + let active = runtime.repeats.stop(device_id); + let _ = runtime.input.release_repeat_keys(); + active + } + None => None, + } + }; if let Some(active) = active { app.state::().end_repeat(active.generation); } @@ -1886,11 +1977,19 @@ fn stop_repeat_for_device( } fn stop_all_repeats(app: &AppHandle) { - let active = runtime() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_mut() - .map_or_else(Vec::new, |runtime| runtime.repeats.stop_all()); + let active = { + let mut guard = runtime() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match guard.as_mut() { + Some(runtime) => { + let active = runtime.repeats.stop_all(); + let _ = runtime.input.release_repeat_keys(); + active + } + None => Vec::new(), + } + }; for repeat in active { app.state::().end_repeat(repeat.generation); } diff --git a/src/App.test.tsx b/src/App.test.tsx index 1c6c3cde..99d9b376 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -393,6 +393,35 @@ describe("Switchify PC shell", () => { expect(screen.getByRole("heading", { name: "Waiting for an Android device" })).toBeInTheDocument(); }); + it("exposes key repeat settings and disables them with the toggle", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + const toggle = screen.getByRole("checkbox", { name: "Repeat held keys" }); + expect(toggle).toBeChecked(); + + const delay = screen.getByRole("group", { name: "Delay before repeating" }); + expect(delay).not.toBeDisabled(); + for (const label of ["None", "Short", "Medium", "Long"]) { + expect(within(delay).getByRole("button", { name: label })).toBeInTheDocument(); + } + expect(within(delay).getByRole("button", { name: "Medium" })).toHaveAttribute("aria-pressed", "true"); + + const interval = screen.getByRole("group", { name: "Key interval" }); + expect(within(interval).getByRole("button", { name: "0.25s" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(within(interval).getByRole("button", { name: "0.5s" })); + expect(within(interval).getByRole("button", { name: "0.5s" })).toHaveAttribute("aria-pressed", "true"); + + fireEvent.click(within(delay).getByRole("button", { name: "None" })); + expect(within(delay).getByRole("button", { name: "None" })).toHaveAttribute("aria-pressed", "true"); + + // Turning the feature off must disable its cadence controls, matching the + // mouse repeat and dwell blocks. + fireEvent.click(toggle); + expect(screen.getByRole("group", { name: "Delay before repeating" })).toBeDisabled(); + expect(screen.getByRole("group", { name: "Key interval" })).toBeDisabled(); + }); + it("opens settings with accessible native controls", async () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" })); diff --git a/src/App.tsx b/src/App.tsx index 6ad81209..fcad1e39 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -292,6 +292,12 @@ function SettingGroup({ title, description, children, id, sectionRef, focusable const pointerSpeedOptions = [5, 25, 50, 75, 100] as const; const pointerSpeedValues = Array.from({ length: 45 }, (_, index) => (index + 1) * 5); const repeatIntervalOptions = [100, 250, 500, 1000] as const; +const keyRepeatDelayOptions = [ + { value: 0, label: "None" }, + { value: 250, label: "Short" }, + { value: 500, label: "Medium" }, + { value: 1000, label: "Long" }, +] as const; const accelerationOptions = [ { value: 0, label: "Off" }, { value: 500, label: "Short" }, @@ -403,6 +409,18 @@ function SettingsView({ state, settings, onChange, chooseTelemetry, updateAction +
+ update("keyRepeatEnabled", value)} /> +
+
Delay before repeating
+ {keyRepeatDelayOptions.map(({ value, label }) => )} +
+
Key interval
+ {repeatIntervalOptions.map((value) => )} +
+

Holding a navigation key on the remote repeats it, like holding a key on a keyboard. Applies to the arrow keys, Tab, Backspace, Delete, Page Up, and Page Down.

+
+
update("dwellClickEnabled", value)} />
diff --git a/src/api.ts b/src/api.ts index 7c2f3b40..14af34b0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -17,6 +17,7 @@ export const browserState: AppState = { startWithSystem: false, pointerScalePercent: 100, mouseRepeatEnabled: true, moveRepeatIntervalMs: 250, scrollRepeatIntervalMs: 250, mouseRepeatAccelerationDurationMs: 1000, + keyRepeatEnabled: true, keyRepeatIntervalMs: 250, keyRepeatInitialDelayMs: 500, dwellClickEnabled: false, dwellClickDelayMs: 1000, cursorOverlayEnabled: true, cursorOverlaySize: "medium", cursorOverlayColor: "red", cursorOverlayVisibility: "whileControlling", diff --git a/src/types.ts b/src/types.ts index b36cd430..b8737533 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,9 @@ export type AppSettings = { moveRepeatIntervalMs: number; scrollRepeatIntervalMs: number; mouseRepeatAccelerationDurationMs: number; + keyRepeatEnabled: boolean; + keyRepeatIntervalMs: number; + keyRepeatInitialDelayMs: number; dwellClickEnabled: boolean; dwellClickDelayMs: number; cursorOverlayEnabled: boolean;