diff --git a/src-tauri/src/mouse_repeat.rs b/src-tauri/src/mouse_repeat.rs index 3c6eb9e2..9e23bf1d 100644 --- a/src-tauri/src/mouse_repeat.rs +++ b/src-tauri/src/mouse_repeat.rs @@ -165,6 +165,48 @@ pub struct ScanScrollRepeat { elapsed_ms: u64, } +/// Repeats a key chosen on the scanned keyboard with Remote's key cadence. +/// Choosing the key was the first press; the second follows the initial delay +/// and every later one the interval. A delayed scanner tick never bursts +/// several presses into the foreground app. +pub struct ScanKeyRepeat { + elapsed_ms: u64, + taps: u32, +} + +impl Default for ScanKeyRepeat { + fn default() -> Self { + Self::new() + } +} + +impl ScanKeyRepeat { + pub fn new() -> Self { + Self { + elapsed_ms: 0, + taps: 1, + } + } + + pub fn advance(&mut self, elapsed_ms: u64, initial_delay_ms: u32, interval_ms: u32) -> bool { + let delay = u64::from( + if self.taps <= 1 { + initial_delay_ms + } else { + interval_ms + } + .max(1), + ); + self.elapsed_ms = self.elapsed_ms.saturating_add(elapsed_ms); + if self.elapsed_ms < delay { + return false; + } + self.taps = self.taps.saturating_add(1); + self.elapsed_ms = (self.elapsed_ms - delay) % u64::from(interval_ms.max(1)); + true + } +} + impl ScanScrollRepeat { pub fn new(direction: i8) -> Self { Self { @@ -767,6 +809,26 @@ mod tests { assert!(repeat.advance(1, 80)); } + #[test] + fn scanned_key_repeat_waits_the_initial_delay_then_the_interval_without_bursting() { + let mut repeat = ScanKeyRepeat::new(); + assert!(!repeat.advance(499, 500, 250)); + assert!(repeat.advance(1, 500, 250)); + assert!(!repeat.advance(249, 500, 250)); + assert!(repeat.advance(1, 500, 250)); + // A stalled tick presses once and keeps only the remainder. + assert!(repeat.advance(1000, 500, 250)); + assert!(!repeat.advance(249, 500, 250)); + assert!(repeat.advance(1, 500, 250)); + + // No initial delay still waits for the next tick. + let mut immediate = ScanKeyRepeat::new(); + assert!(!immediate.advance(0, 0, 100)); + assert!(immediate.advance(1, 0, 100)); + assert!(!immediate.advance(99, 0, 100)); + assert!(immediate.advance(1, 0, 100)); + } + #[test] fn parses_only_bounded_move_and_scroll_commands() { assert_eq!( diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 1b35f417..64b878bb 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -314,6 +314,12 @@ impl Adapter for PointScan { technique.typing_moved(); environment.foreground = foreground; } + let settings = app.state::().snapshot().settings; + technique.set_key_repeat_settings( + settings.scan_key_repeat_enabled, + settings.key_repeat_interval_ms, + settings.key_repeat_initial_delay_ms, + ); environment.panel = true; // The keyboard moves to fit its display rather than closing. // While the displays cannot be read it stays where it is. diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index b8c45d30..8139c4c3 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -66,6 +66,18 @@ pub fn default_click(point: Point) -> Request { count: 1, } } +/// Only navigation keys repeat, never text, Space, Enter or a modifier. +/// Shift and Ctrl may come along (selecting, deleting words), but not Alt or +/// the Windows/Command key, whose shortcuts switch and move windows. +fn repeatable(stroke: crate::scan_keyboard::Stroke) -> bool { + !stroke.modifiers[2] + && !stroke.modifiers[3] + && matches!( + stroke.key, + crate::scan_keyboard::Key::Named(name) + if crate::mouse_repeat::RepeatKey::parse(name).is_some() + ) +} fn selection_policy(point: Point, options: crate::scan_preferences::Resolved) -> (Point, Menu) { (point, Menu::configured(Kind::Actions, options)) } @@ -86,6 +98,7 @@ pub enum WorkflowPhase { Keyboard, KeyboardSuspended, KeyboardOpening, + KeyboardRepeating, Mouse, MouseSuspended, MouseMoving, @@ -111,6 +124,13 @@ enum Stage { Destination, Executing, } +/// A navigation key chosen on the scanned keyboard. It repeats once choosing +/// it has typed it, until a switch stops it. +struct KeyRepeat { + stroke: crate::scan_keyboard::Stroke, + timer: crate::mouse_repeat::ScanKeyRepeat, + running: bool, +} pub struct Workflow { keyboard: crate::scan_keyboard::Keyboard, keyboard_layout_pending: bool, @@ -138,6 +158,10 @@ pub struct Workflow { mouse_move_interval_ms: u32, mouse_scroll_interval_ms: u32, mouse_acceleration_ms: u32, + key_repeat: Option, + scan_key_repeat_enabled: bool, + key_repeat_interval_ms: u32, + key_repeat_initial_delay_ms: u32, point: Engine, stage: Stage, menu: Menu, @@ -183,6 +207,10 @@ impl Workflow { mouse_move_interval_ms: 250, mouse_scroll_interval_ms: 250, mouse_acceleration_ms: 1000, + key_repeat: None, + scan_key_repeat_enabled: false, + key_repeat_interval_ms: 250, + key_repeat_initial_delay_ms: 500, point: Engine::new(config, screen, scale)?, stage: Stage::Idle, menu: Menu::configured(Kind::Actions, menu_options), @@ -263,6 +291,7 @@ impl Workflow { self.mouse_actions_open = false; self.move_repeat = None; self.scroll_repeat = None; + self.key_repeat = None; self.parent_menu.clear(); self.begin_point(); } @@ -354,6 +383,40 @@ impl Workflow { self.mouse.restart(); } } + pub fn set_key_repeat_settings( + &mut self, + enabled: bool, + interval_ms: u32, + initial_delay_ms: u32, + ) { + let changed = self.scan_key_repeat_enabled != enabled + || self.key_repeat_interval_ms != interval_ms + || self.key_repeat_initial_delay_ms != initial_delay_ms; + self.scan_key_repeat_enabled = enabled; + self.key_repeat_interval_ms = interval_ms; + self.key_repeat_initial_delay_ms = initial_delay_ms; + if changed { + self.end_key_repeat(); + } + } + fn key_repeating(&self) -> bool { + self.stage == Stage::Keyboard + && self + .key_repeat + .as_ref() + .is_some_and(|repeat| repeat.running) + } + /// Ends a key repeat, returning whether one was running. Each press was + /// released as it was typed, so nothing is left held down. + fn end_key_repeat(&mut self) -> bool { + let running = self.key_repeating(); + self.key_repeat = None; + if running { + self.pending = None; + self.keyboard.repeat_stopped(); + } + running + } pub fn mouse_feedback(&self) -> Option { use crate::input::PointerFeedback; if self.mouse.error { @@ -378,6 +441,7 @@ impl Workflow { } } pub fn typing_moved(&mut self) { + self.end_key_repeat(); self.keyboard.typing_moved(); } pub fn prediction_enabled(&self) -> bool { @@ -593,6 +657,46 @@ impl Workflow { Key::More | Key::Movement | Key::Dock | Key::Position(_) | Key::Back => None, } } + /// What the scanned keyboard's choice asks for. A navigation key also + /// prepares a repeat when that is turned on. + fn keyboard_output(&mut self, output: Option) -> Option { + match output { + Some(crate::scan_keyboard::Output::Stroke(stroke)) => { + if self.scan_key_repeat_enabled && repeatable(stroke) { + self.key_repeat = Some(KeyRepeat { + stroke, + timer: crate::mouse_repeat::ScanKeyRepeat::new(), + running: false, + }); + } + Some(Request::Keyboard(stroke)) + } + Some(crate::scan_keyboard::Output::Punctuation(punctuation)) => { + Some(Request::KeyboardPunctuation(punctuation)) + } + Some(crate::scan_keyboard::Output::Prediction { token, index }) => { + Some(Request::Prediction { token, index }) + } + Some(crate::scan_keyboard::Output::RetryPrediction) => Some(Request::PredictionRetry), + Some(crate::scan_keyboard::Output::Layout(layout)) => { + self.keyboard_layout_pending = true; + Some(Request::Setting(crate::scan_menu::Setting::KeyboardLayout( + layout, + ))) + } + Some(crate::scan_keyboard::Output::Close) => { + self.keyboard_closed(); + None + } + None => None, + } + } + /// Chooses a key on the open keyboard without scanning to it. + #[cfg(test)] + pub(crate) fn choose_key(&mut self, key: crate::scan_keyboard::Key) -> Option { + let output = self.keyboard.choose(key); + self.keyboard_output(output) + } fn return_to_mouse_panel(&mut self) { self.parent_menu.clear(); self.mouse_actions_open = false; @@ -765,6 +869,8 @@ impl Technique for Workflow { type Phase = Phase; fn execution_failed(&mut self, message: String) { if self.stage == Stage::Keyboard { + self.key_repeat = None; + self.pending = None; if self.keyboard_layout_pending { self.keyboard_layout_pending = false; self.keyboard.layout_change_failed(); @@ -809,8 +915,20 @@ impl Technique for Workflow { self.stage = Stage::Keyboard; } else if self.stage == Stage::Keyboard { self.keyboard_layout_pending = false; - self.keyboard + let typed = self + .keyboard .succeeded_with_context(crate::prediction::keyboard_input_context()); + // The chosen key repeats only once it has been typed. + if self + .key_repeat + .as_ref() + .is_some_and(|repeat| !repeat.running) + { + match self.key_repeat.as_mut() { + Some(repeat) if typed => repeat.running = true, + _ => self.key_repeat = None, + } + } } } fn start(&mut self) { @@ -832,6 +950,7 @@ impl Technique for Workflow { self.mouse_actions_open = false; self.move_repeat = None; self.scroll_repeat = None; + self.key_repeat = None; self.stage = Stage::Idle; self.parent_menu.clear(); self.pending = None; @@ -852,12 +971,15 @@ impl Technique for Workflow { self.active_preferences().automatic } fn mouse_repeating(&self) -> bool { - matches!(self.stage, Stage::MouseMoving | Stage::MouseScrolling) + matches!(self.stage, Stage::MouseMoving | Stage::MouseScrolling) || self.key_repeating() } fn mouse_repeat_stop_edge(&self) -> crate::point_scan::MouseRepeatStopEdge { self.point.config.mouse_repeat_stop_edge } fn stop_mouse_repeat(&mut self) -> bool { + if self.end_key_repeat() { + return true; + } if !matches!(self.stage, Stage::MouseMoving | Stage::MouseScrolling) { return false; } @@ -877,6 +999,17 @@ impl Technique for Workflow { fn handle(&mut self, action: Action) -> Option { self.following = false; self.leaving_home = false; + // Anything that reaches a repeating key stops it, and only an + // explicit change of mode goes on to act. + if self.end_key_repeat() + && !matches!( + action, + Action::OpenPoint | Action::OpenMouse | Action::OpenKeyboard + ) + { + return None; + } + self.key_repeat = None; if action == Action::OpenPoint { return self.open_point(); } @@ -898,28 +1031,7 @@ impl Technique for Workflow { .keyboard .handle_with_context(action, crate::prediction::keyboard_input_context()); self.set_dock(self.keyboard.dock); - match output { - Some(crate::scan_keyboard::Output::Stroke(stroke)) => { - return Some(Request::Keyboard(stroke)) - } - Some(crate::scan_keyboard::Output::Punctuation(punctuation)) => { - return Some(Request::KeyboardPunctuation(punctuation)) - } - Some(crate::scan_keyboard::Output::Prediction { token, index }) => { - return Some(Request::Prediction { token, index }) - } - Some(crate::scan_keyboard::Output::RetryPrediction) => { - return Some(Request::PredictionRetry) - } - Some(crate::scan_keyboard::Output::Layout(layout)) => { - self.keyboard_layout_pending = true; - return Some(Request::Setting(crate::scan_menu::Setting::KeyboardLayout( - layout, - ))); - } - Some(crate::scan_keyboard::Output::Close) => self.keyboard_closed(), - None => {} - } + return self.keyboard_output(output); } Stage::KeyboardOpening => {} Stage::Mouse => { @@ -992,6 +1104,22 @@ impl Technique for Workflow { } } fn update(&mut self, ms: u64, context: UpdateContext) { + if self.key_repeating() { + if context.paused { + self.end_key_repeat(); + return; + } + let (delay, interval) = ( + self.key_repeat_initial_delay_ms, + self.key_repeat_interval_ms, + ); + if let Some(repeat) = self.key_repeat.as_mut() { + if repeat.timer.advance(ms, delay, interval) { + self.pending = Some(Request::Keyboard(repeat.stroke)); + } + } + return; + } if self.stage == Stage::MouseMoving { let (dx, dy) = self.move_repeat.as_mut().map_or((0, 0), |repeat| { repeat.advance( @@ -1053,7 +1181,9 @@ impl Technique for Workflow { }), Stage::MouseMoving => Phase::Workflow(WorkflowPhase::MouseMoving), Stage::MouseScrolling => Phase::Workflow(WorkflowPhase::MouseScrolling), - Stage::Keyboard => Phase::Workflow(if self.keyboard.suspended() { + Stage::Keyboard => Phase::Workflow(if self.key_repeating() { + WorkflowPhase::KeyboardRepeating + } else if self.keyboard.suspended() { WorkflowPhase::KeyboardSuspended } else { WorkflowPhase::Keyboard @@ -1078,10 +1208,19 @@ impl Technique for Workflow { _ => self.mouse_area, }; let mut frame = match self.stage { - Stage::Keyboard => self.keyboard.frame( + Stage::Keyboard => self.keyboard.frame_repeating( self.keyboard_area, self.point.units_per_logical_pixel, self.point.config.scanner_color, + self.key_repeat + .as_ref() + .filter(|_| self.key_repeating()) + .map(|repeat| { + ( + repeat.stroke, + self.point.config.mouse_repeat_stop_edge.instruction(), + ) + }), ), Stage::Mouse | Stage::MouseMoving | Stage::MouseScrolling => self.mouse.frame( self.mouse_area, @@ -2584,6 +2723,236 @@ mod tests { w.reset(); assert_eq!(w.phase(), Phase::default()); } + fn keyboard_session(edge: crate::point_scan::MouseRepeatStopEdge) -> Session { + let config = Config { + automatic: false, + mouse_repeat_stop_edge: edge, + ..last_mode() + }; + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; + let mut s = Session::new(Workflow::new(config.point(), screen, 1.0).unwrap(), false); + assert_eq!(s.action(Action::OpenKeyboard), Some(Request::OpenKeyboard)); + s.technique.execution_succeeded(); + assert_eq!( + s.technique.phase(), + Phase::Workflow(WorkflowPhase::Keyboard) + ); + s.technique.set_key_repeat_settings(true, 250, 500); + s + } + fn typed(request: Option, name: &'static str) -> bool { + matches!( + request, + Some(Request::Keyboard(stroke)) + if stroke.key == crate::scan_keyboard::Key::Named(name) + ) + } + /// Chooses Backspace and lets the first press succeed. + fn repeating_backspace(edge: crate::point_scan::MouseRepeatStopEdge) -> Session { + let mut s = keyboard_session(edge); + let request = s + .technique + .choose_key(crate::scan_keyboard::Key::Named("Backspace")); + assert!(typed(request, "Backspace")); + // Nothing repeats until the first press has been typed. + assert!(!s.technique.mouse_repeating()); + s.technique.execution_succeeded(); + assert!(s.technique.mouse_repeating()); + s + } + #[test] + fn chosen_navigation_keys_repeat_until_a_switch_stops_them() { + use crate::point_scan::MouseRepeatStopEdge::{Press, Release}; + for edge in [Press, Release] { + let mut s = repeating_backspace(edge); + assert_eq!( + s.technique.phase(), + Phase::Workflow(WorkflowPhase::KeyboardRepeating) + ); + assert_eq!(s.technique.mouse_repeat_stop_edge(), edge); + let frame = s.frame(); + assert!(frame.tiles.iter().all(|tile| !tile.selected)); + assert_eq!( + frame.tiles.last().unwrap().text, + format!("Repeating Backspace · {}", edge.instruction()) + ); + + s.tick(250, false); + s.tick(249, false); + assert!(s.take_selection().is_none()); + s.tick(1, false); + assert!(typed(s.take_selection(), "Backspace")); + s.technique.execution_succeeded(); + assert!(s.technique.mouse_repeating()); + s.tick(249, false); + assert!(s.take_selection().is_none()); + s.tick(1, false); + assert!(typed(s.take_selection(), "Backspace")); + s.technique.execution_succeeded(); + + assert!(s.technique.stop_mouse_repeat()); + assert!(!s.technique.stop_mouse_repeat()); + assert_eq!( + s.technique.phase(), + Phase::Workflow(WorkflowPhase::Keyboard) + ); + for _ in 0..4 { + s.tick(250, false); + assert!(s.take_selection().is_none()); + } + assert!(!s.technique.mouse_repeating()); + } + } + #[test] + fn every_navigation_key_repeats_but_text_enter_and_other_keys_never_do() { + use crate::scan_keyboard::Key; + for name in [ + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "Tab", + "Backspace", + "Delete", + "PageUp", + "PageDown", + ] { + let mut s = keyboard_session(Default::default()); + assert!(typed(s.technique.choose_key(Key::Named(name)), name)); + s.technique.execution_succeeded(); + assert!(s.technique.mouse_repeating(), "{name}"); + let text = s.frame().tiles.last().unwrap().text.clone(); + assert!(text.starts_with("Repeating "), "{text}"); + assert!(!text.contains("Arrow"), "{text}"); + } + for key in [ + Key::Named("Enter"), + Key::Named("Escape"), + Key::Named("Home"), + Key::Named("End"), + Key::Named("F5"), + Key::Character('a', 'A'), + Key::Character(' ', ' '), + ] { + let mut s = keyboard_session(Default::default()); + assert!(matches!( + s.technique.choose_key(key), + Some(Request::Keyboard(_)) + )); + s.technique.execution_succeeded(); + assert!(!s.technique.mouse_repeating(), "{key:?}"); + for _ in 0..8 { + s.tick(250, false); + assert!(s.take_selection().is_none(), "{key:?}"); + } + } + + // Turned off, a navigation key is typed once as before. + let mut s = keyboard_session(Default::default()); + s.technique.set_key_repeat_settings(false, 250, 500); + assert!(typed( + s.technique.choose_key(Key::Named("ArrowDown")), + "ArrowDown" + )); + s.technique.execution_succeeded(); + assert!(!s.technique.mouse_repeating()); + for _ in 0..8 { + s.tick(250, false); + assert!(s.take_selection().is_none()); + } + + // A first press that fails never starts repeating. + let mut s = keyboard_session(Default::default()); + s.technique.choose_key(Key::Named("Delete")); + s.execution_failed("Input failed.".into()); + assert!(!s.technique.mouse_repeating()); + for _ in 0..8 { + s.tick(250, false); + assert!(s.take_selection().is_none()); + } + } + #[test] + fn shift_and_ctrl_repeat_with_the_key_but_window_shortcuts_never_do() { + use crate::scan_keyboard::Key; + let mut s = keyboard_session(Default::default()); + assert_eq!(s.technique.choose_key(Key::Modifier(0)), None); + assert!(matches!( + s.technique.choose_key(Key::Named("ArrowLeft")), + Some(Request::Keyboard(stroke)) if stroke.modifiers == [true, false, false, false] + )); + s.technique.execution_succeeded(); + assert!(s.technique.mouse_repeating()); + assert!(s + .frame() + .tiles + .last() + .unwrap() + .text + .starts_with("Repeating Shift+Left arrow · ")); + // The one-shot Shift goes with every repeated press. + s.tick(250, false); + s.tick(250, false); + assert!(matches!( + s.take_selection(), + Some(Request::Keyboard(stroke)) if stroke.modifiers[0] + )); + + for modifier in [2, 3] { + let mut s = keyboard_session(Default::default()); + assert_eq!(s.technique.choose_key(Key::Modifier(modifier)), None); + assert!(typed( + s.technique.choose_key(Key::Named("ArrowRight")), + "ArrowRight" + )); + s.technique.execution_succeeded(); + assert!(!s.technique.mouse_repeating(), "{modifier}"); + } + } + #[test] + fn a_key_repeat_ends_with_failure_settings_pause_window_and_scanning_changes() { + for reason in [ + "failure", "off", "timing", "pause", "window", "select", "stop", "reset", "mouse", + "config", + ] { + let mut s = repeating_backspace(Default::default()); + s.tick(250, false); + s.tick(250, false); + assert!(s.take_selection().is_some()); + s.technique.execution_succeeded(); + match reason { + "failure" => s.execution_failed("Input failed.".into()), + "off" => s.technique.set_key_repeat_settings(false, 250, 500), + "timing" => s.technique.set_key_repeat_settings(true, 100, 500), + "pause" => assert!(s.action(Action::Pause).is_none()), + "window" => s.technique.typing_moved(), + "select" => assert!(s.action(Action::Select).is_none()), + "stop" => assert!(s.action(Action::Stop).is_none()), + "reset" => s.reset(), + "mouse" => assert!(s.action(Action::OpenMouse).is_some()), + _ => s.technique.apply_config(last_mode().point(), true), + } + s.tick(250, false); + assert!(!s.technique.mouse_repeating(), "{reason}"); + assert_ne!( + s.technique.phase(), + Phase::Workflow(WorkflowPhase::KeyboardRepeating), + "{reason}" + ); + for _ in 0..8 { + s.tick(250, false); + assert!(s.take_selection().is_none(), "{reason}"); + } + } + // The same settings arriving again leave the repeat running. + let mut s = repeating_backspace(Default::default()); + s.technique.set_key_repeat_settings(true, 250, 500); + assert!(s.technique.mouse_repeating()); + } fn open(s: &mut Session) { for _ in 0..3 { assert_eq!(s.action(Action::Select), None); diff --git a/src-tauri/src/portable_settings.rs b/src-tauri/src/portable_settings.rs index ad190d6b..cee0e407 100644 --- a/src-tauri/src/portable_settings.rs +++ b/src-tauri/src/portable_settings.rs @@ -16,7 +16,7 @@ use serde_json::Value; use std::collections::HashSet; use tauri::{AppHandle, Manager}; -pub const SCHEMA_VERSION: u32 = 1; +pub const SCHEMA_VERSION: u32 = 2; const MAX_CUSTOM_PROFILES: usize = 32; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -43,6 +43,10 @@ pub struct PortableAppSettings { pub key_repeat_enabled: bool, pub key_repeat_interval_ms: u32, pub key_repeat_initial_delay_ms: u32, + /// Added in version 2. The default only fills in a merge base saved by an + /// older build; documents must still carry it (see `parse`). + #[serde(default)] + pub scan_key_repeat_enabled: bool, pub dwell_click_enabled: bool, pub dwell_click_delay_ms: u32, pub cursor_overlay_enabled: bool, @@ -63,6 +67,7 @@ impl PortableAppSettings { key_repeat_enabled: settings.key_repeat_enabled, key_repeat_interval_ms: settings.key_repeat_interval_ms, key_repeat_initial_delay_ms: settings.key_repeat_initial_delay_ms, + scan_key_repeat_enabled: settings.scan_key_repeat_enabled, dwell_click_enabled: settings.dwell_click_enabled, dwell_click_delay_ms: settings.dwell_click_delay_ms, cursor_overlay_enabled: settings.cursor_overlay_enabled, @@ -86,6 +91,7 @@ impl PortableAppSettings { key_repeat_enabled: self.key_repeat_enabled, key_repeat_interval_ms: self.key_repeat_interval_ms, key_repeat_initial_delay_ms: self.key_repeat_initial_delay_ms, + scan_key_repeat_enabled: self.scan_key_repeat_enabled, dwell_click_enabled: self.dwell_click_enabled, dwell_click_delay_ms: self.dwell_click_delay_ms, cursor_overlay_enabled: self.cursor_overlay_enabled, @@ -180,18 +186,22 @@ impl std::fmt::Display for ParseError { } } -pub fn parse(value: Value, schema_version: u32) -> Result { +pub fn parse(mut value: Value, schema_version: u32) -> Result { if schema_version > SCHEMA_VERSION { return Err(ParseError::Newer(schema_version)); } - if schema_version != SCHEMA_VERSION { - return Err(ParseError::Invalid(format!( - "unsupported version {schema_version}" - ))); + match schema_version { + 1 => value = upgrade_v1(value)?, + SCHEMA_VERSION => {} + _ => { + return Err(ParseError::Invalid(format!( + "unsupported version {schema_version}" + ))) + } } let document: Document = serde_json::from_value(value.clone()) .map_err(|error| ParseError::Invalid(error.to_string()))?; - if document.schema_version != schema_version { + if document.schema_version != SCHEMA_VERSION { return Err(ParseError::Invalid("version mismatch".into())); } // Some sections deserialize leniently (defaults for missing fields, @@ -209,6 +219,21 @@ pub fn parse(value: Value, schema_version: u32) -> Result Ok(document) } +/// Version 1 had no scanned key repeat, which is off unless chosen. +fn upgrade_v1(mut value: Value) -> Result { + if value.get("schemaVersion") != Some(&Value::from(1)) { + return Err(ParseError::Invalid("version mismatch".into())); + } + let app = value + .get_mut("app") + .and_then(Value::as_object_mut) + .filter(|app| !app.contains_key("scanKeyRepeatEnabled")) + .ok_or_else(|| ParseError::Invalid("it does not match version 1 settings".into()))?; + app.insert("scanKeyRepeatEnabled".into(), Value::Bool(false)); + value["schemaVersion"] = Value::from(SCHEMA_VERSION); + Ok(value) +} + /// The sections of a document that differ from this install, already merged /// with local-only values and validated. `None` leaves a section untouched. #[derive(Debug, Default, PartialEq)] @@ -519,12 +544,55 @@ mod tests { ); assert!(matches!(parse(value, 0), Err(ParseError::Invalid(_)))); assert!(matches!( - parse(json!({"schemaVersion": 1}), 1), + parse(json!({"schemaVersion": SCHEMA_VERSION}), SCHEMA_VERSION), Err(ParseError::Invalid(_)) )); let mut extra = serde_json::to_value(local().document()).unwrap(); extra["app"]["startWithSystem"] = json!(true); - assert!(matches!(parse(extra, 1), Err(ParseError::Invalid(_)))); + assert!(matches!( + parse(extra, SCHEMA_VERSION), + Err(ParseError::Invalid(_)) + )); + } + + /// A document as written by version 1, before scanned key repeat. + fn version_one() -> Value { + let mut value = serde_json::to_value(local().document()).unwrap(); + value["schemaVersion"] = json!(1); + value["app"] + .as_object_mut() + .unwrap() + .remove("scanKeyRepeatEnabled"); + value + } + + #[test] + fn version_one_documents_read_with_scanned_key_repeat_off() { + let mut local = local(); + local.settings.scan_key_repeat_enabled = true; + let parsed = parse(version_one(), 1).unwrap(); + assert_eq!(parsed.schema_version, SCHEMA_VERSION); + assert!(!parsed.app.scan_key_repeat_enabled); + let settings = plan(&local, parsed).unwrap().settings.unwrap(); + assert!(!settings.scan_key_repeat_enabled); + + // Version 1 never had the field, and a version 2 document needs it. + let mut early = version_one(); + early["app"]["scanKeyRepeatEnabled"] = json!(true); + assert!(matches!(parse(early, 1), Err(ParseError::Invalid(_)))); + let mut missing = version_one(); + missing["schemaVersion"] = json!(SCHEMA_VERSION); + assert!(matches!( + parse(missing.clone(), SCHEMA_VERSION), + Err(ParseError::Invalid(_)) + )); + assert!(matches!(parse(missing, 1), Err(ParseError::Invalid(_)))); + } + + #[test] + fn a_merge_base_saved_by_version_one_still_loads() { + let base: Document = serde_json::from_value(version_one()).unwrap(); + assert!(!base.app.scan_key_repeat_enabled); } #[test] @@ -752,7 +820,10 @@ mod tests { let mut value = document.clone(); corrupt(&mut value); assert!( - matches!(parse(value.clone(), 1), Err(ParseError::Invalid(_))), + matches!( + parse(value.clone(), SCHEMA_VERSION), + Err(ParseError::Invalid(_)) + ), "accepted {value}" ); } diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 388aaff7..ce8dbb64 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -600,7 +600,7 @@ impl Keyboard { None } #[cfg(test)] - fn choose(&mut self, key: Key) -> Option { + pub(crate) fn choose(&mut self, key: Key) -> Option { self.choose_with_context(key, None) } fn stroke(&self, key: Key) -> Stroke { @@ -815,15 +815,23 @@ impl Keyboard { Some(PendingTyped::Prediction) | None => {} } self.activation = None; - self.restart_after_typing(); - if self.wait_after_typing && self.scan.options.automatic { - self.waiting_after_typing = true; - self.prefer_predictions = false; - self.scan.suspended = true; - } + self.continue_after_typing(); } completed } + fn continue_after_typing(&mut self) { + self.restart_after_typing(); + if self.wait_after_typing && self.scan.options.automatic { + self.waiting_after_typing = true; + self.prefer_predictions = false; + self.scan.suspended = true; + } + } + /// A repeating key was stopped by a switch. Scanning carries on as it + /// does after typing. + pub fn repeat_stopped(&mut self) { + self.continue_after_typing(); + } pub fn prediction_inserted( &mut self, trailing_space: bool, @@ -947,6 +955,35 @@ impl Keyboard { Key::Close => "Close keyboard".into(), } } + /// The repeating key with any modifiers that go with it, such as + /// "Shift+Left arrow". + fn repeat_label(&self, stroke: Stroke) -> String { + let names = [ + "Shift", + "Ctrl", + if self.mac { "Option" } else { "Alt" }, + if self.mac { "Command" } else { "Windows" }, + ]; + let mut parts: Vec<&str> = names + .into_iter() + .zip(stroke.modifiers) + .filter_map(|(name, held)| held.then_some(name)) + .collect(); + parts.push(Self::repeat_name(stroke.key)); + parts.join("+") + } + fn repeat_name(key: Key) -> &'static str { + match key { + Key::Named("ArrowUp") => "Up arrow", + Key::Named("ArrowDown") => "Down arrow", + Key::Named("ArrowLeft") => "Left arrow", + Key::Named("ArrowRight") => "Right arrow", + Key::Named("PageUp") => "Page Up", + Key::Named("PageDown") => "Page Down", + Key::Named(name) => name, + _ => "key", + } + } fn weight(key: Key) -> f64 { match key { Key::Character(' ', _) => 4.0, @@ -979,6 +1016,17 @@ impl Keyboard { } } pub fn frame(&self, screen: Rect, units: f64, color: ScannerColor) -> Frame { + self.frame_repeating(screen, units, color, None) + } + /// Draws the keyboard, with the stop instruction while `repeating` names + /// a key that repeats. + pub fn frame_repeating( + &self, + screen: Rect, + units: f64, + color: ScannerColor, + repeating: Option<(Stroke, &str)>, + ) -> Frame { let row_scan = self.scan.row_scan(); let escaping = self.scan.nav.escaping(); let highlight_row = row_scan || escaping; @@ -995,7 +1043,9 @@ impl Keyboard { Page::Functions => "Navigation", Page::Numbers => "Numbers", }; - let status = if self.error { + let status = if let Some((stroke, instruction)) = repeating { + format!("Repeating {} · {instruction}", self.repeat_label(stroke)) + } else if self.error { "Input failed · Select to try again".to_owned() } else if self.waiting_after_typing { "Press Select to continue typing.".to_owned() @@ -1040,7 +1090,7 @@ impl Keyboard { self.prediction_note() }, dock: self.dock, - selected: (!self.scan.suspended && !self.disabled()) + selected: (repeating.is_none() && !self.scan.suspended && !self.disabled()) .then_some((active_row, active_column)), row_scan: highlight_row, thickness: Default::default(), diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 7c90e362..045b4212 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -1034,6 +1034,9 @@ fn tick(app: &AppHandle) { } let result = (|| -> Result<(), String> { let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + // Read before anything below can change it: settling the + // environment and dispatching can end a repeat, for example. + let before = d.engine.as_ref().map(|engine| engine.technique.phase()); let Data { display, engine, .. } = &mut *d; @@ -1060,15 +1063,11 @@ fn tick(app: &AppHandle) { let prompt = d.pressed.prompt(now_ms); let mut request = None; let captured_keys = prediction_captured_keys(d.remote, &d.switches); - let phase_changed = if let Some(engine) = d.engine.as_mut() { + if let Some(engine) = d.engine.as_mut() { A::poll(app, &mut engine.technique, &captured_keys); - let before = engine.technique.phase(); engine.tick(elapsed, held); request = engine.take_selection(); - before != engine.technique.phase() - } else { - false - }; + } let environment = d.display.clone(); let input_generation = d.input_generation; let remote = d.remote; @@ -1076,7 +1075,14 @@ fn tick(app: &AppHandle) { if let Some(request) = request { dispatch::(app, request, environment.as_ref(), input_generation, remote)?; } - if phase_changed { + let after = c + .data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .engine + .as_ref() + .map(|engine| engine.technique.phase()); + if before != after { publish::(app); } render::(app, prompt.as_ref()) @@ -1418,6 +1424,64 @@ mod repeat_stop_tests { } } + #[test] + fn both_stop_choices_stop_a_scanned_key_repeat_and_consume_the_switch() { + use crate::{ + point_scan::{ + Config, + MouseRepeatStopEdge::{Press, Release}, + }, + point_workflow::{Request, Workflow}, + scan_keyboard::Key, + scanning::{Rect, Session, Technique}, + }; + for edge in [Press, Release] { + for id in ["one", "remote:1"] { + let config = Config { + automatic: false, + mouse_repeat_stop_edge: edge, + ..Config::default() + }; + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; + let mut session = + Session::new(Workflow::new(config.point(), screen, 1.0).unwrap(), false); + assert_eq!( + session.action(Action::OpenKeyboard), + Some(Request::OpenKeyboard) + ); + session.technique.execution_succeeded(); + session.technique.set_key_repeat_settings(true, 100, 0); + assert!(matches!( + session.technique.choose_key(Key::Named("ArrowLeft")), + Some(Request::Keyboard(_)) + )); + session.technique.execution_succeeded(); + assert!(session.technique.mouse_repeating()); + + let mut stop = RepeatStop::default(); + assert_eq!( + stop.press(id, Some(&mut session.technique)), + Some(edge == Press) + ); + assert_eq!(session.technique.mouse_repeating(), edge == Release); + assert_eq!( + stop.release(id, Some(&mut session.technique)), + Some(edge == Release) + ); + assert!(!session.technique.mouse_repeating()); + session.tick(250, false); + assert!(session.take_selection().is_none()); + // The next press is an ordinary one again. + assert_eq!(stop.press(id, Some(&mut session.technique)), None); + } + } + } + #[test] fn repeat_off_leaves_switch_gestures_available_for_both_choices() { use crate::{ diff --git a/src-tauri/src/settings_sync.rs b/src-tauri/src/settings_sync.rs index 068254f5..9772c68a 100644 --- a/src-tauri/src/settings_sync.rs +++ b/src-tauri/src/settings_sync.rs @@ -365,7 +365,15 @@ pub struct Engine { impl Engine { pub fn new(remote: Arc, host: Arc) -> Self { - let state = host.load_state(); + let mut state = host.load_state(); + // A base saved by an older build has already been read into this + // version's settings, so it only differs by its version number. + // Left alone, that alone would upload a newer document and stop + // computers still on the older build syncing. + if let Some(base) = state.base.as_mut() { + base.cloud.schema_version = SCHEMA_VERSION; + base.local.schema_version = SCHEMA_VERSION; + } Self { remote, host, @@ -1681,6 +1689,36 @@ mod tests { assert_eq!(engine.sync(None).await.status, Status::NeedsChoice); } + #[tokio::test] + async fn updating_from_version_one_uploads_nothing_until_a_change() { + let local = with_dwell(untouched(), true); + let remote = Arc::new(FakeRemote::default()); + let host = Arc::new(FakeHost::new(local)); + engine(&remote, &host).sync(None).await; + // What the older build saved and uploaded. + { + let mut state = host.state.lock().unwrap(); + let base = state.base.as_mut().unwrap(); + base.cloud.schema_version = 1; + base.local.schema_version = 1; + } + { + let mut row = remote.row.lock().unwrap(); + let row = row.as_mut().unwrap(); + row.schema_version = 1; + row.payload["schemaVersion"] = serde_json::json!(1); + row.payload["app"] + .as_object_mut() + .unwrap() + .remove("scanKeyRepeatEnabled"); + } + let writes = remote.writes.lock().unwrap().len(); + let updated = engine(&remote, &host); + assert!(!updated.changed_since_sync().await); + assert_eq!(updated.sync(None).await.status, Status::UpToDate); + assert_eq!(remote.writes.lock().unwrap().len(), writes); + } + #[tokio::test] async fn normalized_apply_becomes_the_base_so_nothing_ping_pongs() { let cloud = with_dwell(untouched(), true); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index e0352766..fa5dfa0e 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -70,6 +70,10 @@ pub struct AppSettings { pub key_repeat_interval_ms: u32, #[serde(default = "default_key_repeat_initial_delay")] pub key_repeat_initial_delay_ms: u32, + /// Navigation keys chosen on the scanned keyboard repeat until a switch + /// stops them, at the key repeat delay and interval above. + #[serde(default)] + pub scan_key_repeat_enabled: bool, #[serde(default)] pub dwell_click_enabled: bool, #[serde(default = "default_dwell_click_delay")] @@ -95,6 +99,7 @@ impl Default for AppSettings { 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(), + scan_key_repeat_enabled: false, dwell_click_enabled: false, dwell_click_delay_ms: default_dwell_click_delay(), cursor_overlay_enabled: true, diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index a5b28f95..191bdc03 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -218,6 +218,7 @@ it.each([ ["keyboard", "Choose a keyboard row, then a key."], ["keyboardSuspended", "Select to resume the keyboard."], ["keyboardOpening", "Opening the keyboard."], + ["keyboardRepeating", "Repeating a key. Press and release a switch to stop."], ])("explains the %s workflow phase", async (phase, message) => { mocks.invoke.mockResolvedValue({ ...initial, enabled: true, phase }); render(); diff --git a/src/api.ts b/src/api.ts index 9cb927a9..70f4b13f 100644 --- a/src/api.ts +++ b/src/api.ts @@ -18,6 +18,7 @@ export const browserState: AppState = { moveRepeatIntervalMs: 250, scrollRepeatIntervalMs: 250, mouseRepeatAccelerationDurationMs: 1000, keyRepeatEnabled: true, keyRepeatIntervalMs: 250, keyRepeatInitialDelayMs: 500, + scanKeyRepeatEnabled: false, dwellClickEnabled: false, dwellClickDelayMs: 1000, cursorOverlayEnabled: true, cursorOverlaySize: "medium", cursorOverlayColor: "red", cursorOverlayVisibility: "whileControlling", diff --git a/src/scanning/useScanning.ts b/src/scanning/useScanning.ts index 331d6b7f..50ba120f 100644 --- a/src/scanning/useScanning.ts +++ b/src/scanning/useScanning.ts @@ -41,7 +41,7 @@ export type PointScanState = { remote?: boolean; config: PointScanConfig; enabled: boolean; - phase: "autoSelecting" | "idle" | "row" | "rowEscape" | "cell" | "x" | "y" | "menu" | "menuSuspended" | "dragDestination" | "dragConfirmation" | "executing" | "keyboard" | "keyboardSuspended" | "keyboardOpening" | "mouse" | "mouseSuspended" | "mouseMoving" | "mouseScrolling"; + phase: "autoSelecting" | "idle" | "row" | "rowEscape" | "cell" | "x" | "y" | "menu" | "menuSuspended" | "dragDestination" | "dragConfirmation" | "executing" | "keyboard" | "keyboardSuspended" | "keyboardOpening" | "keyboardRepeating" | "mouse" | "mouseSuspended" | "mouseMoving" | "mouseScrolling"; paused: boolean; message: string; supported: boolean; diff --git a/src/settings/InputSection.tsx b/src/settings/InputSection.tsx index 7579951f..7b7e6cd5 100644 --- a/src/settings/InputSection.tsx +++ b/src/settings/InputSection.tsx @@ -6,7 +6,8 @@ import { } from "./controls"; export function InputSection({ settings, update }: { settings: AppSettings; update: SettingsUpdate }) { - return + const repeating = settings.keyRepeatEnabled || settings.scanKeyRepeatEnabled; + return
update("dwellClickEnabled", value)} />
@@ -14,12 +15,16 @@ export function InputSection({ settings, update }: { settings: AppSettings; upda note={{ summary: "After mobile pointer movement stops, a countdown appears and performs one left click. Move again to rearm it." }} />
+
+ update("scanKeyRepeatEnabled", value)} /> +

Backspace, Delete, the arrow keys, Tab, Page Up and Page Down keep repeating after you choose them, until you press a switch. They use the key repeat timing under More options below. Choose whether a press or a release stops them under Mouse, Stop repeating.

+
update("keyRepeatEnabled", value)} />
- legend="Delay before repeating" columns="four" disabled={!settings.keyRepeatEnabled} options={keyRepeatDelayOptions} value={settings.keyRepeatInitialDelayMs} onChange={(next) => update("keyRepeatInitialDelayMs", next)} /> - legend="Key interval" columns="four" disabled={!settings.keyRepeatEnabled} options={secondsOptions(repeatIntervalOptions)} value={settings.keyRepeatIntervalMs} onChange={(next) => update("keyRepeatIntervalMs", next)} - note={{ about: "key repeat", summary: "Held navigation keys repeat, like on a keyboard.", detail: "Applies to the arrow keys, Tab, Backspace, Delete, Page Up, and Page Down." }} /> + legend="Delay before repeating" columns="four" disabled={!repeating} options={keyRepeatDelayOptions} value={settings.keyRepeatInitialDelayMs} onChange={(next) => update("keyRepeatInitialDelayMs", next)} /> + legend="Key interval" columns="four" disabled={!repeating} options={secondsOptions(repeatIntervalOptions)} value={settings.keyRepeatIntervalMs} onChange={(next) => update("keyRepeatIntervalMs", next)} + note={{ about: "key repeat", summary: "Held navigation keys repeat, like on a keyboard.", detail: "Applies to the arrow keys, Tab, Backspace, Delete, Page Up, and Page Down. Keys repeating on the scanned keyboard use the same timing." }} />
; diff --git a/src/settings/MouseSettingsView.tsx b/src/settings/MouseSettingsView.tsx index ac56f841..0a51df64 100644 --- a/src/settings/MouseSettingsView.tsx +++ b/src/settings/MouseSettingsView.tsx @@ -13,7 +13,7 @@ export function MouseSettingsView({ settings, onChange, scanning }: { settings: scanning.update("mouseRepeatStopEdge", value)} - note={{ summary: "Applies to movement and scrolling in Mouse scanning, including switches forwarded by Remote. On switch release keeps repeating while you hold the switch. The stop gesture ignores assigned switch actions." }} /> + note={{ summary: "Applies to movement and scrolling in Mouse scanning, and to keys repeating on the scanned keyboard, including switches forwarded by Remote. On switch release keeps repeating while you hold the switch. The stop gesture ignores assigned switch actions." }} />

{scanning.pending ? "Saving scanning settings..." : scanning.unsaved ? "Scanning settings have unsaved changes." : "Saved automatically."}

{scanning.error &&

{scanning.error}

} {scanning.error && scanning.unsaved && } diff --git a/src/settings/ScanningSection.tsx b/src/settings/ScanningSection.tsx index 120bbc8b..6f6014c1 100644 --- a/src/settings/ScanningSection.tsx +++ b/src/settings/ScanningSection.tsx @@ -7,6 +7,7 @@ const phases = { keyboard: "Choose a keyboard row, then a key", keyboardSuspended: "Select to resume the keyboard", keyboardOpening: "Opening the keyboard", + keyboardRepeating: "Repeating a key", mouse: "Choose a mouse control", mouseSuspended: "Select to resume the mouse", mouseMoving: "Moving pointer", @@ -29,7 +30,7 @@ export function ScanningSection({ controller }: { controller: ScanningController const { state, config, pending, error, retry, unsaved } = controller; return
-

{state?.enabled ? `${state.paused ? "Paused. " : ""}${phases[state.phase]}${state.phase === "mouseMoving" || state.phase === "mouseScrolling" ? `. ${mouseRepeatStopInstruction(state.config)}` : ""}.` : (state?.message ?? "Loading point scan...")}

+

{state?.enabled ? `${state.paused ? "Paused. " : ""}${phases[state.phase]}${state.phase === "mouseMoving" || state.phase === "mouseScrolling" || state.phase === "keyboardRepeating" ? `. ${mouseRepeatStopInstruction(state.config)}` : ""}.` : (state?.message ?? "Loading point scan...")}

{pending ? "Saving scanning settings..." : unsaved ? "Scanning settings have unsaved changes." : "Saved automatically."}

{error &&

{error}

} {error && unsaved && } @@ -41,7 +42,7 @@ export function ScanningSection({ controller }: { controller: ScanningController

Select opens Home, unless you chose Last mode used. Home offers Point, Mouse and Keyboard, plus Apps and windows, Editing, Browser and Media commands that act on the app in front, so you can bring the right app forward with switches instead of focusing it first. After a click or other action, Select carries on in the same mode; Close menu, Stop scanning or Escape means the next Select opens Home. Home is also a tile in the Point action menu and Mouse Actions.

Choose Mouse from Home or the Point action menu, or assign Open mouse to a switch. Mouse has an Actions tile for editing, windows, browser, media and other commands, so you can stay in Mouse. Modified clicks use the current pointer position. Choose Switch to Point in the Mouse panel or assign Open point to a switch. Stop scanning or Escape ends the session without changing the mode Select continues in next time.

In Mouse, select a direction to move with the saved pointer speed and Remote repeat settings. {mouseRepeatStopInstruction(config)} repeating and scan the panel again. If repeat is off, each selection moves one step.

-

Assign switch actions in the Switches page. Actions run on release; holding a switch pauses the scan highlight. During repeating Mouse movement or scrolling, {config.mouseRepeatStopEdge === "press" ? "the first switch press stops the action" : "the stop switch keeps the action going until release"}; the stop gesture ignores assigned actions. Manual scanning needs Select, Next and Previous. After clicking or reaching the pass limit, use Select to start again.

+

Assign switch actions in the Switches page. Actions run on release; holding a switch pauses the scan highlight. During repeating Mouse movement, scrolling or keys, {config.mouseRepeatStopEdge === "press" ? "the first switch press stops the action" : "the stop switch keeps the action going until release"}; the stop gesture ignores assigned actions. Manual scanning needs Select, Next and Previous. After clicking or reaching the pass limit, use Select to start again.

The scanning keyboard offers QWERTY, Simple QWERTY, and Common letters first. Simple QWERTY keeps three letter rows with punctuation on Numbers. Choose a saved layout in Keyboard settings or cycle layouts from the keyboard footer while typing.

Assigned keys stay reserved while Switchify runs. Escape resets the scan. Mobile connections pause local scanning until they end.

On Windows, assigned keys remain switches with Shift, Ctrl, Alt or Windows held, even in the background. Other keys and Switchify-generated shortcuts still work normally.

diff --git a/src/settings/settings.test.tsx b/src/settings/settings.test.tsx index 06b3e16a..1764ab0a 100644 --- a/src/settings/settings.test.tsx +++ b/src/settings/settings.test.tsx @@ -171,6 +171,14 @@ describe("Switchify PC settings", () => { fireEvent.click(toggle); expect(screen.getByRole("group", { name: "Delay before repeating" })).toBeDisabled(); expect(screen.getByRole("group", { name: "Key interval" })).toBeDisabled(); + + // Scanned keyboard repeat is off by default and shares the same timing. + const scanned = screen.getByRole("checkbox", { name: "Repeat keys on the scanned keyboard" }); + expect(scanned).not.toBeChecked(); + fireEvent.click(scanned); + expect(scanned).toBeChecked(); + expect(screen.getByRole("group", { name: "Delay before repeating" })).not.toBeDisabled(); + expect(screen.getByRole("group", { name: "Key interval" })).not.toBeDisabled(); }); it("opens Mouse settings from the sidebar and keeps Input settings separate", async () => { diff --git a/src/types.ts b/src/types.ts index 86e8813d..faea3672 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,7 @@ export type AppSettings = { keyRepeatEnabled: boolean; keyRepeatIntervalMs: number; keyRepeatInitialDelayMs: number; + scanKeyRepeatEnabled: boolean; dwellClickEnabled: boolean; dwellClickDelayMs: number; cursorOverlayEnabled: boolean;