From 512b9d7395d78b278268092c6bfb06808c5ecf29 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 2 Oct 2026 15:56:50 +0100 Subject: [PATCH 1/2] Add a scanned Home menu so switch users need not focus an app first Select now opens Home when scanning starts afresh, offering Point, Mouse, Keyboard, Apps and windows, Editing, Browser, Media and Switchify. After an action, Select carries on in the same mode, so routine clicking costs no extra steps. Home actions need no chosen point, so Home follows whichever window is in front; commands return to Home and the keyboard opened from it closes back to it. Home is also a tile in the Point action menu and Mouse Actions. A new "Select starts" setting keeps the old last-mode behaviour available; saved settings without it open Home. Closes #966 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/point-scan.md | 10 +- src-tauri/src/lib.rs | 2 +- src-tauri/src/point_scan.rs | 29 +++ src-tauri/src/point_scan_runtime.rs | 9 + src-tauri/src/point_workflow.rs | 336 +++++++++++++++++++++++++--- src-tauri/src/scan_executor.rs | 6 +- src-tauri/src/scan_menu.rs | 115 +++++++++- src-tauri/src/scan_tile.rs | 25 ++- src-tauri/src/scanning_runtime.rs | 7 +- src/App.test.tsx | 2 +- src/App.tsx | 6 +- src/PointScan.test.tsx | 16 ++ src/help/demonstrations.ts | 4 +- src/homeStatus.test.ts | 7 + src/homeStatus.ts | 4 +- src/scanning/useScanning.ts | 10 + src/settings/ScannerPreferences.tsx | 21 +- src/settings/ScanningSection.tsx | 3 +- 18 files changed, 548 insertions(+), 64 deletions(-) diff --git a/docs/point-scan.md b/docs/point-scan.md index 2f36c0cb..a9008c8e 100644 --- a/docs/point-scan.md +++ b/docs/point-scan.md @@ -6,7 +6,7 @@ Point scan ports the Android line-only and grid-then-line techniques to Switchif Open **Settings → Switches** to add named keyboard switches and assign normal and hold actions. There is no on/off control: scanning is armed whenever the saved switches cover the current mode (Select for automatic scanning; Select, Next and Previous for manual) and the environment allows it. The runtime re-arms after a save, after key learning, after Escape, when a mobile session ends, and retries a failed key reservation every two seconds. Fresh installs have no assignments; old point-scan keys migrate once. See [switch assignments](switches.md). Scanning arms at startup, so assigned keys are reserved from launch. -Focus the intended application and press Select to start. Line mode chooses X, then Y, and opens an action menu. Grid mode chooses a row, then a cell, before the same line sequence. Selecting happens on switch release; holding any switch freezes the position and repeat keydowns do not select again. The scan resets after a menu click or completed drag and waits for the next Select, unless After a selection is set to Keep scanning for point scanning; the next scan then starts by itself. See [After a selection](scanner-architecture.md#after-a-selection). Movement wraps at the selected region's edges. Automatic movement stops after three full passes of the current phase without a selection; the scan resets and waits for the next Select. Manual steps never trigger this limit, and each Select starts a fresh count for the next phase. Android's five speeds are 45, 75, 120, 180, and 270 logical units per second, with delayed ticks capped at 250 ms. +Press Select to open Home and choose Point (see [Home](#home)). Line mode chooses X, then Y, and opens an action menu. Grid mode chooses a row, then a cell, before the same line sequence. Selecting happens on switch release; holding any switch freezes the position and repeat keydowns do not select again. The scan resets after a menu click or completed drag and waits for the next Select, unless After a selection is set to Keep scanning for point scanning; the next scan then starts by itself. See [After a selection](scanner-architecture.md#after-a-selection). Movement wraps at the selected region's edges. Automatic movement stops after three full passes of the current phase without a selection; the scan resets and waits for the next Select. Manual steps never trigger this limit, and each Select starts a fresh count for the next phase. Android's five speeds are 45, 75, 120, 180, and 270 logical units per second, with delayed ticks capped at 250 ms. The scan uses the monitor under the pointer when it starts. Windows uses native physical coordinates and display scaling; macOS uses Core Graphics display units and converts overlay rectangles to AppKit coordinates. A monitor geometry change cancels scanning. Native overlay strips are topmost, click-through, and nonactivating. The pointer moves only when a menu action executes. @@ -60,4 +60,10 @@ Start forwarding, then press Select to begin. Local switch keys are inactive dur Cancelled edges, missing edges and sync mismatches never select. Held-state mismatches cancel the gesture and require neutral input. Sessions expire after five seconds without an authenticated edge or sync. Disconnects and safety stops discard the point and release drag buttons; start explicitly again. Ordinary forwarding and remote scanning cannot own input simultaneously. Local scanning resumes after Remote disconnects. -Every scanning-menu page ends with a navigation row containing Close menu. Subpages retain Back, the main page retains New point, and drag confirmation retains Cancel drag. Close menu ends the current scan and clears its points, pending selection and parent-page history without issuing desktop input. Select starts a fresh scan afterward. Pages use up to three columns and four rows; native layout scales the entire menu to fit the display. Pause/resume and emergency-stop behavior are unchanged. +## Home + +Select opens a scanned Home menu, centred on the display under the pointer, when scanning starts afresh: the first Select, or the next one after Close menu, Stop scanning, Escape, or a scan that reached its pass limit without a selection. After a click, completed drag or other action ends a scan, the next Select carries on in the same mode instead, so routine clicking costs no extra steps. The "Select starts" setting can choose Last mode used, which never opens Home on Select. + +Home offers Point, Mouse, Keyboard, Apps and windows, Editing, Browser, Media, Switchify and Close menu. Every Home action works without a chosen point, so Home ignores which window is in front: Apps and windows can bring another app forward with switches, and nobody needs to focus an app before scanning. A command chosen in Home, or in a page opened from it, returns to Home. The keyboard opened from Home returns there when closed. Switchify shows the settings window and ends the scan. A point chosen after leaving Home keeps the usual foreground check. Home is also a tile in the Point action menu and Mouse Actions. + +Every scanning-menu page ends with a navigation row containing Close menu. Subpages retain Back, the main page retains New point, drag confirmation retains Cancel drag, and Home, which has nothing to go back to, ends with Close menu. Close menu ends the current scan and clears its points, pending selection and parent-page history without issuing desktop input. Select starts a fresh scan afterward. Pages use up to three columns and four rows; native layout scales the entire menu to fit the display. Pause/resume and emergency-stop behavior are unchanged. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e5f52925..9829ecb1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -155,7 +155,7 @@ impl PendingNavigation { } } -fn show_main_window(app: &AppHandle, destination: Option<&str>) { +pub(crate) fn show_main_window(app: &AppHandle, destination: Option<&str>) { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.unminimize(); diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index c0b98a02..7c78f701 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -18,6 +18,14 @@ pub enum ControlMode { Point, Mouse, } +/// What Select opens when scanning starts afresh rather than continuing after an action. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StartWith { + #[default] + Home, + LastMode, +} #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum KeyboardLayout { @@ -47,6 +55,7 @@ impl MouseRepeatStopEdge { #[serde(rename_all = "camelCase", default)] pub struct Config { pub control_mode: ControlMode, + pub start_with: StartWith, pub mouse_repeat_stop_edge: MouseRepeatStopEdge, #[serde(deserialize_with = "crate::scan_preferences::deserialize_preferences")] pub scan_preferences: crate::scan_preferences::Preferences, @@ -75,6 +84,7 @@ impl Default for Config { fn default() -> Self { Self { control_mode: ControlMode::Point, + start_with: StartWith::Home, mouse_repeat_stop_edge: MouseRepeatStopEdge::Release, scan_preferences: Default::default(), word_prediction: true, @@ -146,6 +156,7 @@ impl Config { pub fn point(&self) -> PointSettings { PointSettings { control_mode: self.control_mode, + start_with: self.start_with, 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), @@ -173,6 +184,7 @@ impl Config { #[derive(Clone)] pub struct PointSettings { pub control_mode: ControlMode, + pub start_with: StartWith, pub mouse_repeat_stop_edge: MouseRepeatStopEdge, pub scan: crate::scan_preferences::Resolved, pub menu_scan: crate::scan_preferences::Resolved, @@ -1141,6 +1153,22 @@ mod tests { assert!(e.technique.x > -1000.0); } #[test] + fn saved_settings_without_a_start_choice_open_home_and_the_choice_round_trips() { + let config: Config = + serde_json::from_value(serde_json::json!({"controlMode":"mouse"})).unwrap(); + assert_eq!(config.start_with, StartWith::Home); + assert_eq!(config.control_mode, ControlMode::Mouse); + let config: Config = + serde_json::from_value(serde_json::json!({"startWith":"lastMode"})).unwrap(); + assert_eq!(config.start_with, StartWith::LastMode); + assert_eq!(config.point().start_with, StartWith::LastMode); + assert_eq!( + serde_json::to_value(&config).unwrap()["startWith"], + serde_json::json!("lastMode") + ); + assert!(serde_json::from_value::(serde_json::json!({"startWith":"menu"})).is_err()); + } + #[test] fn existing_flat_settings_gain_safe_auto_select_defaults() { let mut json = serde_json::json!({"mode":"grid","automatic":false,"speed":4,"gridSize":7,"blockIntervalMs":1500,"selectKey":"F1","nextKey":"F2","backKey":"F3","pauseKey":"F4"}); let config: Config = serde_json::from_value(json.clone()).unwrap(); @@ -1153,6 +1181,7 @@ mod tests { json["autoSelectDelayMs"] = serde_json::json!(1000); json["scannerColor"] = serde_json::json!("blue"); json["controlMode"] = serde_json::json!("point"); + json["startWith"] = serde_json::json!("home"); json["mouseRepeatStopEdge"] = serde_json::json!("release"); json["wordPrediction"] = serde_json::json!(true); json["keyboardLayout"] = serde_json::json!("qwerty"); diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 41cc86c0..1b35f417 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -107,6 +107,9 @@ impl Adapter for PointScan { config.control_mode = next; true } + fn carry_over(previous: &Workflow, next: &mut Workflow) { + next.resume_from(previous); + } fn keep_runtime_config(next: &mut Config, current: &Config) { next.control_mode = current.control_mode; // Layout changes can also come from the scanned keyboard. Settings @@ -187,6 +190,12 @@ impl Adapter for PointScan { crate::prediction::close(); crate::scan_executor::activate(request) } + Request::OpenSwitchify => { + crate::prediction::close(); + crate::scan_executor::activate(request)?; + crate::show_main_window(app, None); + return Ok(None); + } Request::MouseDrag => { let (position, _) = display_navigation::displays(app).map_err(|e| e.message)?; return crate::scan_executor::toggle_mouse_drag(( diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index df34eba8..212c76e9 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -14,6 +14,8 @@ pub enum Request { }, PredictionRetry, OpenKeyboard, + /// Shows the Switchify window. Scanning has ended. + OpenSwitchify, OpenMouse, OpenPoint, Keyboard(crate::scan_keyboard::Stroke), @@ -122,6 +124,11 @@ pub struct Workflow { /// Where the open panel is drawn while it avoids the pointer. moved: Option, return_to_mouse: bool, + /// The keyboard was opened from Home and returns there when closed. + return_to_home: bool, + /// The last scan ended by performing an action, so Select continues in + /// the same mode instead of opening Home. + resume: bool, mouse_actions_open: bool, move_repeat: Option, scroll_repeat: Option, @@ -164,6 +171,8 @@ impl Workflow { pointer: None, moved: None, return_to_mouse: false, + return_to_home: false, + resume: false, mouse_actions_open: false, move_repeat: None, scroll_repeat: None, @@ -266,6 +275,31 @@ impl Workflow { pub fn control_mode(&self) -> crate::point_scan::ControlMode { self.point.config.control_mode } + /// Carries over whether the scan that came before ended with an action. + pub fn resume_from(&mut self, previous: &Self) { + self.resume = previous.resume; + } + /// Home, or a menu opened from it, is showing. Nothing in it acts at a point. + pub fn home_open(&self) -> bool { + self.stage == Stage::Menu + && (self.menu.kind == Kind::Home + || self + .parent_menu + .first() + .is_some_and(|menu| menu.kind == Kind::Home)) + } + fn open_home(&mut self) { + self.mouse_actions_open = false; + self.return_to_home = false; + self.parent_menu.clear(); + self.pending = None; + self.error = None; + self.elapsed = 0; + // Home acts on whatever window is in front, never on a chosen point. + self.source = (0, 0); + self.destination = (0, 0); + self.open(Kind::Home); + } pub fn set_mouse_area(&mut self, area: Rect, screen: Rect, scale: f64, displays: usize) { if self.point.screen != screen && matches!(self.stage, Stage::MouseMoving | Stage::MouseScrolling) @@ -386,6 +420,7 @@ impl Workflow { self.new_point(); } else { self.stage = Stage::Idle; + self.resume = true; } } fn new_point(&mut self) { @@ -399,8 +434,9 @@ impl Workflow { } /// A window may come forward or go as a point scan begins. Until a /// switch is used in the scan, no point depends on which is in front. + /// Home brings windows forward itself and acts only on the one in front. pub fn follows_foreground(&self) -> bool { - self.following && self.stage == Stage::Point + (self.following && self.stage == Stage::Point) || self.home_open() } fn restore_actions(&mut self) { if let Some(menu) = self.parent_menu.pop() { @@ -415,6 +451,7 @@ impl Workflow { self.point.config.control_mode = crate::point_scan::ControlMode::Mouse; self.stage = Stage::Mouse; self.mouse_actions_open = false; + self.return_to_home = false; self.move_repeat = None; self.scroll_repeat = None; self.mouse = self.new_mouse(); @@ -534,13 +571,32 @@ impl Workflow { if self.return_to_mouse { self.return_to_mouse = false; self.return_to_mouse_panel(); + } else if self.return_to_home { + self.open_home(); } else { - self.start(); + self.resume_mode(); + } + } + /// Starts the mode used last. + fn resume_mode(&mut self) { + let mode = self.point.config.control_mode; + self.reset(); + if mode == crate::point_scan::ControlMode::Mouse { + let _ = self.open_mouse(); + } else { + self.begin_point(); } } fn selected(&mut self, item: Item) -> Option { match item { + Item::Home => self.open_home(), + Item::PointScan => return self.open_point(), + Item::Switchify => { + self.reset(); + return Some(Request::OpenSwitchify); + } Item::Keyboard => { + self.return_to_home = self.home_open(); self.return_to_mouse = self.mouse_open(); if self.return_to_mouse { self.mouse.dragging = false; @@ -586,6 +642,10 @@ impl Workflow { self.menu.continue_after_selection(); } else if self.mouse_actions_open { self.return_to_mouse_panel(); + } else if self.home_open() { + // Stay in Home so the user can go on, for example to + // bring another app forward and then choose a mode. + self.open_home(); } else { self.used(); } @@ -690,10 +750,16 @@ impl Technique for Workflow { let _ = message; return; } + // A failure in Home, or in a keyboard opened from it, returns to Home. + let home = self.home_open() || self.return_to_home; + self.return_to_home = false; self.pending = None; self.following = false; self.stage = Stage::Menu; - self.menu = Menu::configured(Kind::Actions, self.point.config.menu_scan); + self.menu = Menu::configured( + if home { Kind::Home } else { Kind::Actions }, + self.point.config.menu_scan, + ); self.parent_menu.clear(); self.menu.suspend(); self.error = Some(message); @@ -708,12 +774,11 @@ impl Technique for Workflow { } } fn start(&mut self) { - let mode = self.point.config.control_mode; - self.reset(); - if mode == crate::point_scan::ControlMode::Mouse { - let _ = self.open_mouse(); + if self.resume || self.point.config.start_with == crate::point_scan::StartWith::LastMode { + self.resume_mode(); } else { - self.begin_point(); + self.reset(); + self.open_home(); } } fn reset(&mut self) { @@ -721,6 +786,8 @@ impl Technique for Workflow { self.keyboard_layout_pending = false; self.mouse = self.new_mouse(); self.return_to_mouse = false; + self.return_to_home = false; + self.resume = false; self.mouse_actions_open = false; self.move_repeat = None; self.scroll_repeat = None; @@ -870,6 +937,8 @@ impl Technique for Workflow { if self.point.exhausted() { self.point.start(); if self.stage == Stage::Point { + // Nothing was chosen, so Select starts afresh. + self.resume = false; self.stage = Stage::Idle; } else { self.restore_actions(); @@ -1013,6 +1082,9 @@ impl Technique for Workflow { } } Stage::Point | Stage::Destination => self.point.frame(), + Stage::Menu if self.home_open() => self + .menu + .centered_frame(self.point.screen, self.point.units_per_logical_pixel), Stage::Menu => self.menu.frame( if self.menu.kind == Kind::ConfirmDrag { self.destination @@ -1045,7 +1117,7 @@ impl Technique for Workflow { }); } } - if matches!(self.stage, Stage::Menu | Stage::Destination) { + if matches!(self.stage, Stage::Menu | Stage::Destination) && !self.home_open() { let s = self.point.units_per_logical_pixel; frame.strips.extend(outline( Rect { @@ -1088,6 +1160,201 @@ mod tests { use super::*; use crate::{point_scan::Config, scanning::Session}; + /// Select starts the last mode, as these scans were written for. + fn last_mode() -> Config { + Config { + start_with: crate::point_scan::StartWith::LastMode, + ..Config::default() + } + } + fn home_session() -> Session { + Session::new( + Workflow::new( + Config { + block_interval_ms: 250, + ..Config::default() + } + .point(), + Rect { + x: -1000.0, + y: 20.0, + width: 1000.0, + height: 800.0, + }, + 1.0, + ) + .unwrap(), + false, + ) + } + /// The next scan, made the way the runtime makes it when Select starts one. + fn next_session(previous: &Session) -> Session { + let mut next = home_session(); + next.technique.resume_from(&previous.technique); + next + } + fn choose_in(s: &mut Session, row: usize, column: usize) -> Option { + for _ in 0..row { + s.action(Action::Next); + } + assert_eq!(s.action(Action::Select), None); + for _ in 0..column { + s.action(Action::Next); + } + s.action(Action::Select) + } + + #[test] + fn select_opens_home_until_an_action_lets_the_next_select_continue() { + let mut s = home_session(); + assert_eq!(s.action(Action::Select), None); + assert!(s.technique.home_open()); + assert_eq!(s.technique.menu.kind, Kind::Home); + assert!(s.technique.follows_foreground()); + let frame = s.technique.frame(); + let panel = frame.tiles[0].rect; + assert!((panel.x + panel.width / 2.0 + 500.0).abs() < 0.001); + assert!((panel.y + panel.height / 2.0 - 420.0).abs() < 0.001); + // No chosen point is marked, because Home has none. + assert!(frame.strips.is_empty()); + + assert_eq!(choose_in(&mut s, 0, 0), Some(Request::OpenPoint)); + assert!(matches!(s.technique.phase(), Phase::Point(_))); + assert!(s.technique.follows_foreground()); + s.action(Action::Select); + s.action(Action::Select); + assert_eq!(s.technique.menu.kind, Kind::Actions); + assert!(!s.technique.home_open()); + assert!(matches!( + choose_in(&mut s, 0, 0), + Some(Request::Click { right: false, .. }) + )); + assert!(!s.active()); + + // After an action, Select carries on with point scanning. + let mut s = next_session(&s); + s.action(Action::Select); + assert!(matches!(s.technique.phase(), Phase::Point(_))); + // Stopping means starting afresh from Home. + s.action(Action::Stop); + let mut s = next_session(&s); + s.action(Action::Select); + assert!(s.technique.home_open()); + } + + #[test] + fn a_point_scan_that_times_out_starts_afresh_from_home() { + let mut s = home_session(); + s.action(Action::Select); + choose_in(&mut s, 0, 0); + s.technique.resume = true; + for _ in 0..100_000 { + if !s.active() { + break; + } + s.tick(100, false); + assert_eq!(s.take_selection(), None); + } + assert!(!s.active()); + let mut s = next_session(&s); + s.action(Action::Select); + assert!(s.technique.home_open()); + } + + #[test] + fn home_commands_return_to_home_and_close_or_switchify_end_scanning() { + use crate::scan_menu::Command; + let mut s = home_session(); + s.action(Action::Select); + assert_eq!(choose_in(&mut s, 1, 0), None); + assert_eq!(s.technique.menu.kind, Kind::Windows); + assert!(s.technique.home_open()); + assert!(s.technique.follows_foreground()); + assert_eq!( + choose_in(&mut s, 0, 0), + Some(Request::Command { + command: Command::SwitchNext, + point: (0, 0), + }) + ); + assert_eq!(s.technique.menu.kind, Kind::Home); + assert!(s.technique.parent_menu.is_empty()); + assert!(s.active()); + + assert_eq!(choose_in(&mut s, 2, 2), None); + assert!(!s.active()); + + let mut s = next_session(&s); + s.action(Action::Select); + assert_eq!(choose_in(&mut s, 2, 1), Some(Request::OpenSwitchify)); + assert!(!s.active()); + + let mut s = next_session(&s); + s.action(Action::Select); + assert!(s.technique.home_open()); + // Back from a Home group returns to Home. + choose_in(&mut s, 1, 1); + assert_eq!(s.technique.menu.kind, Kind::Editing); + assert_eq!(choose_in(&mut s, 3, 0), None); + assert_eq!(s.technique.menu.kind, Kind::Home); + assert!(s.technique.home_open()); + } + + #[test] + fn keyboard_from_home_returns_home_and_failures_stay_in_home() { + let mut s = home_session(); + s.action(Action::Select); + assert_eq!(choose_in(&mut s, 0, 2), Some(Request::OpenKeyboard)); + assert!(!s.technique.home_open()); + s.technique.execution_succeeded(); + assert_eq!( + s.technique.phase(), + Phase::Workflow(WorkflowPhase::Keyboard) + ); + s.technique.keyboard_closed(); + assert!(s.technique.home_open()); + assert!(!s.technique.return_to_home); + + assert_eq!(choose_in(&mut s, 0, 2), Some(Request::OpenKeyboard)); + s.execution_failed("Keyboard unavailable.".into()); + assert_eq!(s.technique.menu.kind, Kind::Home); + assert!(s.technique.error.is_some()); + assert!(!s.technique.return_to_home); + + // A failed Home command is reported in Home too. + s.action(Action::Select); + choose_in(&mut s, 1, 0); + choose_in(&mut s, 0, 0); + s.execution_failed("Action is unavailable on this platform.".into()); + assert_eq!(s.technique.menu.kind, Kind::Home); + } + + #[test] + fn home_opens_from_the_point_and_mouse_action_menus() { + let mut s = home_session(); + s.action(Action::Select); + choose_in(&mut s, 0, 0); + s.action(Action::Select); + s.action(Action::Select); + assert_eq!(choose_in(&mut s, 2, 2), None); + assert!(s.technique.home_open()); + assert_eq!(s.technique.source, (0, 0)); + assert!(s.technique.follows_foreground()); + + assert_eq!(choose_in(&mut s, 0, 1), Some(Request::OpenMouse)); + s.technique.set_pointer(Some((100.0, 200.0))); + s.technique.mouse_key(crate::scan_mouse::Key::Actions); + assert_eq!(s.technique.menu.kind, Kind::MouseActions); + assert_eq!(choose_in(&mut s, 2, 2), None); + assert!(s.technique.home_open()); + assert!(!s.technique.mouse_actions_open); + assert_eq!(s.technique.mouse_feedback(), None); + assert_eq!( + s.technique.control_mode(), + crate::point_scan::ControlMode::Mouse + ); + } + #[test] fn mouse_actions_use_the_current_pointer_and_return_to_mouse() { use crate::scan_menu::Command; @@ -1098,7 +1365,7 @@ mod tests { width: 1280.0, height: 720.0, }; - let mut workflow = Workflow::new(Config::default().point(), screen, 1.0).unwrap(); + let mut workflow = Workflow::new(last_mode().point(), screen, 1.0).unwrap(); workflow.handle(Action::OpenMouse); workflow.set_pointer(Some((321.4, 246.6))); assert_eq!(workflow.mouse_key(Key::Actions), None); @@ -1165,7 +1432,7 @@ mod tests { width: 1280.0, height: 720.0, }; - let mut workflow = Workflow::new(Config::default().point(), screen, 1.0).unwrap(); + let mut workflow = Workflow::new(last_mode().point(), screen, 1.0).unwrap(); workflow.handle(Action::OpenMouse); workflow.set_pointer(Some((100.0, 200.0))); assert_eq!(workflow.mouse_key(Key::Drag), Some(Request::MouseDrag)); @@ -1207,7 +1474,7 @@ mod tests { height: 720.0, }; let mut session = Session::new( - Workflow::new(Config::default().point(), screen, 1.0).unwrap(), + Workflow::new(last_mode().point(), screen, 1.0).unwrap(), true, ); session.action(Action::OpenMouse); @@ -1244,7 +1511,7 @@ mod tests { }; let config = Config { control_mode: crate::point_scan::ControlMode::Mouse, - ..Config::default() + ..last_mode() }; let mut session = Session::new(Workflow::new(config.point(), screen, 1.0).unwrap(), true); assert_eq!(session.action(Action::Select), None); @@ -1279,7 +1546,7 @@ mod tests { #[test] fn stopped_mouse_motion_waits_at_the_arrow_where_the_user_chose_to() { use crate::scan_preferences::{NextScan, StartFrom}; - let mut config = Config::default(); + let mut config = last_mode(); config.scan_preferences.mouse.next_scan = Some(NextScan::Wait); config.scan_preferences.mouse.start_from = Some(StartFrom::Selection); let screen = Rect { @@ -1326,7 +1593,7 @@ mod tests { block_interval_ms: 250, auto_select_enabled: auto_select, auto_select_delay_ms: 500, - ..Default::default() + ..last_mode() }; config.scan_preferences.point.next_scan = Some(next_scan); // The menu's own choice does not decide this. @@ -1400,7 +1667,7 @@ mod tests { let mut config = Config { automatic: false, block_interval_ms: 250, - ..Default::default() + ..last_mode() }; config.scan_preferences.point.next_scan = Some(next_scan); let screen = Rect { @@ -1462,7 +1729,7 @@ mod tests { let mut config = Config { automatic: true, block_interval_ms: 250, - ..Default::default() + ..last_mode() }; config.scan_preferences.menu.next_scan = Some(next_scan); config.scan_preferences.menu.start_from = Some(start_from); @@ -1505,7 +1772,7 @@ mod tests { height: 720.0, }; let mut session = Session::new( - Workflow::new(Config::default().point(), screen, 1.0).unwrap(), + Workflow::new(last_mode().point(), screen, 1.0).unwrap(), true, ); assert_eq!(session.action(Action::OpenMouse), Some(Request::OpenMouse)); @@ -1569,7 +1836,7 @@ mod tests { width: 1280.0, height: 720.0, }; - let mut workflow = Workflow::new(Config::default().point(), screen, 1.0).unwrap(); + let mut workflow = Workflow::new(last_mode().point(), screen, 1.0).unwrap(); workflow.open_mouse(); workflow.set_mouse_settings(50, 1000, 250, 250, false); assert_eq!( @@ -1612,7 +1879,7 @@ mod tests { (0, 1), (1, 1), ] { - let mut workflow = Workflow::new(Config::default().point(), screen, 1.0).unwrap(); + let mut workflow = Workflow::new(last_mode().point(), screen, 1.0).unwrap(); workflow.open_mouse(); workflow.set_mouse_settings(speed, 1000, 250, 250, false); assert_eq!( @@ -1772,7 +2039,7 @@ mod tests { width: 1280.0, height: 720.0, }; - let mut workflow = Workflow::new(Config::default().point(), screen, 1.0).unwrap(); + let mut workflow = Workflow::new(last_mode().point(), screen, 1.0).unwrap(); workflow.open_mouse(); let dock = Dock { column: 2, row: 1 }; assert_eq!(workflow.mouse_key(Key::Dock), None); @@ -1808,14 +2075,14 @@ mod tests { let bottom = Dock::default().rect(screen, 1.0); let top = Dock { column: 1, row: 0 }.rect(screen, 1.0); let over_bottom = Some((640.0, 700.0)); - let mut workflow = Workflow::new(Config::default().point(), screen, 1.0).unwrap(); + let mut workflow = Workflow::new(last_mode().point(), screen, 1.0).unwrap(); workflow.open_mouse(); workflow.set_pointer(over_bottom); assert_eq!(background(&workflow), bottom, "off by default"); let config = Config { panel_avoids_pointer: true, - ..Config::default() + ..last_mode() }; workflow.apply_config(config.point(), false); assert!(workflow.panel_avoids_pointer()); @@ -1865,7 +2132,7 @@ mod tests { width: 1280.0, height: 720.0, }; - let mut workflow = Workflow::new(Config::default().point(), screen, 1.0).unwrap(); + let mut workflow = Workflow::new(last_mode().point(), screen, 1.0).unwrap(); workflow.open_mouse(); assert_eq!( workflow.mouse_key(crate::scan_mouse::Key::Drag), @@ -1928,7 +2195,7 @@ mod tests { Config { automatic, block_interval_ms: 250, - ..Config::default() + ..last_mode() } .point(), Rect { @@ -1989,7 +2256,7 @@ mod tests { let config = Config { control_mode: mode, keyboard_layout: layout, - ..Config::default() + ..last_mode() }; let mut workflow = Workflow::new(config.point(), screen, 1.0).unwrap(); if mode == ControlMode::Mouse { @@ -2038,7 +2305,7 @@ mod tests { ); assert!(!s.technique.follows_foreground()); // A scan restarted by a change of mode has nothing chosen in it. - s.technique.apply_config(Config::default().point(), true); + s.technique.apply_config(last_mode().point(), true); assert!(s.technique.follows_foreground()); } #[test] @@ -2149,7 +2416,7 @@ mod tests { let config = Config { keyboard_wait_after_typing: true, block_interval_ms: 250, - ..Default::default() + ..last_mode() }; s.technique.apply_config(config.point(), false); s.action(Action::Select); @@ -2252,7 +2519,8 @@ mod tests { .iter() .filter(|tile| !tile.is_panel_background() && tile.rect.x == first.rect.x) .count(); - assert_eq!(choose(&mut s, rows - 1, 1), None, "{kind:?}"); + let close = if kind == Kind::Home { 2 } else { 1 }; + assert_eq!(choose(&mut s, rows - 1, close), None, "{kind:?}"); assert!(!s.active()); assert!(s.technique.stage == Stage::Idle); assert!(s.technique.parent_menu.is_empty()); @@ -2278,7 +2546,7 @@ mod tests { mode, automatic, auto_select_enabled: true, - ..Default::default() + ..last_mode() }; let workflow = Workflow::new( config.point(), @@ -2425,7 +2693,7 @@ mod tests { #[test] fn nested_back_restores_parent_and_pause_resumes_without_selection() { let mut w = Workflow::new( - crate::point_scan::Config::default().point(), + last_mode().point(), Rect { x: 0., y: 0., @@ -2456,7 +2724,7 @@ mod tests { #[test] fn command_failure_preserves_point_and_requires_acknowledgement() { let mut w = Workflow::new( - crate::point_scan::Config::default().point(), + last_mode().point(), Rect { x: 0., y: 0., @@ -2505,7 +2773,7 @@ mod tests { for (column, right, count) in [(0, false, 1), (1, true, 1), (2, false, 2)] { let mut s = session(false); open(&mut s); - assert_eq!(s.frame().tiles.len(), 11); + assert_eq!(s.frame().tiles.len(), 12); assert_eq!( choose(&mut s, 0, column), Some(Request::Click { @@ -2667,7 +2935,7 @@ mod tests { use crate::scan_preferences::Area; let mut config = crate::point_scan::Config { automatic: false, - ..Default::default() + ..last_mode() }; config.scan_preferences.menu.automatic = Some(true); config.scan_preferences.menu.interval_ms = Some(250); diff --git a/src-tauri/src/scan_executor.rs b/src-tauri/src/scan_executor.rs index 3ed27dbb..50153841 100644 --- a/src-tauri/src/scan_executor.rs +++ b/src-tauri/src/scan_executor.rs @@ -18,7 +18,10 @@ pub fn execute( Request::Prediction { .. } | Request::PredictionRetry => { Err("Prediction requires the scan controller.".into()) } - Request::OpenKeyboard | Request::OpenMouse | Request::OpenPoint => input.release_all(), + Request::OpenKeyboard + | Request::OpenMouse + | Request::OpenPoint + | Request::OpenSwitchify => input.release_all(), Request::MouseMove { dx, dy } => input.move_pointer_pixels(dx, dy).map(|_| ()), Request::MouseMoveAbsolute { x, y } => input.move_pointer_pixels_absolute(x, y).map(|_| ()), Request::MouseClick { right, count } => { @@ -781,6 +784,7 @@ mod tests { let workflow = Workflow::new( Config { block_interval_ms: 250, + start_with: crate::point_scan::StartWith::LastMode, ..Config::default() } .point(), diff --git a/src-tauri/src/scan_menu.rs b/src-tauri/src/scan_menu.rs index b0c3b130..b57c6095 100644 --- a/src-tauri/src/scan_menu.rs +++ b/src-tauri/src/scan_menu.rs @@ -5,6 +5,9 @@ use crate::{ }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Item { + Home, + PointScan, + Switchify, Keyboard, MousePanel, KeyboardKey, @@ -34,6 +37,9 @@ pub enum Item { impl Item { pub fn label(self) -> &'static str { match self { + Self::Home => "Home", + Self::PointScan => "Point", + Self::Switchify => "Switchify", Self::Keyboard => "Keyboard", Self::MousePanel => "Mouse", Self::KeyboardKey => "Key", @@ -65,6 +71,7 @@ impl Item { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Kind { + Home, More, MouseActions, MouseScanning, @@ -82,7 +89,8 @@ pub enum Kind { ConfirmDrag, } #[cfg(test)] -pub(crate) const ALL_MENU_KINDS: [Kind; 15] = [ +pub(crate) const ALL_MENU_KINDS: [Kind; 16] = [ + Kind::Home, Kind::Actions, Kind::More, Kind::MouseActions, @@ -150,7 +158,27 @@ impl Menu { self.scan.handle(action) } + /// Draws the menu beside the point it acts on. pub fn frame(&self, point: (i32, i32), screen: Rect, units: f64) -> Frame { + self.frame_placed(screen, units, |width, height, gap| { + place(point, screen, width, height, gap) + }) + } + /// Draws a menu that acts on no point in the middle of the screen. + pub fn centered_frame(&self, screen: Rect, units: f64) -> Frame { + self.frame_placed(screen, units, |width, height, _| Rect { + x: screen.x + (screen.width - width) / 2.0, + y: screen.y + (screen.height - height) / 2.0, + width, + height, + }) + } + fn frame_placed( + &self, + screen: Rect, + units: f64, + placed: impl FnOnce(f64, f64, f64) -> Rect, + ) -> Frame { let columns = self.rows.iter().map(Vec::len).max().unwrap_or(1) as f64; let logical_width = 16.0 + columns * 180.0; let logical_height = 56.0 + 180.0 * self.rows.len() as f64; @@ -159,7 +187,7 @@ impl Menu { .min(screen.height / logical_height); let width = logical_width * scale; let height = logical_height * scale; - let panel = place(point, screen, width, height, 20.0 * scale); + let panel = placed(width, height, 20.0 * scale); let mut frame = Frame::default(); frame.tiles.push(FrameTile::panel_background( panel, @@ -198,6 +226,7 @@ impl Menu { "Back to rows" } else { match self.kind { + Kind::Home => "Choose what to control", Kind::Actions => "Choose an action", Kind::Scroll => "Scroll at selected point", Kind::ConfirmDrag => "Confirm drag", @@ -426,6 +455,7 @@ impl Setting { impl Kind { pub fn label(self) -> &'static str { match self { + Self::Home => "Home", Self::Actions => "Choose an action", Self::Scroll => "Scroll", Self::ConfirmDrag => "Confirm drag", @@ -447,11 +477,23 @@ impl Kind { use self::Command as C; use Item::*; let items = match self { + // Only actions that need no point, so nothing depends on which window is in front. + Self::Home => { + return vec![ + vec![PointScan, MousePanel, Keyboard], + vec![ + Group(Self::Windows), + Group(Self::Editing), + Group(Self::Browser), + ], + vec![Group(Self::Media), Switchify, Cancel], + ] + } Self::Actions => { return vec![ vec![LeftClick, RightClick, DoubleClick], vec![Scroll, Drag, More], - vec![Keyboard, MousePanel], + vec![Keyboard, MousePanel, Home], vec![NewPoint, Cancel], ] } @@ -480,6 +522,7 @@ impl Kind { Group(Self::Displays), Group(Self::MouseScanning), Keyboard, + Home, Back, ], Self::MouseScanning => vec![ @@ -617,11 +660,18 @@ mod tests { for kind in ALL_MENU_KINDS { let rows = kind.rows(); let back = match kind { - Kind::Actions => Item::NewPoint, - Kind::ConfirmDrag => Item::CancelDrag, - _ => Item::Back, + // Home is the top of the menus and has nothing to go back to. + Kind::Home => None, + Kind::Actions => Some(Item::NewPoint), + Kind::ConfirmDrag => Some(Item::CancelDrag), + _ => Some(Item::Back), }; - assert_eq!(rows.last().unwrap(), &[back, Item::Cancel], "{kind:?}"); + if let Some(back) = back { + assert_eq!(rows.last().unwrap(), &[back, Item::Cancel], "{kind:?}"); + } else { + assert_eq!(rows.last().unwrap().last(), Some(&Item::Cancel)); + assert!(!rows.iter().flatten().any(|item| *item == Item::Back)); + } assert_eq!( rows.iter() .flatten() @@ -642,7 +692,9 @@ mod tests { menu.advance(250); } assert_eq!(menu.handle(Action::Select), None); - menu.advance(250); + for _ in 1..kind.rows().last().unwrap().len() { + menu.advance(250); + } assert_eq!(menu.handle(Action::Select), Some(Item::Cancel), "{kind:?}"); } } @@ -685,7 +737,7 @@ mod tests { } #[test] fn every_submenu_is_reachable_and_fits_three_columns_and_four_rows() { - let mut pending = vec![Kind::Actions]; + let mut pending = vec![Kind::Actions, Kind::Home]; let mut visited = vec![]; let mut commands = vec![]; while let Some(kind) = pending.pop() { @@ -696,7 +748,7 @@ mod tests { let rows = kind.rows(); assert!(rows.len() <= 4); assert!(rows.iter().all(|r| r.len() <= 3)); - if kind != Kind::Actions { + if !matches!(kind, Kind::Actions | Kind::Home) { assert!(rows.iter().flatten().any(|i| *i == Item::Back)); } for item in rows.into_iter().flatten() { @@ -715,6 +767,49 @@ mod tests { assert!(visited.contains(&Kind::Zoom)); } #[test] + fn home_offers_modes_and_point_free_actions_in_the_middle_of_the_screen() { + let home = Kind::Home.rows().into_iter().flatten().collect::>(); + assert_eq!( + home, + [ + Item::PointScan, + Item::MousePanel, + Item::Keyboard, + Item::Group(Kind::Windows), + Item::Group(Kind::Editing), + Item::Group(Kind::Browser), + Item::Group(Kind::Media), + Item::Switchify, + Item::Cancel, + ] + ); + // Mouse and Displays act at a point or move it; Scanning settings restart the point scan. + let mut pending = home.clone(); + while let Some(item) = pending.pop() { + if let Item::Group(kind) = item { + assert!(!matches!( + kind, + Kind::Mouse | Kind::Displays | Kind::Scanning | Kind::Scroll + )); + pending.extend(kind.rows().into_iter().flatten()); + } + } + for kind in [Kind::Actions, Kind::MouseActions] { + assert!(kind.rows().iter().flatten().any(|item| *item == Item::Home)); + } + let screen = Rect { + x: -1280.0, + y: -100.0, + width: 1280.0, + height: 720.0, + }; + for kind in [Kind::Home, Kind::Windows] { + let panel = Menu::new(kind, 250).centered_frame(screen, 1.5).tiles[0].rect; + assert!((panel.x + panel.width / 2.0 + 640.0).abs() < 0.001); + assert!((panel.y + panel.height / 2.0 - 260.0).abs() < 0.001); + } + } + #[test] fn settings_step_tenths_and_clamp_at_limits() { let mut c = crate::point_scan::Config { block_interval_ms: 650, diff --git a/src-tauri/src/scan_tile.rs b/src-tauri/src/scan_tile.rs index 53b18404..914aab3a 100644 --- a/src-tauri/src/scan_tile.rs +++ b/src-tauri/src/scan_tile.rs @@ -181,8 +181,8 @@ pub fn bitmap(tile: &FrameTile) -> Result { lines(&[(62., 46.), (106., 90.)]); lines(&[(106., 46.), (62., 90.)]); } - Keyboard | MousePanel | More | Group(_) | Command(_) | Setting(_) | Display(_) | Pause - | Reverse => { + Home | PointScan | Switchify | Keyboard | MousePanel | More | Group(_) | Command(_) + | Setting(_) | Display(_) | Pause | Reverse => { artwork(&mut path, tile.icon); } DragHere => { @@ -358,6 +358,22 @@ fn return_arrow(path: &mut PathBuilder, forward: bool) { fn artwork(path: &mut PathBuilder, item: Item) { use crate::scan_menu::{Command as C, Kind as K, Setting as S}; match item { + Item::Home => { + line(path, &[(48., 68.), (84., 36.), (120., 68.)]); + line(path, &[(58., 60.), (58., 100.), (110., 100.), (110., 60.)]); + rect(path, 76., 76., 16., 24.); + } + Item::PointScan => { + line(path, &[(84., 34.), (84., 102.)]); + line(path, &[(50., 68.), (118., 68.)]); + rect(path, 72., 56., 24., 24.); + } + Item::Switchify => { + for (y, x) in [(46., 70.), (68., 100.), (90., 62.)] { + line(path, &[(48., y), (120., y)]); + path.push_circle(x, y, 7.); + } + } Item::Keyboard => { rect(path, 46., 44., 76., 48.); for y in [55., 67.] { @@ -428,7 +444,7 @@ fn artwork(path: &mut PathBuilder, item: Item) { path.push_circle(78., 61., 24.); line(path, &[(95., 79.), (117., 101.)]); } - K::More | K::Actions | K::MouseActions | K::Scroll | K::ConfirmDrag => { + K::Home | K::More | K::Actions | K::MouseActions | K::Scroll | K::ConfirmDrag => { unreachable!("Not a grouped menu tile") } }, @@ -750,6 +766,9 @@ mod tests { use Item::*; let mut images = Vec::new(); for icon in [ + Home, + PointScan, + Switchify, LeftClick, RightClick, DoubleClick, diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 2510a434..eec3b56c 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -22,6 +22,8 @@ pub trait Adapter: Send + Sync + 'static { fn sync_mode(_config: &mut Self::Config, _technique: &Self::Technique) -> bool { false } + /// Carries what a finished scan leaves behind into the scan Select starts next. + fn carry_over(_previous: &Self::Technique, _next: &mut Self::Technique) {} /// Carries state the runtime owns, such as the saved mode, into settings saved by the UI. fn keep_runtime_config(_next: &mut Self::Config, _current: &Self::Config) {} fn cursor_action_feedback( @@ -511,7 +513,10 @@ fn switch(app: &AppHandle, action: Action, input_generation: u64, re Action::OpenKeyboard | Action::OpenPoint | Action::OpenMouse ) || (d.engine.as_ref().is_none_or(|e| !e.active()) && action == Action::Select) { - let (engine, display) = A::create(app, d.config.clone())?; + let (mut engine, display) = A::create(app, d.config.clone())?; + if let Some(previous) = d.engine.as_ref() { + A::carry_over(&previous.technique, &mut engine); + } d.engine = Some(Session::new(engine, A::switches(&d.config).automatic)); d.display = Some(display); A::prepare(app)?; diff --git a/src/App.test.tsx b/src/App.test.tsx index 2214e095..4fb9b1c9 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -65,7 +65,7 @@ describe("Switchify PC shell", () => { render(); await screen.findByRole("heading", { name: "Ready" }); expect(screen.getByText("1 saved · Select assigned")).toBeInTheDocument(); - expect(screen.getByText("Select starts Point scanning · Manual")).toBeInTheDocument(); + expect(screen.getByText("Select opens Home · Manual")).toBeInTheDocument(); scanning.state = { ...scanning.state!, paused: true }; act(() => receive?.({ ...structuredClone(browserState), bluetooth: "connected" })); expect(screen.getByText("Local scanning is paused while a mobile device is connected.")).toBeInTheDocument(); diff --git a/src/App.tsx b/src/App.tsx index 6fe7d14c..0b0215c4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,7 +14,7 @@ import { MouseSettingsView } from "./settings/MouseSettingsView"; import { updateDescription, updateInFlight, updateLiveness, updateProgress, updateStanding, type UpdateAction } from "./settings/UpdatesSection"; import { TabPanel, Tabs } from "./Tabs"; import { useSwitches, type SwitchController } from "./scanning/useSwitches"; -import { useScanning, type ScanningController } from "./scanning/useScanning"; +import { lastModeName, selectOpensHome, useScanning, type ScanningController } from "./scanning/useScanning"; import { areaOptions } from "./scanning/preferences"; import { homeStatus } from "./homeStatus"; import { interfaceModeAdvice, reservedKeyAdvice } from "./help/keyAvailability"; @@ -81,7 +81,7 @@ function HomeView({ state, switches, scanning, navigate, onDisconnect, onAccessi

