From 5a0cf1ee9fc9d2a477a417f7ac14b30b1a3945cb Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 30 Sep 2026 14:04:35 +0100 Subject: [PATCH] Make scanned Mouse repeat stop edge configurable --- src-tauri/src/point_scan.rs | 38 ++++ src-tauri/src/point_workflow.rs | 35 ++-- src-tauri/src/scan_mouse.rs | 20 +- src-tauri/src/scanning.rs | 8 +- src-tauri/src/scanning_runtime.rs | 307 ++++++++++++++++++++++++---- src/App.tsx | 4 +- src/PointScan.test.tsx | 41 ++++ src/scanning/useScanning.ts | 5 + src/settings/MouseSettingsView.tsx | 15 +- src/settings/PointerSection.tsx | 5 +- src/settings/ScannerPreferences.tsx | 4 +- src/settings/ScanningSection.tsx | 12 +- src/settings/SwitchesSection.tsx | 2 +- 13 files changed, 411 insertions(+), 85 deletions(-) diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index f9a5404d..c0b98a02 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -27,10 +27,27 @@ pub enum KeyboardLayout { CommonLetters, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MouseRepeatStopEdge { + Press, + #[default] + Release, +} +impl MouseRepeatStopEdge { + pub fn instruction(self) -> &'static str { + match self { + Self::Press => "Press a switch to stop", + Self::Release => "Press and release a switch to stop", + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct Config { pub control_mode: ControlMode, + pub mouse_repeat_stop_edge: MouseRepeatStopEdge, #[serde(deserialize_with = "crate::scan_preferences::deserialize_preferences")] pub scan_preferences: crate::scan_preferences::Preferences, pub word_prediction: bool, @@ -58,6 +75,7 @@ impl Default for Config { fn default() -> Self { Self { control_mode: ControlMode::Point, + mouse_repeat_stop_edge: MouseRepeatStopEdge::Release, scan_preferences: Default::default(), word_prediction: true, keyboard_layout: KeyboardLayout::Qwerty, @@ -128,6 +146,7 @@ impl Config { pub fn point(&self) -> PointSettings { PointSettings { control_mode: self.control_mode, + mouse_repeat_stop_edge: self.mouse_repeat_stop_edge, scan: self.resolved(crate::scan_preferences::Area::Point), menu_scan: self.resolved(crate::scan_preferences::Area::Menu), keyboard_scan: self.resolved(crate::scan_preferences::Area::Keyboard), @@ -154,6 +173,7 @@ impl Config { #[derive(Clone)] pub struct PointSettings { pub control_mode: ControlMode, + pub mouse_repeat_stop_edge: MouseRepeatStopEdge, pub scan: crate::scan_preferences::Resolved, pub menu_scan: crate::scan_preferences::Resolved, pub keyboard_scan: crate::scan_preferences::Resolved, @@ -1133,6 +1153,7 @@ mod tests { json["autoSelectDelayMs"] = serde_json::json!(1000); json["scannerColor"] = serde_json::json!("blue"); json["controlMode"] = serde_json::json!("point"); + json["mouseRepeatStopEdge"] = serde_json::json!("release"); json["wordPrediction"] = serde_json::json!(true); json["keyboardLayout"] = serde_json::json!("qwerty"); json["enhancedWordPrediction"] = serde_json::json!(false); @@ -1182,6 +1203,23 @@ mod tests { .is_err()); } #[test] + fn mouse_repeat_stop_edge_is_saved_and_older_settings_default_to_release() { + let older: Config = serde_json::from_str(r#"{"controlMode":"mouse"}"#).unwrap(); + assert_eq!(older.mouse_repeat_stop_edge, MouseRepeatStopEdge::Release); + for (value, edge) in [ + ("press", MouseRepeatStopEdge::Press), + ("release", MouseRepeatStopEdge::Release), + ] { + let config: Config = + serde_json::from_value(serde_json::json!({"mouseRepeatStopEdge":value})).unwrap(); + assert_eq!(config.point().mouse_repeat_stop_edge, edge); + let saved = serde_json::to_value(&config).unwrap(); + assert_eq!(saved["mouseRepeatStopEdge"], value); + assert_eq!(serde_json::from_value::(saved).unwrap(), config); + } + assert!(serde_json::from_str::(r#"{"mouseRepeatStopEdge":"unknown"}"#).is_err()); + } + #[test] fn keyboard_layout_is_additive_and_uses_stable_saved_names() { let older: Config = serde_json::from_str(r#"{"wordPrediction":false}"#).unwrap(); assert_eq!(older.keyboard_layout, KeyboardLayout::Qwerty); diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index dab06d65..df34eba8 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -157,7 +157,8 @@ impl Workflow { ), keyboard_layout_pending: false, keyboard_area: screen, - mouse: crate::scan_mouse::MousePanel::new(mouse_options, 1, 100), + mouse: crate::scan_mouse::MousePanel::new(mouse_options, 1, 100) + .with_repeat_stop_edge(config.mouse_repeat_stop_edge), mouse_area: screen, dock: Default::default(), pointer: None, @@ -199,7 +200,8 @@ impl Workflow { self.point.config.mouse_scan, 1, self.mouse.speed_percent, - ); + ) + .with_repeat_stop_edge(self.point.config.mouse_repeat_stop_edge); mouse.dock = self.dock; mouse } @@ -744,7 +746,10 @@ impl Technique for Workflow { fn mouse_repeating(&self) -> bool { matches!(self.stage, Stage::MouseMoving | Stage::MouseScrolling) } - fn switch_released(&mut self) -> bool { + 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 !matches!(self.stage, Stage::MouseMoving | Stage::MouseScrolling) { return false; } @@ -1292,7 +1297,7 @@ mod tests { session.action(Action::Select), Some(Request::MouseMove { dy: 0, .. }) )); - assert!(session.technique.switch_released()); + assert!(session.technique.stop_mouse_repeat()); let status = |session: &Session| { let mut frame = session.technique.frame(); frame.tiles.pop().unwrap().text @@ -1303,7 +1308,7 @@ mod tests { assert_eq!(status(&session), "Movement · Select →"); // A move that fails starts again from the top. assert!(session.action(Action::Select).is_some()); - session.technique.switch_released(); + session.technique.stop_mouse_repeat(); session.action(Action::Select); session.technique.mouse_repeat_enabled = false; assert!(session.action(Action::Select).is_some()); @@ -1535,8 +1540,8 @@ mod tests { matches!(session.take_selection(), Some(Request::MouseMove { dx, dy: 0 }) if dx > 0) ); assert!(session.technique.mouse_repeating()); - assert!(session.technique.switch_released()); - assert!(!session.technique.switch_released()); + assert!(session.technique.stop_mouse_repeat()); + assert!(!session.technique.stop_mouse_repeat()); assert_eq!( session.technique.phase(), Phase::Workflow(WorkflowPhase::Mouse) @@ -1585,7 +1590,7 @@ mod tests { }, ); assert!(workflow.take_selection().is_none()); - assert!(!workflow.switch_released()); + assert!(!workflow.stop_mouse_repeat()); } #[test] @@ -1668,8 +1673,8 @@ mod tests { ); assert_eq!(workflow.take_selection(), Some(Request::MouseScroll { dy })); assert!(workflow.mouse_repeating()); - assert!(workflow.switch_released()); - assert!(!workflow.switch_released()); + assert!(workflow.stop_mouse_repeat()); + assert!(!workflow.stop_mouse_repeat()); assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Mouse)); workflow.update( 120, @@ -1693,7 +1698,7 @@ mod tests { Some(Request::MouseScroll { dy: 5 }) ); assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Mouse)); - assert!(!workflow.switch_released()); + assert!(!workflow.stop_mouse_repeat()); workflow.set_mouse_settings(100, 1000, 250, 80, true); workflow.mouse_key(crate::scan_mouse::Key::Scroll(-1)); workflow.execution_failed("input failed".into()); @@ -1706,7 +1711,7 @@ mod tests { workflow.mouse_key(crate::scan_mouse::Key::Scroll(1)); workflow.set_mouse_settings(100, 1000, 250, 80, false); assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Mouse)); - assert!(!workflow.switch_released()); + assert!(!workflow.stop_mouse_repeat()); workflow.set_mouse_settings(100, 1000, 250, 80, true); workflow.mouse_key(crate::scan_mouse::Key::Scroll(1)); let next_screen = Rect { @@ -1743,7 +1748,7 @@ mod tests { assert!(workflow.mouse_repeating()); workflow.set_mouse_settings(105, 1000, 250, 80, true); assert!(!workflow.mouse_repeating()); - assert!(!workflow.switch_released()); + assert!(!workflow.stop_mouse_repeat()); workflow.mouse_key(Key::Move(1, 0)); let next_screen = Rect { @@ -1755,7 +1760,7 @@ mod tests { workflow.set_mouse_area(next_screen, next_screen, 1.0, 2); assert!(!workflow.mouse_repeating()); assert!(workflow.move_repeat.is_none()); - assert!(!workflow.switch_released()); + assert!(!workflow.stop_mouse_repeat()); } #[test] @@ -1910,7 +1915,7 @@ mod tests { )); workflow.execution_failed("input failed".into()); assert_eq!(workflow.mouse_feedback(), None); - assert!(!workflow.switch_released()); + assert!(!workflow.stop_mouse_repeat()); workflow.handle(Action::Select); assert_eq!( workflow.mouse_feedback(), diff --git a/src-tauri/src/scan_mouse.rs b/src-tauri/src/scan_mouse.rs index 411767bf..ebc2bd74 100644 --- a/src-tauri/src/scan_mouse.rs +++ b/src-tauri/src/scan_mouse.rs @@ -39,6 +39,7 @@ pub struct MousePanel { pub speed_percent: u16, pub error: bool, notice: Option<&'static str>, + repeat_stop_edge: crate::point_scan::MouseRepeatStopEdge, positioning: bool, displays: usize, rows: Vec>, @@ -56,12 +57,17 @@ impl MousePanel { speed_percent, error: false, notice: None, + repeat_stop_edge: Default::default(), positioning: false, displays, rows, scan, } } + pub fn with_repeat_stop_edge(mut self, edge: crate::point_scan::MouseRepeatStopEdge) -> Self { + self.repeat_stop_edge = edge; + self + } fn rows(more: bool, positioning: bool, displays: usize) -> Vec> { use Key::*; if positioning { @@ -221,14 +227,12 @@ impl MousePanel { let escaping = self.scan.nav.escaping(); let highlight_row = row_scan || escaping; let status = if let Some(repeating) = repeating { - match repeating { - RepeatPrompt::Moving => "Moving pointer · Press and release a switch to stop", - RepeatPrompt::Scrolling(dy) if dy > 0 => { - "Scrolling up · Press and release a switch to stop" - } - RepeatPrompt::Scrolling(_) => "Scrolling down · Press and release a switch to stop", - } - .to_owned() + let action = match repeating { + RepeatPrompt::Moving => "Moving pointer", + RepeatPrompt::Scrolling(dy) if dy > 0 => "Scrolling up", + RepeatPrompt::Scrolling(_) => "Scrolling down", + }; + format!("{action} · {}", self.repeat_stop_edge.instruction()) } else if self.error { "Mouse action failed · Select to resume".to_owned() } else if let Some(notice) = self.notice { diff --git a/src-tauri/src/scanning.rs b/src-tauri/src/scanning.rs index 26bb04f0..227438c8 100644 --- a/src-tauri/src/scanning.rs +++ b/src-tauri/src/scanning.rs @@ -325,8 +325,12 @@ pub trait Technique { fn mouse_repeating(&self) -> bool { false } - /// Ends a repeating scanned Mouse action after its stop switch is released. - fn switch_released(&mut self) -> bool { + /// Which switch edge stops a repeating scanned Mouse action. + fn mouse_repeat_stop_edge(&self) -> crate::point_scan::MouseRepeatStopEdge { + Default::default() + } + /// Ends a repeating scanned Mouse action. + fn stop_mouse_repeat(&mut self) -> bool { false } fn execution_failed(&mut self, _message: String) {} diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 9b26d972..2510a434 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -82,17 +82,26 @@ const RETRY_MS: u64 = 2000; struct RepeatStop { owner: Option, held: HashSet, + stop_on_release: bool, } impl RepeatStop { - fn down(&mut self, id: &str, repeating: bool) -> bool { + /// None means an ordinary press; Some records whether to stop now. + fn down( + &mut self, + id: &str, + repeating: bool, + edge: crate::point_scan::MouseRepeatStopEdge, + ) -> Option { if self.owner.is_none() && !repeating && self.held.is_empty() { - return false; + return None; } self.held.insert(id.to_owned()); - if self.owner.is_none() && repeating { + let first = self.owner.is_none() && repeating; + if first { self.owner = Some(id.to_owned()); + self.stop_on_release = edge == crate::point_scan::MouseRepeatStopEdge::Release; } - true + Some(first && !self.stop_on_release) } /// None means the release belongs to ordinary switch handling. fn up(&mut self, id: &str) -> Option { @@ -103,7 +112,19 @@ impl RepeatStop { if stop { self.owner = None; } - Some(stop) + Some(stop && self.stop_on_release) + } + fn press(&mut self, id: &str, mut technique: Option<&mut T>) -> Option { + let repeating = technique.as_ref().is_some_and(|t| t.mouse_repeating()); + let edge = technique + .as_ref() + .map_or(Default::default(), |t| t.mouse_repeat_stop_edge()); + self.down(id, repeating, edge) + .map(|stop| stop && technique.as_mut().is_some_and(|t| t.stop_mouse_repeat())) + } + fn release(&mut self, id: &str, mut technique: Option<&mut T>) -> Option { + self.up(id) + .map(|stop| stop && technique.as_mut().is_some_and(|t| t.stop_mouse_repeat())) } fn held(&self) -> bool { !self.held.is_empty() @@ -826,13 +847,20 @@ fn tick(app: &AppHandle) { if d.remote_hold_started.is_none() { d.remote_hold_started = Some(now_ms); } - let repeating = d - .engine - .as_ref() - .is_some_and(|engine| engine.technique.mouse_repeating()); - if d.repeat_stop.down(&id.to_string(), repeating) { + let Data { + repeat_stop, + engine, + .. + } = &mut *d; + if let Some(stopped) = repeat_stop.press( + &id.to_string(), + engine.as_mut().map(|engine| &mut engine.technique), + ) { d.pressed.cancel(); - (None, false) + if stopped { + d.last_tick = Instant::now(); + } + (None, stopped) } else { let countdown = d .engine @@ -848,19 +876,24 @@ fn tick(app: &AppHandle) { } } crate::remote_scan::Edge::Up(id) => { - let (action, stopped) = - if let Some(stop) = d.repeat_stop.up(&id.to_string()) { - let stopped = stop - && d.engine - .as_mut() - .is_some_and(|engine| engine.technique.switch_released()); - if stopped { - d.last_tick = Instant::now(); - } - (None, stopped) - } else { - (d.pressed.released(&id.to_string(), now_ms), false) - }; + let (action, stopped) = if let Some(stopped) = { + let Data { + repeat_stop, + engine, + .. + } = &mut *d; + repeat_stop.release( + &id.to_string(), + engine.as_mut().map(|engine| &mut engine.technique), + ) + } { + if stopped { + d.last_tick = Instant::now(); + } + (None, stopped) + } else { + (d.pressed.released(&id.to_string(), now_ms), false) + }; if !d.pressed.held() && !d.repeat_stop.held() { d.remote_hold_started = None; } @@ -922,13 +955,20 @@ fn tick(app: &AppHandle) { continue; } if action == crate::switch_input::Action::Pressed { - let repeating = d - .engine - .as_ref() - .is_some_and(|engine| engine.technique.mouse_repeating()); - if d.repeat_stop.down(&switch_id, repeating) { + let Data { + repeat_stop, + engine, + .. + } = &mut *d; + if let Some(stopped) = repeat_stop.press( + &switch_id, + engine.as_mut().map(|engine| &mut engine.technique), + ) { d.pressed.cancel(); - (None, false) + if stopped { + d.last_tick = Instant::now(); + } + (None, stopped) } else { let settings = d.switches.clone(); let countdown = d @@ -944,11 +984,17 @@ fn tick(app: &AppHandle) { (None, false) } } else { - if let Some(stop) = d.repeat_stop.up(&switch_id) { - let stopped = stop - && d.engine - .as_mut() - .is_some_and(|engine| engine.technique.switch_released()); + if let Some(stopped) = { + let Data { + repeat_stop, + engine, + .. + } = &mut *d; + repeat_stop.release( + &switch_id, + engine.as_mut().map(|engine| &mut engine.technique), + ) + } { if stopped { d.last_tick = Instant::now(); } @@ -1233,12 +1279,183 @@ mod repeat_stop_tests { } } + fn repeating( + edge: crate::point_scan::MouseRepeatStopEdge, + scroll: bool, + ) -> crate::scanning::Session { + use crate::{ + point_scan::Config, + point_workflow::{Request, Workflow}, + scanning::{Rect, Session}, + }; + 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); + session.action(Action::OpenMouse); + session.action(Action::Next); + session.action(Action::Select); + if scroll { + for _ in 0..3 { + session.action(Action::Next); + } + } + let request = session.action(Action::Select); + assert!( + matches!( + request, + Some(Request::MouseMove { .. }) | Some(Request::MouseScroll { .. }) + ), + "{request:?}" + ); + session + } + + #[test] + fn both_stop_choices_control_move_and_scroll_for_local_and_forwarded_switches() { + use crate::{ + point_scan::MouseRepeatStopEdge::{Press, Release}, + scanning::Technique, + }; + for edge in [Press, Release] { + for scroll in [false, true] { + for id in ["one", "remote:1"] { + let mut session = repeating(edge, scroll); + let frame = session.technique.frame(); + assert!(frame + .tiles + .last() + .unwrap() + .text + .ends_with(edge.instruction())); + let mut stop = RepeatStop::default(); + let mut gestures = Gestures::default(); + gestures.pressed("previous", 0, &switches()); + assert_eq!( + stop.release("unmatched", Some(&mut session.technique)), + None + ); + assert_eq!( + stop.press(id, Some(&mut session.technique)), + Some(edge == Press) + ); + gestures.cancel(); // Both transport paths cancel configured gestures on capture. + assert!(gestures.prompt(30_000).is_none()); + assert_eq!(gestures.released(id, 30_000), None); + assert_eq!(session.technique.mouse_repeating(), edge == Release); + assert_eq!( + stop.press("overlap", Some(&mut session.technique)), + Some(false) + ); + let mut repeated = false; + for _ in 0..20 { + session.tick(33, stop.held()); + repeated |= session.take_selection().is_some(); + } + assert_eq!(repeated, edge == Release); + assert_eq!( + stop.release("overlap", Some(&mut session.technique)), + Some(false) + ); + 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()); + assert_eq!(stop.release(id, Some(&mut session.technique)), None); + assert_eq!(stop.press(id, Some(&mut session.technique)), None); + } + } + } + } + + #[test] + fn repeat_off_leaves_switch_gestures_available_for_both_choices() { + use crate::{ + point_scan::MouseRepeatStopEdge::{Press, Release}, + scanning::Technique, + }; + for edge in [Press, Release] { + let mut session = repeating(edge, false); + session + .technique + .set_mouse_settings(100, 1000, 250, 250, false); + assert!(!session.technique.mouse_repeating()); + // Select the first movement key again: this time it is a single step. + session.action(Action::Next); + session.action(Action::Select); + assert!(matches!( + session.action(Action::Select), + Some(crate::point_workflow::Request::MouseMove { .. }) + )); + assert!(!session.technique.mouse_repeating()); + let mut stop = RepeatStop::default(); + assert_eq!(stop.press("one", Some(&mut session.technique)), None); + let mut gestures = Gestures::default(); + gestures.pressed("one", 0, &switches()); + assert_eq!(stop.release("one", Some(&mut session.technique)), None); + assert_eq!(gestures.released("one", 10), Some(Action::Select)); + } + } + + #[test] + fn captured_edges_remain_consumed_after_repeat_cleanup() { + use crate::{ + point_scan::MouseRepeatStopEdge::{Press, Release}, + scanning::{Rect, Technique}, + }; + for edge in [Press, Release] { + for scroll in [false, true] { + for reason in ["settings", "failure", "display", "emergency", "disconnect"] { + let mut session = repeating(edge, scroll); + let mut stop = RepeatStop::default(); + stop.press("one", Some(&mut session.technique)); + match reason { + "failure" => session + .technique + .execution_failed("fake input failure".into()), + "display" => { + let screen = Rect { + x: 1280.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; + session.technique.set_mouse_area(screen, screen, 1.0, 2); + } + _ => { + session.technique.reset(); + stop.clear(); + } + } + assert!(!session.technique.mouse_repeating()); + assert_ne!( + stop.release("one", Some(&mut session.technique)), + Some(true) + ); + assert!(Gestures::default().released("one", 30_000).is_none()); + assert!(session.take_selection().is_none()); + } + } + } + } + #[test] fn local_and_remote_stop_edges_consume_press_hold_and_release() { for id in ["one", "remote:1"] { let mut stop = RepeatStop::default(); let gestures = Gestures::default(); - assert!(stop.down(id, true)); + assert_eq!(stop.down(id, true, Default::default()), Some(false)); assert!(stop.held()); assert!(gestures.prompt(30_000).is_none()); assert!(!gestures.held()); @@ -1246,19 +1463,19 @@ mod repeat_stop_tests { assert_eq!(stop.up(id), Some(true)); assert_eq!(stop.up(id), None); assert!(!stop.held()); - assert!(!stop.down(id, false)); + assert_eq!(stop.down(id, false, Default::default()), None); } } #[test] fn first_press_owns_stop_and_overlapping_releases_are_consumed() { let mut stop = RepeatStop::default(); - assert!(stop.down("one", true)); - assert!(stop.down("one", true)); - assert!(stop.down("two", true)); + assert_eq!(stop.down("one", true, Default::default()), Some(false)); + assert_eq!(stop.down("one", true, Default::default()), Some(false)); + assert_eq!(stop.down("two", true, Default::default()), Some(false)); assert_eq!(stop.up("one"), Some(true)); assert!(stop.held()); - assert!(stop.down("three", false)); + assert_eq!(stop.down("three", false, Default::default()), Some(false)); assert_eq!(stop.up("two"), Some(false)); assert_eq!(stop.up("three"), Some(false)); assert!(!stop.held()); @@ -1268,16 +1485,16 @@ mod repeat_stop_tests { fn ending_repeat_while_held_still_consumes_pending_releases() { for _reason in ["settings", "failure", "display"] { let mut stop = RepeatStop::default(); - assert!(stop.down("one", true)); - assert!(stop.down("two", true)); + assert_eq!(stop.down("one", true, Default::default()), Some(false)); + assert_eq!(stop.down("two", true, Default::default()), Some(false)); // The technique ended its repeat independently before either Up. assert_eq!(stop.up("one"), Some(true)); assert_eq!(stop.up("two"), Some(false)); assert!(!stop.held()); - assert!(!stop.down("one", false)); + assert_eq!(stop.down("one", false, Default::default()), None); } let mut stop = RepeatStop::default(); - assert!(stop.down("one", true)); + assert_eq!(stop.down("one", true, Default::default()), Some(false)); stop.clear(); // Escape, emergency hold, disconnect, or a new session. assert_eq!(stop.up("one"), None); let mut gestures = Gestures::default(); diff --git a/src/App.tsx b/src/App.tsx index 7359585e..6fe7d14c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -419,7 +419,7 @@ function SetupGuide({ state, switches, suspended, busy, error, skip, finish, acc

Input access

{state.accessibility === "required" && }
} {step === 1 && } - {step === 2 &&

Use your switches

Focus the application you want to use, then press and release Select. It starts the last mode you used. Point scanning chooses a screen location and an action; Mouse scanning moves a visible pointer ring and offers mouse controls.

Automatic scanning moves the highlight for you. Use Auto scan rate for grid and panel timing, and Line speed for point scanning lines. Holding a switch pauses the scan highlight; release runs the action shown. During repeating Mouse movement or scrolling, press and release a switch to stop.

Escape disables switch control. Assign Stop scanning or Pause / resume in Switches. Holding any switch for {switches.state ? switches.state.escapeHoldMs / 1000 : 4}s disables switch control.

Open Scanning after setup to adjust movement and switch between Point and Mouse. Mobile connection is optional.

} + {step === 2 &&

Use your switches

Focus the application you want to use, then press and release Select. It starts the last mode you used. Point scanning chooses a screen location and an action; Mouse scanning moves a visible pointer ring and offers mouse controls.

Automatic scanning moves the highlight for you. Use Auto scan rate for grid and panel timing, and Line speed for point scanning lines. Holding a switch pauses the scan highlight; release runs the action shown. Choose whether repeating Mouse movement and scrolling stop on switch press or release under Mouse settings.

Escape disables switch control. Assign Stop scanning or Pause / resume in Switches. Holding any switch for {switches.state ? switches.state.escapeHoldMs / 1000 : 4}s disables switch control.

Open Scanning after setup to adjust movement and switch between Point and Mouse. Mobile connection is optional.

} {step === 3 &&

Choose startup behavior

Switchify can start quietly when you sign in, ready for your switches.

} {step === 4 &&

Choose whether to share diagnostics

Optional anonymous app health and sanitized errors help improve Switchify. Typed text, commands, pairing secrets, device names, and full paths are never included.

Privacy policy
} @@ -791,7 +791,7 @@ export function App() { {view === "home" && void perform(api.disconnectAll)} onAccessibility={() => void perform(() => api.checkAccessibility(true))} />} {view === "switches" &&

Switches

The switches you use to control this computer.

} {view === "scanning" &&

Scanning

Choose how your switches control the pointer.

} - {view === "mouse" && } + {view === "mouse" && } {(view === "mobile" || view === "devices" || view === "profiles") &&

Mobile

Control this computer from Switchify on a phone or tablet.

{view === "mobile" && void perform(api.disconnectAll)} />} {view === "devices" && void perform(() => api.forgetDevice(id))} />} diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index 2be75500..032057d9 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -12,6 +12,8 @@ import { type PointScanState, } from "./scanning/useScanning"; import { defaultScanPreferences } from "./scanning/preferences"; +import { MouseSettingsView } from "./settings/MouseSettingsView"; +import { browserState } from "./api"; import { ScanningSection } from "./settings/ScanningSection"; function more() { const button = screen.getByRole("button", { name: "More options" }); @@ -562,3 +564,42 @@ it("explains the saved Mouse mode and keeps mode cards keyboard accessible", asy fireEvent.click(screen.getByRole("button", { name: "Back to scanning settings" })); expect(screen.getByRole("button", { name: "Customise mouse scanning" })).toHaveFocus(); }); + +function MouseSettings() { + const scanning = useScanning(); + return {}} scanning={scanning} />; +} + +it("saves the accessible Mouse stop choice and retains failed edits for retry", async () => { + render(); + const release = screen.getByRole("button", { name: "On switch release" }); + await waitFor(() => expect(release).toBeEnabled()); + expect(release).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("group", { name: "Stop repeating" })).toBeInTheDocument(); + const press = screen.getByRole("button", { name: "On switch press" }); + press.focus(); + expect(press).toHaveFocus(); + fireEvent.click(press); + await waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith("configure_point_scan", { + config: { ...defaultPointScanConfig, mouseRepeatStopEdge: "press" }, + })); + await waitFor(() => expect(press).toBeEnabled()); + expect(press).toHaveAttribute("aria-pressed", "true"); + mocks.invoke.mockRejectedValueOnce("Disk is full."); + fireEvent.click(release); + expect(await screen.findByRole("alert")).toHaveTextContent("Disk is full."); + expect(release).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText("Scanning settings have unsaved changes.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Retry save" })); + await screen.findByText("Saved automatically."); + expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { + config: { ...defaultPointScanConfig, mouseRepeatStopEdge: "release" }, + }); +}); + +it.each(["press", "release"] as const)("reports the saved %s stop instruction during Mouse repeats", async mouseRepeatStopEdge => { + mocks.invoke.mockResolvedValue({ ...initial, config: { ...initial.config, mouseRepeatStopEdge }, enabled: true, phase: "mouseScrolling" }); + render(); + const instruction = mouseRepeatStopEdge === "press" ? "Press a switch to stop" : "Press and release a switch to stop"; + await screen.findByText(`Scrolling. ${instruction}.`); +}); diff --git a/src/scanning/useScanning.ts b/src/scanning/useScanning.ts index c40eb725..11cea81a 100644 --- a/src/scanning/useScanning.ts +++ b/src/scanning/useScanning.ts @@ -5,6 +5,7 @@ export type ScannerColor = "red" | "green" | "blue" | "yellow" | "white"; export type KeyboardLayout = "qwerty" | "simpleQwerty" | "commonLetters"; export type PointScanConfig = { controlMode: "point" | "mouse"; + mouseRepeatStopEdge: "press" | "release"; scanPreferences?: import("./preferences").ScanPreferences; wordPrediction: boolean; keyboardLayout: KeyboardLayout; @@ -24,6 +25,9 @@ export type PointScanConfig = { backKey: string; pauseKey: string; }; +export function mouseRepeatStopInstruction(config: PointScanConfig) { + return config.mouseRepeatStopEdge === "press" ? "Press a switch to stop" : "Press and release a switch to stop"; +} export type PointScanState = { remote?: boolean; config: PointScanConfig; @@ -35,6 +39,7 @@ export type PointScanState = { }; export const defaultPointScanConfig: PointScanConfig = { controlMode: "point", + mouseRepeatStopEdge: "release", wordPrediction: true, keyboardLayout: "qwerty", enhancedWordPrediction: false, diff --git a/src/settings/MouseSettingsView.tsx b/src/settings/MouseSettingsView.tsx index 8139e1a2..ac56f841 100644 --- a/src/settings/MouseSettingsView.tsx +++ b/src/settings/MouseSettingsView.tsx @@ -1,11 +1,22 @@ +import { Button } from "../ui/controls"; +import { validSwitches, type ScanningController } from "../scanning/useScanning"; +import { OptionGroup } from "./controls"; import type { AppSettings } from "../types"; import { PointerSection } from "./PointerSection"; import type { SettingsUpdate } from "./controls"; -export function MouseSettingsView({ settings, onChange }: { settings: AppSettings; onChange: (next: AppSettings) => void }) { +export function MouseSettingsView({ settings, onChange, scanning }: { settings: AppSettings; onChange: (next: AppSettings) => void; scanning: ScanningController }) { const update: SettingsUpdate = (key, value) => onChange({ ...settings, [key]: value }); return

Mouse

Pointer speed and repeat for Mouse scanning and Remote. Set panel scan timing under Scanning.

- + + 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." }} /> +

{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/PointerSection.tsx b/src/settings/PointerSection.tsx index 69e20d11..aa66a7d7 100644 --- a/src/settings/PointerSection.tsx +++ b/src/settings/PointerSection.tsx @@ -1,5 +1,5 @@ import { Button, Input, MoreOptions } from "../ui/controls"; -import { useEffect, useState } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import type { AppSettings } from "../types"; import { OptionGroup, SettingGroup, Toggle, accelerationOptions, repeatIntervalOptions, @@ -7,7 +7,7 @@ import { } from "./controls"; import { speedLevel, speedLevelLabel, speedPercent } from "./pointerSpeedScale"; -export function PointerSection({ settings, update }: { settings: AppSettings; update: SettingsUpdate }) { +export function PointerSection({ settings, update, children }: { settings: AppSettings; update: SettingsUpdate; children?: ReactNode }) { const percent = settings.pointerScalePercent; const level = speedLevelLabel(percent); const [sliderLevel, setSliderLevel] = useState(level); @@ -41,6 +41,7 @@ export function PointerSection({ settings, update }: { settings: AppSettings; up
update("mouseRepeatEnabled", value)} /> + {children}
legend="Movement interval" columns="four" disabled={!settings.mouseRepeatEnabled} options={secondsOptions(repeatIntervalOptions)} value={settings.moveRepeatIntervalMs} onChange={(next) => update("moveRepeatIntervalMs", next)} /> legend="Movement acceleration" columns="four" disabled={!settings.mouseRepeatEnabled} options={accelerationOptions} value={settings.mouseRepeatAccelerationDurationMs} onChange={(next) => update("mouseRepeatAccelerationDurationMs", next)} /> diff --git a/src/settings/ScannerPreferences.tsx b/src/settings/ScannerPreferences.tsx index 3c26cdd4..d9860c8d 100644 --- a/src/settings/ScannerPreferences.tsx +++ b/src/settings/ScannerPreferences.tsx @@ -1,7 +1,7 @@ import { Button, Input, Select, MoreOptions } from "../ui/controls"; import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; import { ChevronRight } from 'lucide-react'; -import type { ScanningController, ScannerColor, PointScanConfig } from '../scanning/useScanning'; +import { mouseRepeatStopInstruction, type ScanningController, type ScannerColor, type PointScanConfig } from '../scanning/useScanning'; import { areaOptions, sharedOptions, defaultScanPreferences, usualAfterSelection, savedKeyboardWait, type ScanArea, type ScanOptions } from '../scanning/preferences'; import { SettingGroup, Toggle, OptionGroup } from './controls'; @@ -74,7 +74,7 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl

{names[area]}

{descriptions[area]} Change any value to customise it; other settings follow the shared defaults.

} {area === 'shared' &&
{(['point', 'mouse'] as const).map(key => )}
} - {area === 'mouse' &&

The ring stays visible while Mouse is open. Select a direction to move or a scroll tile to scroll; with Repeat mouse movement and scrolling on, press and release a switch to stop either action. Actions opens editing, window, browser, media, display and scanning commands, then returns to Mouse. Modified clicks use the current pointer. End a drag before opening Actions. Pointer speed and repeat controls are under Mouse in the sidebar. Switch to Point returns to screen selection.

} + {area === 'mouse' &&

The ring stays visible while Mouse is open. Select a direction to move or a scroll tile to scroll; with Repeat mouse movement and scrolling on, {mouseRepeatStopInstruction(config).toLowerCase()} either action. Actions opens editing, window, browser, media, display and scanning commands, then returns to Mouse. Modified clicks use the current pointer. End a drag before opening Actions. Pointer speed and repeat controls are under Mouse in the sidebar. Switch to Point returns to screen selection.

} {area === 'point' && <>
-

{state?.enabled ? `${state.paused ? "Paused. " : ""}${phases[state.phase]}.` : (state?.message ?? "Loading point scan...")}

+

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

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

{error &&

{error}

} {error && unsaved && } @@ -39,8 +39,8 @@ export function ScanningSection({ controller }: { controller: ScanningController
How scanning works

Point scanning chooses a screen location with a moving line or grid, then opens actions for clicks, scrolling and dragging. Mouse scanning moves a visible pointer ring and offers directions, clicks, dragging, scrolling and Keyboard in a persistent panel.

Select starts the last mode used. Choose Mouse from 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 will start next time.

-

In Mouse, select a direction to move with the saved pointer speed and Remote repeat settings. Press and release a switch to stop 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, the stop switch keeps the action going until release and ignores its assigned actions. Manual scanning needs Select, Next and Previous. After clicking or reaching the pass limit, use Select to start again.

+

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.

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/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx index 957c7ba0..7d4f0ac4 100644 --- a/src/settings/SwitchesSection.tsx +++ b/src/settings/SwitchesSection.tsx @@ -271,7 +271,7 @@ function SwitchEditor({ ? `Hold ${binding.holdActions .map((a, i) => `${seconds(holdIntervalMs * (i + 1))} for ${actions[a]}`) .join(", ")}. Release to run the action shown.` - : "Holding pauses the scan highlight. Add actions to offer them one by one while held; Mouse repeat uses press and release to stop instead."} + : "Holding pauses the scan highlight. Add actions to offer them one by one while held; Mouse repeat follows the Stop repeating choice in Mouse settings instead."}