Input access

{state.accessibility === "required" && }

Saved switches

{saved.length} saved · {hasSelect ? "Select assigned" : "Select action needed"}

-

Scanning

Select starts {scanning.config.controlMode === "mouse" ? "Mouse scanning" : "Point scanning"} · {areaOptions(scanning.config, scanning.config.controlMode).automatic ? "Automatic" : "Manual"}

+

Scanning

{selectOpensHome(scanning.config) ? "Select opens Home" : `Select starts ${lastModeName(scanning.config)}`} · {areaOptions(scanning.config, scanning.config.controlMode).automatic ? "Automatic" : "Manual"}

Mobile connection (optional)

{mobileConnected ? state.connectedDeviceName ?? bluetoothLabels.connected : bluetoothLabels[state.bluetooth]}

{mobileConnected ? : }
; @@ -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. 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 === 2 &&

Use your switches

Press and release Select to open Home. Choose Point to pick a place on the screen and an action, Mouse to move a visible pointer ring with mouse controls, or Keyboard to type. Apps and windows brings another app forward, so you never need to focus an app first. After an action, Select carries on in the same mode.

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
} diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index 032057d9..7fd422ee 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -237,6 +237,22 @@ it("saves scanner colour and updates the sample", async () => { })); }); +it("saves what Select starts and explains Home without asking to focus an app", async () => { + render(); + await screen.findByText(initial.message); + const group = screen.getByRole("group", { name: "Select starts" }); + expect(group).toBeVisible(); + expect(screen.getByRole("button", { name: "Home menu" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText("Home", { selector: "strong" })).toBeInTheDocument(); + expect(screen.queryByText(/Focus the application/)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Last mode used" })); + await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { + config: { ...defaultPointScanConfig, startWith: "lastMode" }, + })); + expect(screen.getByRole("button", { name: "Last mode used" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText("Select starts here")).toBeInTheDocument(); +}); + it("retains a failed colour selection for retry", async () => { render(); await screen.findByText(initial.message); diff --git a/src/help/demonstrations.ts b/src/help/demonstrations.ts index 31244cc7..63580725 100644 --- a/src/help/demonstrations.ts +++ b/src/help/demonstrations.ts @@ -11,8 +11,8 @@ export function demonstration(kind: DemonstrationKind, platform: DemoPlatform) { usb: { title: "Connect a USB switch", steps: ["A USB switch or keyboard interface sends a key when you press the switch.", "Connect it to a compatible USB port on your computer.", "Choose Add switch and learn its key. Connecting the cable alone does not configure Switchify."] }, learn: { title: "Learn a switch key", steps: ["Choose Add switch, then Learn key to open the capture dialog.", "Press and release your physical switch once.", "The key it sends appears in the editor. F13 is only an example; your switch may send a different key."] }, select: { title: "Assign Select", steps: ["After learning a key, open its Press and release action list.", "Choose Select so a short press and release chooses the highlighted item.", "Save the switch. Automatic scanning needs Select; manual scanning also needs Next and Previous."] }, - grid: { title: "Automatic grid then line scanning", steps: ["Focus the application you want to control. Press and release Select to start automatic scanning.", "Rows highlight in turn. Press and release Select to choose a row.", "Cells in that row highlight in turn. Press and release Select to choose a cell.", "Choose the horizontal position with Select as the vertical line moves across the cell.", "Choose the vertical position with Select as the horizontal line moves down the cell.", "With Auto select enabled, a countdown starts: wait for a left click, or press a switch to open the action menu.", "With Auto select disabled, the action menu opens immediately. Choose an action with Select. After a click, Select starts a new scan."] }, - line: { title: "Automatic line-only scanning", steps: ["Focus the application you want to control. Press and release Select to start automatic scanning.", "Choose the horizontal position with Select as the vertical line moves across the display.", "Choose the vertical position with Select as the horizontal line moves down the display.", "With Auto select enabled, wait through the countdown for a left click, or press a switch to open the action menu.", "With Auto select disabled, the action menu opens immediately. Choose an action with Select. After a click, Select starts a new scan."] }, + grid: { title: "Automatic grid then line scanning", steps: ["Press and release Select to open Home, then choose Point to start automatic scanning. Use Apps and windows in Home first if you need another app in front.", "Rows highlight in turn. Press and release Select to choose a row.", "Cells in that row highlight in turn. Press and release Select to choose a cell.", "Choose the horizontal position with Select as the vertical line moves across the cell.", "Choose the vertical position with Select as the horizontal line moves down the cell.", "With Auto select enabled, a countdown starts: wait for a left click, or press a switch to open the action menu.", "With Auto select disabled, the action menu opens immediately. Choose an action with Select. After a click, Select starts a new scan."] }, + line: { title: "Automatic line-only scanning", steps: ["Press and release Select to open Home, then choose Point to start automatic scanning. Use Apps and windows in Home first if you need another app in front.", "Choose the horizontal position with Select as the vertical line moves across the display.", "Choose the vertical position with Select as the horizontal line moves down the display.", "With Auto select enabled, wait through the countdown for a left click, or press a switch to open the action menu.", "With Auto select disabled, the action menu opens immediately. Choose an action with Select. After a click, Select starts a new scan."] }, hold: { title: "Press, hold and release", steps: ["Press your switch briefly. Movement freezes while the switch is down.", "Release it to run its Press and release action, such as Select.", "Hold the switch longer to move through its configured hold actions. Scanning movement stays frozen.", "Keep holding until the action you want is offered. The actions shown here are examples, not your assignments.", "Release to execute the offered hold action. Holding never executes the offered action by itself; the emergency hold resets scanning."] }, access: { title: "Allow Accessibility on macOS", steps: ["Choose Open Accessibility Settings in Switchify PC.", "In System Settings, open Privacy & Security, then Accessibility, and find Switchify PC.", "Enable Switchify PC, then return to the app so it can check access. This illustration does not grant permission."] }, pair: { title: "Pair a mobile device", steps: ["Open Switchify on your mobile device and choose this computer while it is advertising.", "Compare the six-digit codes in both apps. These are example codes, not a live pairing request.", "Approve on this computer only if the codes match. If they differ or you did not request pairing, reject the request.", "A successful pairing trusts the mobile device. Check the real connection status in the app."] }, diff --git a/src/homeStatus.test.ts b/src/homeStatus.test.ts index aa30e84e..f70dafcd 100644 --- a/src/homeStatus.test.ts +++ b/src/homeStatus.test.ts @@ -29,4 +29,11 @@ describe("homeStatus", () => { expect(homeStatus({ ...ready, switchesLoaded: false, hasSelect: false }).message).toBe("Loading switch control..."); expect(homeStatus({ ...ready, scanning: { ...scanning, supported: false, message: "Unsupported." }, hasSelect: false }).message).toBe("Unsupported."); }); + + it("says what Select opens instead of asking for an app to be focused", () => { + expect(homeStatus(ready).message).toBe("Press and release your Select switch to open Home, then choose what to control."); + const { startWith: _, ...saved } = defaultPointScanConfig; + expect(homeStatus({ ...ready, scanning: { ...scanning, config: saved } }).message).toContain("open Home"); + expect(homeStatus({ ...ready, scanning: { ...scanning, config: { ...defaultPointScanConfig, startWith: "lastMode", controlMode: "mouse" } } }).message).toBe("Press and release your Select switch to start Mouse scanning."); + }); }); diff --git a/src/homeStatus.ts b/src/homeStatus.ts index dd7d4ef0..d8bfc0bb 100644 --- a/src/homeStatus.ts +++ b/src/homeStatus.ts @@ -1,5 +1,5 @@ import type { AppState } from "./types"; -import type { PointScanState } from "./scanning/useScanning"; +import { lastModeName, selectOpensHome, type PointScanState } from "./scanning/useScanning"; export type HomeTone = "ready" | "attention" | "neutral"; export type HomeStatus = { title: string; tone: HomeTone; message: string }; @@ -28,6 +28,6 @@ export function homeStatus({ accessibility, bluetooth, switchesLoaded, hasSelect if (!switchesLoaded) return { title: "Switch control", tone: "neutral", message: "Loading switch control..." }; if (!hasSelect) return { title: "Finish setting up", tone: "attention", message: "Add a switch with the Select action to begin scanning." }; if (scanning.paused) return { title: "Paused", tone: "neutral", message: "Scanning is paused. Use your Pause / resume switch to continue." }; - if (scanning.enabled) return { title: "Ready", tone: "ready", message: "Focus the application you want to use, then press and release your Select switch." }; + if (scanning.enabled) return { title: "Ready", tone: "ready", message: selectOpensHome(scanning.config) ? "Press and release your Select switch to open Home, then choose what to control." : `Press and release your Select switch to start ${lastModeName(scanning.config)}.` }; return { title: "Switch control", tone: "neutral", message: scanning.message }; } diff --git a/src/scanning/useScanning.ts b/src/scanning/useScanning.ts index 11cea81a..331d6b7f 100644 --- a/src/scanning/useScanning.ts +++ b/src/scanning/useScanning.ts @@ -5,6 +5,8 @@ export type ScannerColor = "red" | "green" | "blue" | "yellow" | "white"; export type KeyboardLayout = "qwerty" | "simpleQwerty" | "commonLetters"; export type PointScanConfig = { controlMode: "point" | "mouse"; + /** What Select opens when scanning starts afresh. Older saved state may lack it; the runtime then opens Home. */ + startWith?: "home" | "lastMode"; mouseRepeatStopEdge: "press" | "release"; scanPreferences?: import("./preferences").ScanPreferences; wordPrediction: boolean; @@ -25,6 +27,13 @@ export type PointScanConfig = { backKey: string; pauseKey: string; }; +/** Select opens Home unless it continues after an action or the user chose the last mode. */ +export function selectOpensHome(config: PointScanConfig) { + return config.startWith !== "lastMode"; +} +export function lastModeName(config: PointScanConfig) { + return config.controlMode === "mouse" ? "Mouse scanning" : "Point scanning"; +} export function mouseRepeatStopInstruction(config: PointScanConfig) { return config.mouseRepeatStopEdge === "press" ? "Press a switch to stop" : "Press and release a switch to stop"; } @@ -39,6 +48,7 @@ export type PointScanState = { }; export const defaultPointScanConfig: PointScanConfig = { controlMode: "point", + startWith: "home", mouseRepeatStopEdge: "release", wordPrediction: true, keyboardLayout: "qwerty", diff --git a/src/settings/ScannerPreferences.tsx b/src/settings/ScannerPreferences.tsx index d9860c8d..f5a51dae 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 { mouseRepeatStopInstruction, type ScanningController, type ScannerColor, type PointScanConfig } from '../scanning/useScanning'; +import { lastModeName, mouseRepeatStopInstruction, selectOpensHome, 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'; @@ -42,6 +42,7 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl keyboard: 'Choose keys and word suggestions when the scanning keyboard is open.', }; const disabled = !state?.supported; + const opensHome = selectOpensHome(config); const settings = config.scanPreferences ?? defaultScanPreferences; const shared = sharedOptions(config); const effective = area === 'shared' ? shared : areaOptions(config, area); @@ -69,11 +70,25 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl ; }; return
- {area === 'shared' ?

Two ways to control your PC

Select starts the last mode you used: {config.controlMode === 'mouse' ? 'Mouse scanning' : 'Point scanning'}. Use Open Point or Open Mouse on a switch to change modes. You can also switch from a scanned panel.

:
+ {area === 'shared' ?

Two ways to control your PC

{opensHome + ? <>Select opens Home. After an action, it carries on in the last mode you used: {lastModeName(config)}. + : <>Select starts the last mode you used: {lastModeName(config)}.} Use Open Point or Open Mouse on a switch to change modes. You can also switch from a scanned panel.

:

{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 === 'shared' &&
{(['point', 'mouse'] as const).map(key => )}
} + {area === 'shared' && + > + legend="Select starts" + disabled={disabled} + value={config.startWith ?? 'home'} + onChange={value => update('startWith', value)} + options={[{ value: 'home', label: 'Home menu' }, { value: 'lastMode', label: 'Last mode used' }]} + note={{ summary: opensHome + ? 'Home offers Point, Mouse and Keyboard, plus Apps and windows to bring another app forward, without focusing it first. After a click or other action, Select carries on in the same mode. Close menu or Stop scanning returns to Home.' + : 'Select starts the last mode you used straight away. Open Home from the Point action menu or Mouse Actions.' }} + /> + } {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' && <>
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.

+

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.

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.

From e420998e847b213e68b1a901b87dd9f019593c63 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 2 Oct 2026 16:02:16 +0100 Subject: [PATCH 2/2] Keep display restarts in point scanning and return failed Home exits to Home Next/Previous display rebuilt the session with Select, which now opens Home; it starts point scanning directly instead. A failed Point or Switchify request chosen in Home returns to Home rather than to an action menu with no chosen point. Refs #966 Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/point_workflow.rs | 42 +++++++++++++++++++++++++++++-- src-tauri/src/scanning_runtime.rs | 40 ++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index 212c76e9..e1965992 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -126,6 +126,8 @@ pub struct Workflow { return_to_mouse: bool, /// The keyboard was opened from Home and returns there when closed. return_to_home: bool, + /// The request just chosen in Home left it, and returns there if it fails. + leaving_home: bool, /// The last scan ended by performing an action, so Select continues in /// the same mode instead of opening Home. resume: bool, @@ -172,6 +174,7 @@ impl Workflow { moved: None, return_to_mouse: false, return_to_home: false, + leaving_home: false, resume: false, mouse_actions_open: false, move_repeat: None, @@ -590,9 +593,15 @@ impl Workflow { fn selected(&mut self, item: Item) -> Option { match item { Item::Home => self.open_home(), - Item::PointScan => return self.open_point(), + // No point exists yet, so a failure goes back to Home, never to an action menu. + Item::PointScan => { + let request = self.open_point(); + self.leaving_home = true; + return request; + } Item::Switchify => { self.reset(); + self.leaving_home = true; return Some(Request::OpenSwitchify); } Item::Keyboard => { @@ -751,8 +760,9 @@ impl Technique for Workflow { return; } // A failure in Home, or in a keyboard opened from it, returns to Home. - let home = self.home_open() || self.return_to_home; + let home = self.home_open() || self.return_to_home || self.leaving_home; self.return_to_home = false; + self.leaving_home = false; self.pending = None; self.following = false; self.stage = Stage::Menu; @@ -765,6 +775,7 @@ impl Technique for Workflow { self.error = Some(message); } fn execution_succeeded(&mut self) { + self.leaving_home = false; if self.stage == Stage::KeyboardOpening { self.stage = Stage::Keyboard; } else if self.stage == Stage::Keyboard { @@ -787,6 +798,7 @@ impl Technique for Workflow { self.mouse = self.new_mouse(); self.return_to_mouse = false; self.return_to_home = false; + self.leaving_home = false; self.resume = false; self.mouse_actions_open = false; self.move_repeat = None; @@ -835,6 +847,7 @@ impl Technique for Workflow { } fn handle(&mut self, action: Action) -> Option { self.following = false; + self.leaving_home = false; if action == Action::OpenPoint { return self.open_point(); } @@ -1329,6 +1342,31 @@ mod tests { assert_eq!(s.technique.menu.kind, Kind::Home); } + #[test] + fn leaving_home_that_fails_returns_to_home_not_to_an_unchosen_point() { + for (column, request) in [(0, Request::OpenPoint), (1, Request::OpenSwitchify)] { + let mut s = home_session(); + s.action(Action::Select); + let row = if request == Request::OpenPoint { 0 } else { 2 }; + assert_eq!(choose_in(&mut s, row, column), Some(request)); + s.execution_failed("Input could not be released.".into()); + assert_eq!(s.technique.menu.kind, Kind::Home, "{request:?}"); + assert!(s.technique.home_open()); + assert!(s.active()); + } + // Once Point has opened, a failed click offers the action menu at its point as before. + let mut s = home_session(); + s.action(Action::Select); + choose_in(&mut s, 0, 0); + s.technique.execution_succeeded(); + s.action(Action::Select); + s.action(Action::Select); + choose_in(&mut s, 0, 0); + s.execution_failed("Click failed.".into()); + assert_eq!(s.technique.menu.kind, Kind::Actions); + assert!(!s.technique.home_open()); + } + #[test] fn home_opens_from_the_point_and_mouse_action_menus() { let mut s = home_session(); diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index eec3b56c..7c90e362 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -1222,6 +1222,16 @@ pub fn update_point_setting( publish::(app); Ok(()) } +/// A point scan already under way, whatever Select would open. +fn point_session( + technique: crate::point_workflow::Workflow, + automatic: bool, +) -> Session { + let mut session = Session::new(technique, automatic); + // The display changes before the scan starts; nothing is left to release. + let _ = session.action(Action::OpenPoint); + session +} pub fn restart_point_on_display(app: &AppHandle, next: bool) -> Result<(), String> { use crate::point_scan_runtime::PointScan; let (cursor, displays) = crate::display_navigation::displays(app).map_err(|e| e.message)?; @@ -1230,9 +1240,7 @@ pub fn restart_point_on_display(app: &AppHandle, next: bool) -> Result<(), Strin let controller = app.state::>(); let mut data = controller.data.lock().unwrap_or_else(|p| p.into_inner()); let (technique, environment) = PointScan::create(app, data.config.clone())?; - let mut engine = Session::new(technique, data.config.automatic); - engine.action(Action::Select); - data.engine = Some(engine); + data.engine = Some(point_session(technique, data.config.automatic)); data.display = Some(environment); data.last_tick = Instant::now(); Ok(()) @@ -1240,6 +1248,32 @@ pub fn restart_point_on_display(app: &AppHandle, next: bool) -> Result<(), Strin #[cfg(test)] mod config_file_tests { + #[test] + fn a_display_restart_resumes_point_scanning_instead_of_opening_home() { + use crate::{ + point_scan::{Config, StartWith}, + point_workflow::{Phase, Workflow}, + scanning::{Rect, Technique}, + }; + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; + for start_with in [StartWith::Home, StartWith::LastMode] { + let config = Config { + start_with, + control_mode: crate::point_scan::ControlMode::Point, + ..Config::default() + }; + let session = + super::point_session(Workflow::new(config.point(), screen, 1.0).unwrap(), true); + assert!(session.active()); + assert!(!session.technique.home_open()); + assert!(matches!(session.technique.phase(), Phase::Point(_))); + } + } #[test] fn saving_replaces_the_file_without_leaving_a_temporary_copy() { let dir = std::env::temp_dir().join(format!(