From 5de12d0578f222ff46274a73f64d107fa70ec86f Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 12 Sep 2026 11:59:22 +0100 Subject: [PATCH 1/4] Port Android point scanning to native desktop switch control --- docs/point-scan.md | 15 + src-tauri/Cargo.lock | 36 +- src-tauri/Cargo.toml | 1 + src-tauri/build.rs | 2 + src-tauri/capabilities/main.json | 2 + .../autogenerated/configure_point_scan.toml | 11 + .../autogenerated/get_point_scan.toml | 11 + src-tauri/src/input.rs | 18 + src-tauri/src/lib.rs | 53 ++ src-tauri/src/macos.rs | 4 + src-tauri/src/overlay.rs | 4 +- src-tauri/src/overlay_macos.rs | 2 +- src-tauri/src/overlay_windows.rs | 2 +- src-tauri/src/point_scan.rs | 492 ++++++++++++++++++ src-tauri/src/point_scan_host.rs | 161 ++++++ src-tauri/src/point_scan_runtime.rs | 318 +++++++++++ src-tauri/src/windows_runtime.rs | 3 + src/App.tsx | 5 +- src/PointScan.test.tsx | 35 ++ src/PointScan.tsx | 68 +++ src/styles.css | 4 + 21 files changed, 1241 insertions(+), 6 deletions(-) create mode 100644 docs/point-scan.md create mode 100644 src-tauri/permissions/autogenerated/configure_point_scan.toml create mode 100644 src-tauri/permissions/autogenerated/get_point_scan.toml create mode 100644 src-tauri/src/point_scan.rs create mode 100644 src-tauri/src/point_scan_host.rs create mode 100644 src-tauri/src/point_scan_runtime.rs create mode 100644 src/PointScan.test.tsx create mode 100644 src/PointScan.tsx diff --git a/docs/point-scan.md b/docs/point-scan.md new file mode 100644 index 00000000..5fad836b --- /dev/null +++ b/docs/point-scan.md @@ -0,0 +1,15 @@ +# Native point scan + +Point scan ports the Android line-only and grid-then-line techniques to Switchify PC. The reference source is `switchifyapp/switchify-android` commit `856720d8747e2f3d1724a572bf754ffae05df299`, especially `PointScanLineManager`, `PointScanBlockManager`, and `ContinuousLineSpeedUtils`. + +Open **Point scan**, choose automatic or manual scanning, and enable it. Local keyboard-emulating switch interfaces can use Space to select, Enter to step forward, Backspace to step backward, and F8 to pause or resume. The four keys can be changed. Escape always disables scanning and releases the reserved keys. Scanning is off at every application startup. + +Focus the intended application and press Select to start. Line mode chooses X, then Y, and clicks once. Grid mode chooses a row, then a cell, before the same line sequence. Selecting happens on switch release; holding Select freezes the position and repeat keydowns do not select again. The scan resets after a click and waits for the next Select. Movement wraps at the selected region's edges. 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 for the final click. + +This first port uses switches attached to the computer. Android must be disconnected before enabling it, and an Android connection cancels local scanning. Existing authenticated Bluetooth commands and switch-forwarding profiles are unchanged. Input permission is still required on macOS. Point settings use a separate `point-scan.json` in the existing Tauri application configuration directory, so old settings and pairing schemas remain unchanged. + +The engine is independent of OS input. Activation uses the existing `InputInjector` adapter. Automated tests use a fake adapter and never move the real pointer. The Tauri global-shortcut plugin supplies global press/release events on both platforms; the existing dependencies did not include a switch-key listener. Only the main window can configure scanning through IPC. + +Manual validation should cover both modes, every speed, manual movement, holding and releasing switches, Escape, focus retention, display changes, mixed scaling, a Bluetooth connection during scanning, and application exit. Run macOS input checks through `npm run macos:run` to retain the stable Accessibility identity. No physical switch or real desktop input is exercised by automated tests. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index aa2936b3..e5d65fa4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1608,6 +1608,24 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.19", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -3165,7 +3183,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4008,6 +4026,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-autostart", + "tauri-plugin-global-shortcut", "tauri-plugin-single-instance", "tauri-plugin-updater", "tiny-skia", @@ -4294,6 +4313,21 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", +] + [[package]] name = "tauri-plugin-single-instance" version = "2.4.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 02f1f449..04e1c227 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ serde_json = "1" sha2 = "0.10" tauri = { version = "2.11.5", features = ["tray-icon", "image-png", "macos-private-api"] } tauri-plugin-autostart = "2.5.1" +tauri-plugin-global-shortcut = "2" tauri-plugin-single-instance = "2.4.3" tauri-plugin-updater = "2.10.1" tiny-skia = "0.11.4" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 60b06d31..127d27e2 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -5,6 +5,8 @@ fn main() { add_command_line_tools_swift_library_path(); let app_manifest = tauri_build::AppManifest::new().commands(&[ "get_app_state", + "get_point_scan", + "configure_point_scan", "check_accessibility", "approve_pairing", "reject_pairing", diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index 7162bed2..f89c3a2c 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -6,6 +6,8 @@ "permissions": [ "core:default", "allow-get-app-state", + "allow-get-point-scan", + "allow-configure-point-scan", "allow-check-accessibility", "allow-approve-pairing", "allow-reject-pairing", diff --git a/src-tauri/permissions/autogenerated/configure_point_scan.toml b/src-tauri/permissions/autogenerated/configure_point_scan.toml new file mode 100644 index 00000000..1683224a --- /dev/null +++ b/src-tauri/permissions/autogenerated/configure_point_scan.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-configure-point-scan" +description = "Enables the configure_point_scan command without any pre-configured scope." +commands.allow = ["configure_point_scan"] + +[[permission]] +identifier = "deny-configure-point-scan" +description = "Denies the configure_point_scan command without any pre-configured scope." +commands.deny = ["configure_point_scan"] diff --git a/src-tauri/permissions/autogenerated/get_point_scan.toml b/src-tauri/permissions/autogenerated/get_point_scan.toml new file mode 100644 index 00000000..d92a25a0 --- /dev/null +++ b/src-tauri/permissions/autogenerated/get_point_scan.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-get-point-scan" +description = "Enables the get_point_scan command without any pre-configured scope." +commands.allow = ["get_point_scan"] + +[[permission]] +identifier = "deny-get-point-scan" +description = "Denies the get_point_scan command without any pre-configured scope." +commands.deny = ["get_point_scan"] diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index 82b84ea1..7304765a 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -1529,6 +1529,24 @@ mod tests { assert_eq!(input.injector.text, vec!["Hello"]); } + #[test] + fn point_scan_clicks_once_at_absolute_point_with_fake_input() { + let mut input = DesktopInput::new(FakeInjector::default()); + crate::point_scan::click(&mut input, (-640, 512)).unwrap(); + assert_eq!(input.injector.absolute_moves, vec![(-640, 512)]); + assert_eq!(input.injector.clicks, vec![(MouseButton::Left, 1)]); + assert!(input.injector.keys.is_empty()); + } + + #[test] + fn point_scan_refuses_to_move_during_drag() { + let mut input = DesktopInput::new(FakeInjector::default()); + input.held_button = Some(MouseButton::Left); + assert!(crate::point_scan::click(&mut input, (30, 40)).is_err()); + assert!(input.injector.absolute_moves.is_empty()); + assert!(input.injector.clicks.is_empty()); + } + #[cfg(target_os = "windows")] #[test] fn canonical_windows_alphanumeric_keys_use_physical_keys() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 68bac917..aa96943d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,9 @@ mod macos_relaunch; mod modifier_overlay; mod mouse_repeat; mod overlay; +mod point_scan; +mod point_scan_host; +mod point_scan_runtime; mod protocol; mod state; mod storage; @@ -199,6 +202,7 @@ pub(crate) fn sync_tray_state(app: &AppHandle, state: &AppState) { } fn finish_app_exit(app: &AppHandle) { + point_scan_runtime::cancel(app); app.state::().cancel(app); let model = app.state::(); let _ = platform_disconnect_all(app, &model.shared); @@ -365,6 +369,7 @@ fn finish_disconnect( overlay: &overlay::CursorOverlay, modifier_overlay: &modifier_overlay::ModifierOverlay, ) -> AppState { + point_scan_runtime::cancel(app); app.state::().cancel(app); overlay.end_session(); modifier_overlay.end_session(); @@ -1211,6 +1216,50 @@ fn install_tray(app: &mut tauri::App) -> tauri::Result<()> { Ok(()) } +#[tauri::command] +fn get_point_scan( + controller: State<'_, point_scan_runtime::Controller>, +) -> point_scan_runtime::View { + controller.view() +} + +#[tauri::command] +async fn configure_point_scan( + app: AppHandle, + config: point_scan::Config, + enabled: bool, +) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel(); + let handle = app.clone(); + app.run_on_main_thread(move || { + let _ = tx.send(point_scan_runtime::configure(&handle, config, enabled)); + }) + .map_err(|e| e.to_string())?; + rx.await + .map_err(|_| "Point scan configuration was cancelled.".to_string())? +} + +fn point_scan_prepare(app: &AppHandle) -> Result<(), String> { + let state = app.state::().snapshot(); + if state.bluetooth == state::BluetoothState::Connected { + return Err("Disconnect Android before using local point scan.".into()); + } + if state.accessibility != state::AccessibilityState::Granted { + return Err("Grant input access before using point scan.".into()); + } + app.state::().cancel(app); + platform_stop_mouse_repeat(app); + Ok(()) +} + +fn point_scan_click(app: &AppHandle, point: (i32, i32)) -> Result<(), String> { + point_scan_prepare(app)?; + // Reuse the production input adapter, independent of Bluetooth availability. + let injector = enigo::Enigo::new(&enigo::Settings::default()) + .map_err(|_| "Point scan input could not be initialized.".to_string())?; + point_scan::click(&mut input::DesktopInput::new(injector), point) +} + pub fn run() { #[cfg(target_os = "macos")] if macos_relaunch::run_from_args() { @@ -1230,6 +1279,7 @@ pub fn run() { let overlay_shared = shared.clone(); let modifier_overlay_shared = shared.clone(); tauri::Builder::default() + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_single_instance::init(|app, args, _| { if has_start_hidden_argument(&args) { return; @@ -1250,6 +1300,7 @@ pub fn run() { .manage(PendingProfileExit::default()) .manage(PendingNavigation::default()) .setup(move |app| { + point_scan_runtime::install(app.handle()); install_tray(app)?; if updater_is_configured(app.config().plugins.0.get("updater")) { let model = app.state::(); @@ -1333,6 +1384,8 @@ pub fn run() { } }) .invoke_handler(tauri::generate_handler![ + get_point_scan, + configure_point_scan, get_app_state, check_accessibility, approve_pairing, diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index fdeab741..8919b976 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -391,6 +391,7 @@ pub fn reject_pairing( } pub fn disconnect_all(app: &AppHandle, shared: &SharedModel) -> Result<(), String> { + crate::point_scan_runtime::cancel(app); app.state::().cancel(app); with_runtime(|runtime| { runtime.stop_all_repeats(); @@ -969,6 +970,7 @@ impl MacRuntime { let _ = input.release_all(); input.end_control_session(); } + crate::point_scan_runtime::cancel(&self.app); self.app.state::().end_session(); self.app.state::().end_session(); self.set_bluetooth(BluetoothState::Advertising); @@ -1403,6 +1405,7 @@ impl MacRuntime { } } if command.command_type == "connection.disconnecting" { + crate::point_scan_runtime::cancel(&self.app); overlay.end_session(); } } @@ -2013,6 +2016,7 @@ impl MacRuntime { let _ = input.release_all(); input.end_control_session(); } + crate::point_scan_runtime::cancel(&self.app); self.app.state::().end_session(); self.app.state::().end_session(); } diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index f11889bf..3e45060f 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -10,10 +10,10 @@ use crate::state::{AppSettings, SharedModel}; #[cfg(target_os = "macos")] #[path = "overlay_macos.rs"] -mod platform; +pub(crate) mod platform; #[cfg(target_os = "windows")] #[path = "overlay_windows.rs"] -mod platform; +pub(crate) mod platform; const FOLLOW_INTERVAL: Duration = Duration::from_millis(75); const DEFAULT_DURATION: Duration = Duration::from_millis(900); diff --git a/src-tauri/src/overlay_macos.rs b/src-tauri/src/overlay_macos.rs index c1123c0e..99cd9f16 100644 --- a/src-tauri/src/overlay_macos.rs +++ b/src-tauri/src/overlay_macos.rs @@ -190,7 +190,7 @@ impl MacOverlayHost { } } -fn make_panel(mtm: MainThreadMarker) -> Retained { +pub(crate) fn make_panel(mtm: MainThreadMarker) -> Retained { let panel = NSPanel::initWithContentRect_styleMask_backing_defer( NSPanel::alloc(mtm), rect(0.0, 0.0, 1.0, 1.0), diff --git a/src-tauri/src/overlay_windows.rs b/src-tauri/src/overlay_windows.rs index 5335133e..603f77d2 100644 --- a/src-tauri/src/overlay_windows.rs +++ b/src-tauri/src/overlay_windows.rs @@ -206,7 +206,7 @@ unsafe fn create_overlay_window( } } -fn present_solid( +pub(crate) fn present_solid( window: HWND, x: i32, y: i32, diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs new file mode 100644 index 00000000..3e9f62b9 --- /dev/null +++ b/src-tauri/src/point_scan.rs @@ -0,0 +1,492 @@ +//! Android point scanning, with desktop coordinates and no OS input in the engine. +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Mode { + Line, + Grid, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Config { + pub mode: Mode, + pub automatic: bool, + pub speed: usize, + pub grid_size: usize, + pub block_interval_ms: u64, + pub select_key: String, + pub next_key: String, + pub back_key: String, + pub pause_key: String, +} +impl Default for Config { + fn default() -> Self { + Self { + mode: Mode::Line, + automatic: true, + speed: 2, + grid_size: 4, + block_interval_ms: 1000, + select_key: "Space".into(), + next_key: "Enter".into(), + back_key: "Backspace".into(), + pause_key: "F8".into(), + } + } +} +impl Config { + pub fn keys(&self) -> [&str; 5] { + [ + &self.select_key, + &self.next_key, + &self.back_key, + &self.pause_key, + "Escape", + ] + } + pub fn validate(&self) -> Result<(), String> { + if self.speed > 4 + || !(2..=10).contains(&self.grid_size) + || !(250..=5000).contains(&self.block_interval_ms) + { + return Err("Point scan speed, grid size, or interval is invalid.".into()); + } + let keys = self.keys(); + for (index, key) in keys.iter().enumerate() { + let function = key + .strip_prefix('F') + .and_then(|n| n.parse::().ok()) + .is_some_and(|n| (1..=24).contains(&n)); + if !function + && ![ + "Space", + "Enter", + "Backspace", + "Escape", + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + ] + .contains(key) + { + return Err("Choose a supported switch key.".into()); + } + if keys[..index].contains(key) { + return Err( + "Each switch action must use a different key. Escape is reserved for cancel." + .into(), + ); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} +impl Rect { + pub fn valid(self) -> bool { + [self.x, self.y, self.width, self.height] + .iter() + .all(|n| n.is_finite()) + && self.width >= 2.0 + && self.height >= 2.0 + } +} +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Phase { + Idle, + Row, + Cell, + X, + Y, +} +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Action { + Select, + Next, + Back, + Pause, + Cancel, +} +pub const ACTIONS: [Action; 5] = [ + Action::Select, + Action::Next, + Action::Back, + Action::Pause, + Action::Cancel, +]; + +pub struct Engine { + pub config: Config, + pub screen: Rect, + pub region: Rect, + pub phase: Phase, + pub paused: bool, + pub x: f64, + pub y: f64, + pub row: usize, + pub cell: usize, + pub units_per_logical_pixel: f64, + direction: f64, + block_elapsed: u64, +} +impl Engine { + pub fn new(config: Config, screen: Rect, units_per_logical_pixel: f64) -> Result { + config.validate()?; + if !screen.valid() || !units_per_logical_pixel.is_finite() || units_per_logical_pixel <= 0.0 + { + return Err("The scanning display has invalid geometry.".into()); + } + Ok(Self { + config, + screen, + region: screen, + phase: Phase::Idle, + paused: false, + x: screen.x, + y: screen.y, + row: 0, + cell: 0, + direction: 1.0, + block_elapsed: 0, + units_per_logical_pixel, + }) + } + pub fn reset(&mut self) { + self.phase = Phase::Idle; + self.paused = false; + self.region = self.screen; + self.x = self.screen.x; + self.y = self.screen.y; + self.row = 0; + self.cell = 0; + self.direction = 1.0; + self.block_elapsed = 0; + } + pub fn action(&mut self, action: Action) -> Option<(i32, i32)> { + if action == Action::Cancel { + self.reset(); + return None; + } + if self.phase == Phase::Idle { + if action == Action::Select { + self.phase = if self.config.mode == Mode::Grid { + Phase::Row + } else { + Phase::X + }; + } + return None; + } + match action { + Action::Select => { + self.block_elapsed = 0; + match self.phase { + Phase::Row => { + self.phase = Phase::Cell; + self.cell = 0; + } + Phase::Cell => { + self.region = self.cell_rect(); + self.x = self.region.x; + self.y = self.region.y; + self.phase = Phase::X; + self.direction = 1.0; + } + Phase::X => { + self.phase = Phase::Y; + self.direction = 1.0; + } + Phase::Y => { + let point = ( + self.x + .round() + .clamp(self.region.x, self.region.x + self.region.width - 1.0) + as i32, + self.y + .round() + .clamp(self.region.y, self.region.y + self.region.height - 1.0) + as i32, + ); + self.reset(); + return Some(point); + } + Phase::Idle => {} + } + } + Action::Next | Action::Back => { + self.direction = if action == Action::Next { 1.0 } else { -1.0 }; + self.step(33); + } + Action::Pause => self.paused = !self.paused, + Action::Cancel => {} + } + None + } + pub fn tick(&mut self, elapsed_ms: u64) { + if !self.config.automatic || self.paused || self.phase == Phase::Idle { + return; + } + // Android caps delayed timer callbacks at 250 ms to avoid large jumps. + let elapsed_ms = elapsed_ms.min(250); + if matches!(self.phase, Phase::Row | Phase::Cell) { + self.block_elapsed += elapsed_ms; + if self.block_elapsed < self.config.block_interval_ms { + return; + } + self.block_elapsed %= self.config.block_interval_ms; + } + self.step(elapsed_ms); + } + fn step(&mut self, elapsed_ms: u64) { + let amount = ([45.0, 75.0, 120.0, 180.0, 270.0][self.config.speed] + * self.units_per_logical_pixel + * elapsed_ms as f64 + / 1000.0) + .max(1.0); + match self.phase { + Phase::Row => { + self.row = (self.row as i32 + self.direction as i32) + .rem_euclid(self.config.grid_size as i32) as usize + } + Phase::Cell => { + self.cell = (self.cell as i32 + self.direction as i32) + .rem_euclid(self.config.grid_size as i32) as usize + } + Phase::X => { + self.x = advance( + self.x, + self.region.x, + self.region.width, + self.direction * amount, + ) + } + Phase::Y => { + self.y = advance( + self.y, + self.region.y, + self.region.height, + self.direction * amount, + ) + } + Phase::Idle => {} + } + } + pub fn row_rect(&self) -> Rect { + Rect { + x: self.screen.x, + y: self.screen.y + self.screen.height * self.row as f64 / self.config.grid_size as f64, + width: self.screen.width, + height: self.screen.height / self.config.grid_size as f64, + } + } + pub fn cell_rect(&self) -> Rect { + let row = self.row_rect(); + Rect { + x: row.x + row.width * self.cell as f64 / self.config.grid_size as f64, + width: row.width / self.config.grid_size as f64, + ..row + } + } + /// Thin rectangles let both native hosts render without a full-screen bitmap. + pub fn lines(&self) -> Vec { + let t = 2.0 * self.units_per_logical_pixel; + let mut result = vec![]; + match self.phase { + Phase::Idle => return result, + Phase::Row | Phase::Cell => { + let n = self.config.grid_size as f64; + for i in 0..=self.config.grid_size { + result.push(Rect { + x: (self.screen.x + self.screen.width * i as f64 / n) + .min(self.screen.x + self.screen.width - t), + y: self.screen.y, + width: t, + height: self.screen.height, + }); + result.push(Rect { + x: self.screen.x, + y: (self.screen.y + self.screen.height * i as f64 / n) + .min(self.screen.y + self.screen.height - t), + width: self.screen.width, + height: t, + }); + } + let r = if self.phase == Phase::Row { + self.row_rect() + } else { + self.cell_rect() + }; + result.extend(outline(r, t * 2.0)); + } + Phase::X | Phase::Y => { + if self.config.mode == Mode::Grid { + result.extend(outline(self.region, t)); + } + result.push(Rect { + x: self.x.min(self.region.x + self.region.width - t), + y: self.region.y, + width: t, + height: self.region.height, + }); + if self.phase == Phase::Y { + result.push(Rect { + x: self.region.x, + y: self.y.min(self.region.y + self.region.height - t), + width: self.region.width, + height: t, + }); + } + } + } + result + } +} +fn outline(r: Rect, t: f64) -> [Rect; 4] { + let t = t.min(r.width).min(r.height); + [ + Rect { height: t, ..r }, + Rect { + y: r.y + r.height - t, + height: t, + ..r + }, + Rect { width: t, ..r }, + Rect { + x: r.x + r.width - t, + width: t, + ..r + }, + ] +} +fn advance(value: f64, start: f64, length: f64, delta: f64) -> f64 { + let next = value + delta; + if next > start + length - 1.0 { + start + } else if next < start { + start + length - 1.0 + } else { + next + } +} + +/// Keep injection behind the existing adapter; no tests use the system adapter. +pub fn click( + input: &mut crate::input::DesktopInput, + point: (i32, i32), +) -> Result<(), String> { + if input.has_active_switch_session() || input.has_active_drag() { + return Err("End switch forwarding or dragging before using point scan.".into()); + } + input.move_pointer_absolute(point.0, point.1)?; + input.click_pointer(crate::protocol::MouseButton::Left, 1) +} + +#[cfg(test)] +mod tests { + use super::*; + fn engine(config: Config) -> Engine { + Engine::new( + config, + Rect { + x: -1000.0, + y: 50.0, + width: 999.0, + height: 701.0, + }, + 1.0, + ) + .unwrap() + } + #[test] + fn line_selects_x_then_y_and_resets() { + let mut e = engine(Config::default()); + assert_eq!(e.action(Action::Select), None); + e.tick(100); + let x = e.x; + e.action(Action::Select); + e.tick(100); + assert_eq!(e.x, x); + assert_eq!(e.action(Action::Select), Some((-988, 62))); + assert_eq!(e.phase, Phase::Idle); + assert!(e.lines().is_empty()); + } + #[test] + fn grid_uses_row_then_cell_and_covers_remainder() { + let mut e = engine(Config { + mode: Mode::Grid, + grid_size: 3, + ..Config::default() + }); + e.action(Action::Select); + e.action(Action::Back); + assert_eq!(e.row, 2); + e.action(Action::Select); + e.action(Action::Back); + e.action(Action::Select); + assert_eq!(e.region.x + e.region.width, -1.0); + assert!((e.region.y + e.region.height - 751.0).abs() < 0.001); + assert_eq!(e.phase, Phase::X); + } + #[test] + fn pause_resume_wrap_and_delayed_ticks() { + let mut e = engine(Config::default()); + e.action(Action::Select); + e.action(Action::Pause); + e.tick(1000); + assert_eq!(e.x, -1000.0); + e.action(Action::Pause); + e.tick(10000); + assert_eq!(e.x, -970.0); + e.x = -1000.0; + e.action(Action::Back); + assert_eq!(e.x, -2.0); + e.action(Action::Cancel); + assert_eq!(e.phase, Phase::Idle); + } + #[test] + fn manual_mode_only_moves_on_steps() { + let mut e = engine(Config { + automatic: false, + ..Config::default() + }); + e.action(Action::Select); + e.tick(250); + assert_eq!(e.x, -1000.0); + e.action(Action::Next); + assert!(e.x > -1000.0); + } + #[test] + fn legacy_empty_config_and_validation() { + let c: Config = serde_json::from_str("{}").unwrap(); + assert_eq!(c, Config::default()); + assert!(Config { + select_key: "Escape".into(), + ..c.clone() + } + .validate() + .is_err()); + assert!(Config { speed: 5, ..c }.validate().is_err()); + assert!(Engine::new( + Config::default(), + Rect { + x: 0.0, + y: 0.0, + width: f64::NAN, + height: 10.0 + }, + 1.0 + ) + .is_err()); + } +} diff --git a/src-tauri/src/point_scan_host.rs b/src-tauri/src/point_scan_host.rs new file mode 100644 index 00000000..c4d83b76 --- /dev/null +++ b/src-tauri/src/point_scan_host.rs @@ -0,0 +1,161 @@ +//! Native, nonactivating strips, using the same display units as input injection. +use crate::point_scan::Rect; + +#[cfg(target_os = "windows")] +mod platform { + use super::*; + use windows::{ + core::w, + Win32::{ + Foundation::HWND, System::LibraryLoader::GetModuleHandleW, UI::WindowsAndMessaging::*, + }, + }; + unsafe extern "system" fn window_proc( + hwnd: HWND, + message: u32, + wparam: windows::Win32::Foundation::WPARAM, + lparam: windows::Win32::Foundation::LPARAM, + ) -> windows::Win32::Foundation::LRESULT { + unsafe { DefWindowProcW(hwnd, message, wparam, lparam) } + } + pub struct Host { + windows: Vec, + } + impl Host { + pub fn new() -> Result { + Ok(Self { windows: vec![] }) + } + pub fn render(&mut self, rects: &[Rect]) -> Result<(), String> { + unsafe { + while self.windows.len() < rects.len() { + let module = GetModuleHandleW(None).map_err(|e| e.to_string())?; + let class = w!("SwitchifyPointScanStrip"); + RegisterClassW(&WNDCLASSW { + lpfnWndProc: Some(window_proc), + hInstance: module.into(), + lpszClassName: class, + ..Default::default() + }); + self.windows.push( + CreateWindowExW( + WS_EX_LAYERED + | WS_EX_TRANSPARENT + | WS_EX_NOACTIVATE + | WS_EX_TOOLWINDOW + | WS_EX_TOPMOST, + class, + w!(""), + WS_POPUP, + 0, + 0, + 1, + 1, + None, + None, + Some(module.into()), + None, + ) + .map_err(|e| e.to_string())?, + ); + } + for (index, window) in self.windows.iter().enumerate() { + if let Some(r) = rects.get(index) { + crate::overlay::platform::present_solid( + *window, + r.x.round() as i32, + r.y.round() as i32, + r.width.round().max(1.0) as i32, + r.height.round().max(1.0) as i32, + [255, 196, 0], + 230, + )?; + } else { + let _ = ShowWindow(*window, SW_HIDE); + } + } + } + Ok(()) + } + pub fn hide(&mut self) { + for window in &self.windows { + unsafe { + let _ = ShowWindow(*window, SW_HIDE); + } + } + } + } + impl Drop for Host { + fn drop(&mut self) { + for window in &self.windows { + unsafe { + let _ = DestroyWindow(*window); + } + } + } + } +} +#[cfg(target_os = "macos")] +mod platform { + use super::*; + use objc2::{rc::Retained, MainThreadMarker}; + use objc2_app_kit::{NSColor, NSPanel, NSScreen}; + use objc2_foundation::{NSPoint, NSRect, NSSize}; + pub struct Host { + panels: Vec>, + } + impl Host { + pub fn new() -> Result { + Ok(Self { panels: vec![] }) + } + pub fn render(&mut self, rects: &[Rect]) -> Result<(), String> { + let mtm = + MainThreadMarker::new().ok_or("Point scan requires the AppKit main thread.")?; + let screens = NSScreen::screens(mtm); + let primary = screens + .firstObject() + .ok_or("No scanning display is available.")?; + let top = primary.frame().origin.y + primary.frame().size.height; + while self.panels.len() < rects.len() { + self.panels.push(crate::overlay::platform::make_panel(mtm)); + } + for (index, panel) in self.panels.iter().enumerate() { + if let Some(r) = rects.get(index) { + panel.setBackgroundColor(Some(&NSColor::colorWithSRGBRed_green_blue_alpha( + 1.0, 0.77, 0.0, 0.90, + ))); + panel.setFrame_display( + NSRect::new( + NSPoint::new(r.x, top - r.y - r.height), + NSSize::new(r.width, r.height), + ), + false, + ); + panel.orderFrontRegardless(); + } else { + panel.orderOut(None); + } + } + Ok(()) + } + pub fn hide(&mut self) { + for panel in &self.panels { + panel.orderOut(None); + } + } + } +} +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +mod platform { + use super::*; + pub struct Host; + impl Host { + pub fn new() -> Result { + Err("Point scan is supported on Windows and macOS.".into()) + } + pub fn render(&mut self, _: &[Rect]) -> Result<(), String> { + Err("Point scan is unavailable.".into()) + } + pub fn hide(&mut self) {} + } +} +pub use platform::Host; diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs new file mode 100644 index 00000000..5f8a70f4 --- /dev/null +++ b/src-tauri/src/point_scan_runtime.rs @@ -0,0 +1,318 @@ +use crate::{ + display_navigation::{self, Display}, + point_scan::{Action, Config, Engine, Phase, Rect, ACTIONS}, + point_scan_host::Host, +}; +use serde::Serialize; +use std::{ + cell::RefCell, + sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, + }, + time::Instant, +}; +use tauri::{AppHandle, Emitter, Manager}; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; + +thread_local! {static HOST:RefCell>=const{RefCell::new(None)};} +pub struct Controller { + enabled: AtomicBool, + data: Mutex, +} +struct Data { + config: Config, + engine: Option, + display: Option, + registered: Vec, + pressed: [bool; 5], + last_tick: Instant, + message: String, +} +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct View { + pub config: Config, + pub enabled: bool, + pub phase: Phase, + pub paused: bool, + pub message: String, + pub supported: bool, +} +impl Controller { + pub fn new(app: &AppHandle) -> Self { + let config = config_path(app) + .ok() + .and_then(|p| std::fs::read(p).ok()) + .and_then(|b| serde_json::from_slice::(&b).ok()) + .filter(|c| c.validate().is_ok()) + .unwrap_or_default(); + Self { + enabled: AtomicBool::new(false), + data: Mutex::new(Data { + config, + engine: None, + display: None, + registered: vec![], + pressed: [false; 5], + last_tick: Instant::now(), + message: "Point scan is off.".into(), + }), + } + } + pub fn view(&self) -> View { + let d = self.data.lock().unwrap_or_else(|p| p.into_inner()); + View { + config: d.config.clone(), + enabled: self.enabled.load(Ordering::SeqCst), + phase: d.engine.as_ref().map_or(Phase::Idle, |e| e.phase), + paused: d.engine.as_ref().is_some_and(|e| e.paused), + message: d.message.clone(), + supported: cfg!(any(target_os = "windows", target_os = "macos")), + } + } +} +fn config_path(app: &AppHandle) -> Result { + app.path() + .app_config_dir() + .map(|p| p.join("point-scan.json")) + .map_err(|e| e.to_string()) +} +fn publish(app: &AppHandle) { + let _ = app.emit("point-scan-changed", app.state::().view()); +} +/// Can be called from transport threads while their input lock is held. +pub fn cancel(app: &AppHandle) { + if let Some(c) = app.try_state::() { + c.enabled.store(false, Ordering::SeqCst); + } + let handle = app.clone(); + let _ = app.run_on_main_thread(move || disable(&handle, "Point scan stopped.")); +} +fn disable(app: &AppHandle, message: &str) { + let c = app.state::(); + c.enabled.store(false, Ordering::SeqCst); + let keys = { + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + d.engine = None; + d.display = None; + d.pressed = [false; 5]; + d.message = message.into(); + std::mem::take(&mut d.registered) + }; + for key in keys { + let _ = app.global_shortcut().unregister(key.as_str()); + } + HOST.with(|host| { + if let Some(host) = host.borrow_mut().as_mut() { + host.hide(); + } + }); + publish(app); +} +pub fn configure(app: &AppHandle, config: Config, enabled: bool) -> Result { + config.validate()?; + disable(app, "Point scan is off."); + if enabled { + crate::point_scan_prepare(app)?; + HOST.with(|slot| { + if slot.borrow().is_none() { + *slot.borrow_mut() = Some(Host::new()?); + } + Ok::<_, String>(()) + })?; + for (index, key) in config.keys().iter().enumerate() { + if let Err(error) = app + .global_shortcut() + .on_shortcut(*key, move |app, _, event| { + let handle = app.clone(); + let _ = app.run_on_main_thread(move || { + switch(&handle, index, event.state == ShortcutState::Pressed) + }); + }) + { + disable(app, "A switch key is already in use. Choose another key."); + return Err(format!("Could not reserve {key}: {error}")); + } + app.state::() + .data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .registered + .push((*key).into()); + } + } + let path = config_path(app)?; + let save = || -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::write( + &path, + serde_json::to_vec_pretty(&config).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string()) + }; + if let Err(error) = save() { + disable(app, "Point scan settings could not be saved."); + return Err(error); + } + let c = app.state::(); + { + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + d.config = config; + d.message = if enabled { + "Ready. Press the select switch to begin." + } else { + "Point scan is off." + } + .into(); + } + c.enabled.store(enabled, Ordering::SeqCst); + publish(app); + Ok(c.view()) +} +fn new_engine(app: &AppHandle, config: Config) -> Result<(Engine, Display), String> { + let (cursor, displays) = display_navigation::displays(app).map_err(|e| e.message)?; + let display = display_navigation::current_display(cursor, &displays) + .ok_or("No scanning display is available.")? + .clone(); + let units = if cfg!(target_os = "windows") { + display.scale_factor + } else { + 1.0 + }; + let e = Engine::new( + config, + Rect { + x: display.x.into(), + y: display.y.into(), + width: display.width.into(), + height: display.height.into(), + }, + units, + )?; + Ok((e, display)) +} +fn switch(app: &AppHandle, index: usize, pressed: bool) { + let c = app.state::(); + if !c.enabled.load(Ordering::SeqCst) { + return; + } + if ACTIONS[index] == Action::Cancel { + if pressed { + disable(app, "Point scan cancelled. Switch keys released."); + } + return; + } + let result = (|| -> Result<(), String> { + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + if pressed { + d.pressed[index] = true; + return Ok(()); + } + if !std::mem::replace(&mut d.pressed[index], false) { + return Ok(()); + } + if d.engine.as_ref().is_none_or(|e| e.phase == Phase::Idle) + && ACTIONS[index] == Action::Select + { + let (engine, display) = new_engine(app, d.config.clone())?; + d.engine = Some(engine); + d.display = Some(display); + crate::point_scan_prepare(app)?; + } + let point = d.engine.as_mut().and_then(|e| e.action(ACTIONS[index])); + d.last_tick = Instant::now(); + let display = d.display.clone(); + drop(d); + if let Some(point) = point { + validate_display(app, display.as_ref())?; + HOST.with(|host| { + if let Some(h) = host.borrow_mut().as_mut() { + h.hide(); + } + }); + if c.enabled.load(Ordering::SeqCst) { + crate::point_scan_click(app, point)?; + } + } + render(app) + })(); + if let Err(error) = result { + disable(app, &error); + } else { + publish(app); + } +} +fn validate_display(app: &AppHandle, display: Option<&Display>) -> Result<(), String> { + if let Some(expected) = display { + let (_, displays) = display_navigation::displays(app).map_err(|e| e.message)?; + if !displays.contains(expected) { + return Err("Display geometry changed. Enable point scan again.".into()); + } + } + Ok(()) +} +fn render(app: &AppHandle) -> Result<(), String> { + let c = app.state::(); + let d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + let lines = d.engine.as_ref().map_or_else(Vec::new, Engine::lines); + HOST.with(|host| { + if let Some(host) = host.borrow_mut().as_mut() { + host.render(&lines) + } else { + Ok(()) + } + }) +} +fn tick(app: &AppHandle) { + let c = app.state::(); + if !c.enabled.load(Ordering::SeqCst) { + return; + } + if app.state::().snapshot().bluetooth + == crate::state::BluetoothState::Connected + { + disable(app, "Android connected. Local point scan stopped."); + return; + } + let result = (|| -> Result<(), String> { + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + validate_display(app, d.display.as_ref())?; + let now = Instant::now(); + let elapsed = now.duration_since(d.last_tick).as_millis() as u64; + d.last_tick = now; + if !d.pressed[0] { + if let Some(engine) = d.engine.as_mut() { + engine.tick(elapsed); + } + } + drop(d); + render(app) + })(); + if let Err(error) = result { + disable(app, &error); + } +} +pub fn install(app: &AppHandle) { + app.manage(Controller::new(app)); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(33)).await; + let handle = app.clone(); + let (tx, rx) = tokio::sync::oneshot::channel(); + if app + .run_on_main_thread(move || { + tick(&handle); + let _ = tx.send(()); + }) + .is_err() + || rx.await.is_err() + { + break; + } + } + }); +} diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index 40ab955a..3f9926af 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -948,6 +948,7 @@ async fn start_gatt( .cancel(&subscribe_app); stop_all_repeats(&subscribe_app); release_input_session(); + crate::point_scan_runtime::cancel(&subscribe_app); subscribe_app.state::().end_session(); subscribe_app.state::().end_session(); } @@ -1639,6 +1640,7 @@ fn complete_desktop( } } if command.command_type == "connection.disconnecting" { + crate::point_scan_runtime::cancel(app); overlay.end_session(); } } @@ -2216,6 +2218,7 @@ pub fn disconnect_all(app: &AppHandle, shared: &SharedModel) -> Result<(), Strin } fn reset_transport(app: &AppHandle, shared: &SharedModel, bluetooth: BluetoothState) { + crate::point_scan_runtime::cancel(app); app.state::().cancel(app); stop_all_repeats(app); take_runtime(); diff --git a/src/App.tsx b/src/App.tsx index 1646b99a..4ca0a0b8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,8 +10,9 @@ import { applyLocalSettings, changedSettingKeys } from "./settings/diff"; import { SettingsView } from "./settings/SettingsView"; import { updateDescription, updateInFlight, updateLiveness, updateProgress, updateStanding, type UpdateAction } from "./settings/UpdatesSection"; import { TabPanel, Tabs } from "./Tabs"; +import { PointScan } from "./PointScan"; -type View = "home" | "devices" | "profiles" | "settings" | "support"; +type View = "home" | "devices" | "profiles" | "pointScan" | "settings" | "support"; const brandIconUrl = new URL("../src-tauri/icons/icon.png", import.meta.url).href; const androidQrUrl = new URL("./assets/android-download-qr.png", import.meta.url).href; @@ -671,6 +672,7 @@ export function App() { ["home", "Home", ], ["devices", "Devices", ], ["profiles", "Switch Forwarding", ], ["settings", "Settings", ], ["support", "Support", ], + ["pointScan", "Point scan", ], ] as const, []); const selectView = (next: View) => { @@ -725,6 +727,7 @@ export function App() {

{updateNotice}

{view === "home" && void perform(api.disconnectAll)} onAccessibility={() => void perform(() => api.checkAccessibility(true))} onSetup={openSetup} />} {view === "devices" && void perform(() => api.forgetDevice(id))} />} + {view === "pointScan" && } {view === "profiles" && { profileEditorDirty.current = dirty; }} nativeExitRequest={profileExitRequest} onConfirmNativeExit={confirmProfileExit} onCancelNativeExit={cancelProfileExit} />} {view === "settings" && void perform(() => api.setTelemetryConsent(enabled))} updateAction={(action) => void runUpdate(action)} cancelUpdate={() => void cancelUpdate()} busy={busy} focusUpdates={focusUpdates} onUpdatesFocused={() => setFocusUpdates(false)} updateAttention={updateFailure?.text ?? null} onUpdatesShown={setUpdatesShown} />} {view === "support" && void perform(operation)} openSetup={openSetup} openUpdates={openUpdates} />} diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx new file mode 100644 index 00000000..7facf35f --- /dev/null +++ b/src/PointScan.test.tsx @@ -0,0 +1,35 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { PointScan, defaultPointScanConfig, type PointScanState } from "./PointScan"; + +const mocks = vi.hoisted(() => ({ invoke: vi.fn(), listen: vi.fn() })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); +vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen })); +const initial: PointScanState = { config: defaultPointScanConfig, enabled: false, phase: "idle", paused: false, supported: true, message: "Point scan is off." }; +beforeEach(() => { + Object.defineProperty(window, "__TAURI_INTERNALS__", { configurable: true, value: {} }); + mocks.listen.mockReset().mockResolvedValue(vi.fn()); + mocks.invoke.mockReset().mockImplementation(async (command, args) => command === "get_point_scan" ? initial : { ...initial, ...args }); +}); +it("enables native point scan and locks its configuration until disabled", async () => { + render(); + await screen.findByText("Point scan is off."); + fireEvent.click(screen.getByRole("button", { name: "Enable point scan" })); + await screen.findByRole("button", { name: "Disable point scan" }); + expect(mocks.invoke).toHaveBeenCalledWith("configure_point_scan", { config: defaultPointScanConfig, enabled: true }); + expect(screen.getByLabelText("Mode")).toBeDisabled(); +}); +it("rejects duplicate switch keys and exposes grid settings", async () => { + render(); await screen.findByText("Point scan is off."); + fireEvent.change(screen.getByLabelText("Mode"), { target: { value: "grid" } }); + expect(screen.getByLabelText("Grid size")).toHaveValue("4"); + fireEvent.change(screen.getByLabelText("Forward switch"), { target: { value: "Space" } }); + expect(screen.getByRole("button", { name: "Enable point scan" })).toBeDisabled(); +}); +it("reports native registration failure without claiming scanning started", async () => { + render(); await screen.findByText("Point scan is off."); + mocks.invoke.mockRejectedValueOnce("Space is already in use."); + fireEvent.click(screen.getByRole("button", { name: "Enable point scan" })); + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("Space is already in use.")); + expect(screen.getByRole("button", { name: "Enable point scan" })).toBeEnabled(); +}); diff --git a/src/PointScan.tsx b/src/PointScan.tsx new file mode 100644 index 00000000..22f988ac --- /dev/null +++ b/src/PointScan.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; + +export type PointScanConfig = { + mode: "line" | "grid"; automatic: boolean; speed: number; gridSize: number; blockIntervalMs: number; + selectKey: string; nextKey: string; backKey: string; pauseKey: string; +}; +export type PointScanState = { config: PointScanConfig; enabled: boolean; phase: "idle" | "row" | "cell" | "x" | "y"; paused: boolean; message: string; supported: boolean }; +export const defaultPointScanConfig: PointScanConfig = { mode: "line", automatic: true, speed: 2, gridSize: 4, blockIntervalMs: 1000, selectKey: "Space", nextKey: "Enter", backKey: "Backspace", pauseKey: "F8" }; +const keys = ["Space", "Enter", "Backspace", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", ...Array.from({ length: 24 }, (_, i) => `F${i + 1}`)]; +const phases = { idle: "Ready to begin", row: "Choose a row", cell: "Choose a cell", x: "Choose the horizontal position", y: "Choose the vertical position" }; + +export function PointScan() { + const [state, setState] = useState(null); + const [config, setConfig] = useState(defaultPointScanConfig); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { + let alive = true; + let unlisten: (() => void) | undefined; + const receive = (next: PointScanState) => { if (alive) { setState(next); setConfig(next.config); } }; + if (!("__TAURI_INTERNALS__" in window)) { + setState({ config: defaultPointScanConfig, enabled: false, phase: "idle", paused: false, supported: false, message: "Open the desktop app to use point scan." }); + return; + } + void listen("point-scan-changed", (event) => receive(event.payload)).then((stop) => { + if (alive) unlisten = stop; else stop(); + return invoke("get_point_scan"); + }).then(receive).catch((reason) => { if (alive) setError(String(reason)); }); + return () => { alive = false; unlisten?.(); }; + }, []); + const apply = async (enabled: boolean) => { + setBusy(true); setError(null); + try { const next = await invoke("configure_point_scan", { config, enabled }); setState(next); setConfig(next.config); } + catch (reason) { setError(String(reason)); } + finally { setBusy(false); } + }; + const update = (key: K, value: PointScanConfig[K]) => setConfig({ ...config, [key]: value }); + const invalidKeys = new Set([config.selectKey, config.nextKey, config.backKey, config.pauseKey]).size !== 4; + return
+

Point scan

Select a point using switches connected to this computer.

+
+

Local switch control

+

Enable point scan, focus the application you want to use, then press Select. The display under the pointer is scanned. Select the X position, then the Y position to click once.

+

Switch keys are reserved while enabled. Escape stops scanning and releases them. Disconnect Android before enabling local point scan.

+

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

+ {error &&

{error}

} + {invalidKeys &&

Each switch action needs a different key.

} +
+ + +
+
+
+ Scanning settings + + + + {config.mode === "grid" && <> + + + } + {([["selectKey", "Select switch"], ["nextKey", "Forward switch"], ["backKey", "Backward switch"], ["pauseKey", "Pause / resume switch"]] as const).map(([key, label]) => )} +
+

Select takes effect on release. Holding Select freezes scanning. Forward and Backward step once and set direction. After clicking, press Select to start again. Settings are saved, but scanning stays off when the app restarts.

+
; +} diff --git a/src/styles.css b/src/styles.css index 5d754a89..0ee1a349 100644 --- a/src/styles.css +++ b/src/styles.css @@ -317,3 +317,7 @@ button:disabled { cursor: default; opacity: 0.55; } .settings-view > .tab-bar { grid-template-columns: repeat(var(--tab-count), auto); } .settings-view > .tab-bar button { padding: 0 4px; } + +.point-scan-options { border: 1px solid var(--border); border-radius: 12px; padding: 20px; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 18px; } +.point-scan-options label { display: flex; flex-direction: column; gap: 8px; } +.point-scan-view .button-row { display: flex; gap: 12px; margin: 18px 0; } From 9fccf8f9bc3c15aa00b14b12777f52bf816e9a66 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 12 Sep 2026 12:03:18 +0100 Subject: [PATCH 2/4] Reject stale switch callbacks and modified point selections --- src-tauri/src/input.rs | 12 ++++++++++-- src-tauri/src/lib.rs | 6 +++++- src-tauri/src/point_scan.rs | 4 ++++ src-tauri/src/point_scan_host.rs | 19 +++++++++++++++++++ src-tauri/src/point_scan_runtime.rs | 17 ++++++++++++++--- src-tauri/tests/config.rs | 4 ++++ 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index 7304765a..7e2afce1 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -1532,7 +1532,7 @@ mod tests { #[test] fn point_scan_clicks_once_at_absolute_point_with_fake_input() { let mut input = DesktopInput::new(FakeInjector::default()); - crate::point_scan::click(&mut input, (-640, 512)).unwrap(); + crate::point_scan::click(&mut input, (-640, 512), true).unwrap(); assert_eq!(input.injector.absolute_moves, vec![(-640, 512)]); assert_eq!(input.injector.clicks, vec![(MouseButton::Left, 1)]); assert!(input.injector.keys.is_empty()); @@ -1542,7 +1542,15 @@ mod tests { fn point_scan_refuses_to_move_during_drag() { let mut input = DesktopInput::new(FakeInjector::default()); input.held_button = Some(MouseButton::Left); - assert!(crate::point_scan::click(&mut input, (30, 40)).is_err()); + assert!(crate::point_scan::click(&mut input, (30, 40), true).is_err()); + assert!(input.injector.absolute_moves.is_empty()); + assert!(input.injector.clicks.is_empty()); + } + + #[test] + fn point_scan_does_not_inject_a_modified_click() { + let mut input = DesktopInput::new(FakeInjector::default()); + assert!(crate::point_scan::click(&mut input, (30, 40), false).is_err()); assert!(input.injector.absolute_moves.is_empty()); assert!(input.injector.clicks.is_empty()); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aa96943d..c3af72d3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1257,7 +1257,11 @@ fn point_scan_click(app: &AppHandle, point: (i32, i32)) -> Result<(), String> { // Reuse the production input adapter, independent of Bluetooth availability. let injector = enigo::Enigo::new(&enigo::Settings::default()) .map_err(|_| "Point scan input could not be initialized.".to_string())?; - point_scan::click(&mut input::DesktopInput::new(injector), point) + point_scan::click( + &mut input::DesktopInput::new(injector), + point, + point_scan_host::modifiers_released(), + ) } pub fn run() { diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index 3e9f62b9..1917b818 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -384,7 +384,11 @@ fn advance(value: f64, start: f64, length: f64, delta: f64) -> f64 { pub fn click( input: &mut crate::input::DesktopInput, point: (i32, i32), + modifiers_released: bool, ) -> Result<(), String> { + if !modifiers_released { + return Err("Release modifier keys before selecting a point.".into()); + } if input.has_active_switch_session() || input.has_active_drag() { return Err("End switch forwarding or dragging before using point scan.".into()); } diff --git a/src-tauri/src/point_scan_host.rs b/src-tauri/src/point_scan_host.rs index c4d83b76..b76d61d8 100644 --- a/src-tauri/src/point_scan_host.rs +++ b/src-tauri/src/point_scan_host.rs @@ -159,3 +159,22 @@ mod platform { } } pub use platform::Host; + +pub fn modifiers_released() -> bool { + #[cfg(target_os = "windows")] + { + [0x10, 0x11, 0x12, 0x5B, 0x5C].iter().all(|key| unsafe { + windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState(*key) >= 0 + }) + } + #[cfg(target_os = "macos")] + { + use objc2_app_kit::{NSEvent, NSEventModifierFlags as Flags}; + !NSEvent::modifierFlags_class() + .intersects(Flags::Shift | Flags::Control | Flags::Option | Flags::Command) + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + false + } +} diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 5f8a70f4..44db9186 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -7,7 +7,7 @@ use serde::Serialize; use std::{ cell::RefCell, sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, Mutex, }, time::Instant, @@ -18,6 +18,7 @@ use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; thread_local! {static HOST:RefCell>=const{RefCell::new(None)};} pub struct Controller { enabled: AtomicBool, + generation: AtomicU64, data: Mutex, } struct Data { @@ -49,6 +50,7 @@ impl Controller { .unwrap_or_default(); Self { enabled: AtomicBool::new(false), + generation: AtomicU64::new(0), data: Mutex::new(Data { config, engine: None, @@ -92,6 +94,7 @@ pub fn cancel(app: &AppHandle) { fn disable(app: &AppHandle, message: &str) { let c = app.state::(); c.enabled.store(false, Ordering::SeqCst); + c.generation.fetch_add(1, Ordering::SeqCst); let keys = { let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); d.engine = None; @@ -112,6 +115,7 @@ fn disable(app: &AppHandle, message: &str) { } pub fn configure(app: &AppHandle, config: Config, enabled: bool) -> Result { config.validate()?; + let path = config_path(app)?; disable(app, "Point scan is off."); if enabled { crate::point_scan_prepare(app)?; @@ -121,13 +125,21 @@ pub fn configure(app: &AppHandle, config: Config, enabled: bool) -> Result(()) })?; + let generation = app.state::().generation.load(Ordering::SeqCst); for (index, key) in config.keys().iter().enumerate() { if let Err(error) = app .global_shortcut() .on_shortcut(*key, move |app, _, event| { let handle = app.clone(); let _ = app.run_on_main_thread(move || { - switch(&handle, index, event.state == ShortcutState::Pressed) + if handle + .state::() + .generation + .load(Ordering::SeqCst) + == generation + { + switch(&handle, index, event.state == ShortcutState::Pressed); + } }); }) { @@ -142,7 +154,6 @@ pub fn configure(app: &AppHandle, config: Config, enabled: bool) -> Result Result<(), String> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; diff --git a/src-tauri/tests/config.rs b/src-tauri/tests/config.rs index 7f669c2a..b5333618 100644 --- a/src-tauri/tests/config.rs +++ b/src-tauri/tests/config.rs @@ -81,6 +81,8 @@ fn main_window_has_application_commands_and_core_defaults() { assert!(permissions.contains(&"core:default")); for permission in [ "allow-get-app-state", + "allow-get-point-scan", + "allow-configure-point-scan", "allow-approve-pairing", "allow-save-settings", "allow-list-switch-profiles", @@ -111,6 +113,8 @@ fn modifier_overlay_cannot_invoke_sensitive_application_commands() { let overlay = capability(include_str!("../capabilities/modifier-overlay.json")); let permissions = permission_names(&overlay); for denied in [ + "allow-get-point-scan", + "allow-configure-point-scan", "allow-save-settings", "allow-approve-pairing", "allow-reject-pairing", From 9693a7a2a27610eaa96a46e1e3e7577ddfd1d4ba Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 12 Sep 2026 13:12:02 +0100 Subject: [PATCH 3/4] Move scanning into Settings and extract reusable scan core and runtime --- docs/point-scan.md | 14 +- src-tauri/src/input.rs | 6 +- src-tauri/src/lib.rs | 13 +- src-tauri/src/macos.rs | 8 +- src-tauri/src/point_scan.rs | 316 +++++++-------- src-tauri/src/point_scan_activation.rs | 15 + src-tauri/src/point_scan_runtime.rs | 318 ++------------- .../src/{point_scan_host.rs => scan_host.rs} | 2 +- src-tauri/src/scanning.rs | 366 ++++++++++++++++++ src-tauri/src/scanning_runtime.rs | 365 +++++++++++++++++ src-tauri/src/windows_runtime.rs | 6 +- src/App.tsx | 9 +- src/PointScan.test.tsx | 169 +++++++- src/PointScan.tsx | 68 ---- src/scanning/useScanning.ts | 185 +++++++++ src/settings/ScanningSection.tsx | 212 ++++++++++ src/settings/SettingsView.tsx | 9 +- src/settings/settings.test.tsx | 15 +- src/styles.css | 4 - 19 files changed, 1530 insertions(+), 570 deletions(-) create mode 100644 src-tauri/src/point_scan_activation.rs rename src-tauri/src/{point_scan_host.rs => scan_host.rs} (99%) create mode 100644 src-tauri/src/scanning.rs create mode 100644 src-tauri/src/scanning_runtime.rs delete mode 100644 src/PointScan.tsx create mode 100644 src/scanning/useScanning.ts create mode 100644 src/settings/ScanningSection.tsx diff --git a/docs/point-scan.md b/docs/point-scan.md index 5fad836b..1d54dead 100644 --- a/docs/point-scan.md +++ b/docs/point-scan.md @@ -2,7 +2,7 @@ Point scan ports the Android line-only and grid-then-line techniques to Switchify PC. The reference source is `switchifyapp/switchify-android` commit `856720d8747e2f3d1724a572bf754ffae05df299`, especially `PointScanLineManager`, `PointScanBlockManager`, and `ContinuousLineSpeedUtils`. -Open **Point scan**, choose automatic or manual scanning, and enable it. Local keyboard-emulating switch interfaces can use Space to select, Enter to step forward, Backspace to step backward, and F8 to pause or resume. The four keys can be changed. Escape always disables scanning and releases the reserved keys. Scanning is off at every application startup. +Open **Settings → Scanning**, choose automatic or manual scanning, and enable it. Local keyboard-emulating switch interfaces can use Space to select, Enter to step forward, Backspace to step backward, and F8 to pause or resume. The four keys can be changed. Escape always disables scanning and releases the reserved keys. Scanning is off at every application startup. Focus the intended application and press Select to start. Line mode chooses X, then Y, and clicks once. Grid mode chooses a row, then a cell, before the same line sequence. Selecting happens on switch release; holding Select freezes the position and repeat keydowns do not select again. The scan resets after a click and waits for the next Select. Movement wraps at the selected region's edges. Android's five speeds are 45, 75, 120, 180, and 270 logical units per second, with delayed ticks capped at 250 ms. @@ -10,6 +10,18 @@ The scan uses the monitor under the pointer when it starts. Windows uses native This first port uses switches attached to the computer. Android must be disconnected before enabling it, and an Android connection cancels local scanning. Existing authenticated Bluetooth commands and switch-forwarding profiles are unchanged. Input permission is still required on macOS. Point settings use a separate `point-scan.json` in the existing Tauri application configuration directory, so old settings and pairing schemas remain unchanged. +Changes save automatically, including when you leave Settings. Enabling waits for pending saves; a failed save keeps your edits and offers Retry save. Configuration is locked while scanning is enabled. Navigating away does not stop scanning. + The engine is independent of OS input. Activation uses the existing `InputInjector` adapter. Automated tests use a fake adapter and never move the real pointer. The Tauri global-shortcut plugin supplies global press/release events on both platforms; the existing dependencies did not include a switch-key listener. Only the main window can configure scanning through IPC. Manual validation should cover both modes, every speed, manual movement, holding and releasing switches, Escape, focus retention, display changes, mixed scaling, a Bluetooth connection during scanning, and application exit. Run macOS input checks through `npm run macos:run` to retain the stable Accessibility identity. No physical switch or real desktop input is exercised by automated tests. + +## Reusing scanning in Switchify PC + +`scanning.rs` is the pure shared core. `Session` owns start, pause, completion, cancellation and bounded automatic timing. `SwitchInput` turns presses and releases into semantic actions, suppresses repeat events and rejects stale shortcut generations. `Cycle` and `Interval` provide traversal and timing. A technique implements `start`, `advance`, `handle`, `reset`, `frame` and `phase`, with its own typed selection result. The test-only item technique exercises this contract without pointer coordinates or desktop input. + +`point_scan.rs` implements row/cell and X/Y selection. Its engine receives only point settings; the existing flat `Config` maps into shared switch settings and point settings. `scanning_runtime.rs` owns shortcut registration, session ticking, persistence, event publication and shutdown. Its `Adapter` supplies configuration, environment validation and selection activation. `scan_host.rs` renders shared frame strips through nonactivating Windows and macOS windows. `point_scan_runtime.rs` supplies the display and click adapters, and `point_scan_activation.rs` checks input state before clicking through `InputInjector`. + +Only one local technique is installed at a time. A future technique can use the shared controller with its own adapter; adding a technique chooser is separate work. Transport cleanup calls the shared scan service, which invalidates callbacks immediately and releases native resources on the main thread. Queued cancellation cannot stop a newer registration. + +The existing `point-scan.json`, `get_point_scan`, `configure_point_scan` and `point-scan-changed` retain their field names and payload shapes. No production item scanner, UI Automation scanner or keyboard integration is added here. diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index 7e2afce1..c4b8df48 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -1532,7 +1532,7 @@ mod tests { #[test] fn point_scan_clicks_once_at_absolute_point_with_fake_input() { let mut input = DesktopInput::new(FakeInjector::default()); - crate::point_scan::click(&mut input, (-640, 512), true).unwrap(); + crate::point_scan_activation::click(&mut input, (-640, 512), true).unwrap(); assert_eq!(input.injector.absolute_moves, vec![(-640, 512)]); assert_eq!(input.injector.clicks, vec![(MouseButton::Left, 1)]); assert!(input.injector.keys.is_empty()); @@ -1542,7 +1542,7 @@ mod tests { fn point_scan_refuses_to_move_during_drag() { let mut input = DesktopInput::new(FakeInjector::default()); input.held_button = Some(MouseButton::Left); - assert!(crate::point_scan::click(&mut input, (30, 40), true).is_err()); + assert!(crate::point_scan_activation::click(&mut input, (30, 40), true).is_err()); assert!(input.injector.absolute_moves.is_empty()); assert!(input.injector.clicks.is_empty()); } @@ -1550,7 +1550,7 @@ mod tests { #[test] fn point_scan_does_not_inject_a_modified_click() { let mut input = DesktopInput::new(FakeInjector::default()); - assert!(crate::point_scan::click(&mut input, (30, 40), false).is_err()); + assert!(crate::point_scan_activation::click(&mut input, (30, 40), false).is_err()); assert!(input.injector.absolute_moves.is_empty()); assert!(input.injector.clicks.is_empty()); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c3af72d3..e8e1953b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,9 +15,12 @@ mod modifier_overlay; mod mouse_repeat; mod overlay; mod point_scan; -mod point_scan_host; +mod point_scan_activation; mod point_scan_runtime; mod protocol; +mod scan_host; +mod scanning; +mod scanning_runtime; mod state; mod storage; mod telemetry; @@ -202,7 +205,7 @@ pub(crate) fn sync_tray_state(app: &AppHandle, state: &AppState) { } fn finish_app_exit(app: &AppHandle) { - point_scan_runtime::cancel(app); + scanning_runtime::cancel(app); app.state::().cancel(app); let model = app.state::(); let _ = platform_disconnect_all(app, &model.shared); @@ -369,7 +372,7 @@ fn finish_disconnect( overlay: &overlay::CursorOverlay, modifier_overlay: &modifier_overlay::ModifierOverlay, ) -> AppState { - point_scan_runtime::cancel(app); + scanning_runtime::cancel(app); app.state::().cancel(app); overlay.end_session(); modifier_overlay.end_session(); @@ -1257,10 +1260,10 @@ fn point_scan_click(app: &AppHandle, point: (i32, i32)) -> Result<(), String> { // Reuse the production input adapter, independent of Bluetooth availability. let injector = enigo::Enigo::new(&enigo::Settings::default()) .map_err(|_| "Point scan input could not be initialized.".to_string())?; - point_scan::click( + point_scan_activation::click( &mut input::DesktopInput::new(injector), point, - point_scan_host::modifiers_released(), + scan_host::modifiers_released(), ) } diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 8919b976..3b9cf0a2 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -391,7 +391,7 @@ pub fn reject_pairing( } pub fn disconnect_all(app: &AppHandle, shared: &SharedModel) -> Result<(), String> { - crate::point_scan_runtime::cancel(app); + crate::scanning_runtime::cancel(app); app.state::().cancel(app); with_runtime(|runtime| { runtime.stop_all_repeats(); @@ -970,7 +970,7 @@ impl MacRuntime { let _ = input.release_all(); input.end_control_session(); } - crate::point_scan_runtime::cancel(&self.app); + crate::scanning_runtime::cancel(&self.app); self.app.state::().end_session(); self.app.state::().end_session(); self.set_bluetooth(BluetoothState::Advertising); @@ -1405,7 +1405,7 @@ impl MacRuntime { } } if command.command_type == "connection.disconnecting" { - crate::point_scan_runtime::cancel(&self.app); + crate::scanning_runtime::cancel(&self.app); overlay.end_session(); } } @@ -2016,7 +2016,7 @@ impl MacRuntime { let _ = input.release_all(); input.end_control_session(); } - crate::point_scan_runtime::cancel(&self.app); + crate::scanning_runtime::cancel(&self.app); self.app.state::().end_session(); self.app.state::().end_session(); } diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index 1917b818..f4b1c87f 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -1,4 +1,5 @@ //! Android point scanning, with desktop coordinates and no OS input in the engine. +use crate::scanning::{Action, Cycle, Frame, Interval, Rect, SwitchSettings, Technique, TICK_MS}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] @@ -37,111 +38,75 @@ impl Default for Config { } } impl Config { - pub fn keys(&self) -> [&str; 5] { - [ - &self.select_key, - &self.next_key, - &self.back_key, - &self.pause_key, - "Escape", - ] + pub fn switches(&self) -> SwitchSettings { + SwitchSettings { + automatic: self.automatic, + select_key: self.select_key.clone(), + next_key: self.next_key.clone(), + back_key: self.back_key.clone(), + pause_key: self.pause_key.clone(), + } + } + pub fn point(&self) -> PointSettings { + PointSettings { + mode: self.mode, + speed: self.speed, + grid_size: self.grid_size, + block_interval_ms: self.block_interval_ms, + } } pub fn validate(&self) -> Result<(), String> { + self.switches().validate()?; + self.point().validate() + } +} +#[derive(Clone)] +pub struct PointSettings { + pub mode: Mode, + pub speed: usize, + pub grid_size: usize, + pub block_interval_ms: u64, +} +impl PointSettings { + fn validate(&self) -> Result<(), String> { if self.speed > 4 || !(2..=10).contains(&self.grid_size) || !(250..=5000).contains(&self.block_interval_ms) { return Err("Point scan speed, grid size, or interval is invalid.".into()); } - let keys = self.keys(); - for (index, key) in keys.iter().enumerate() { - let function = key - .strip_prefix('F') - .and_then(|n| n.parse::().ok()) - .is_some_and(|n| (1..=24).contains(&n)); - if !function - && ![ - "Space", - "Enter", - "Backspace", - "Escape", - "ArrowUp", - "ArrowDown", - "ArrowLeft", - "ArrowRight", - ] - .contains(key) - { - return Err("Choose a supported switch key.".into()); - } - if keys[..index].contains(key) { - return Err( - "Each switch action must use a different key. Escape is reserved for cancel." - .into(), - ); - } - } Ok(()) } } - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Rect { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, -} -impl Rect { - pub fn valid(self) -> bool { - [self.x, self.y, self.width, self.height] - .iter() - .all(|n| n.is_finite()) - && self.width >= 2.0 - && self.height >= 2.0 - } -} -#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize)] #[serde(rename_all = "camelCase")] pub enum Phase { + #[default] Idle, Row, Cell, X, Y, } -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum Action { - Select, - Next, - Back, - Pause, - Cancel, -} -pub const ACTIONS: [Action; 5] = [ - Action::Select, - Action::Next, - Action::Back, - Action::Pause, - Action::Cancel, -]; - pub struct Engine { - pub config: Config, + pub config: PointSettings, pub screen: Rect, pub region: Rect, pub phase: Phase, - pub paused: bool, pub x: f64, pub y: f64, - pub row: usize, - pub cell: usize, + pub row: Cycle, + pub cell: Cycle, pub units_per_logical_pixel: f64, direction: f64, - block_elapsed: u64, + block_elapsed: Interval, } impl Engine { - pub fn new(config: Config, screen: Rect, units_per_logical_pixel: f64) -> Result { + pub fn new( + config: PointSettings, + screen: Rect, + units_per_logical_pixel: f64, + ) -> Result { config.validate()?; if !screen.valid() || !units_per_logical_pixel.is_finite() || units_per_logical_pixel <= 0.0 { @@ -152,49 +117,33 @@ impl Engine { screen, region: screen, phase: Phase::Idle, - paused: false, x: screen.x, y: screen.y, - row: 0, - cell: 0, + row: Cycle::default(), + cell: Cycle::default(), direction: 1.0, - block_elapsed: 0, + block_elapsed: Interval::default(), units_per_logical_pixel, }) } pub fn reset(&mut self) { self.phase = Phase::Idle; - self.paused = false; self.region = self.screen; self.x = self.screen.x; self.y = self.screen.y; - self.row = 0; - self.cell = 0; + self.row.reset(); + self.cell.reset(); self.direction = 1.0; - self.block_elapsed = 0; + self.block_elapsed.reset(); } - pub fn action(&mut self, action: Action) -> Option<(i32, i32)> { - if action == Action::Cancel { - self.reset(); - return None; - } - if self.phase == Phase::Idle { - if action == Action::Select { - self.phase = if self.config.mode == Mode::Grid { - Phase::Row - } else { - Phase::X - }; - } - return None; - } + fn select_action(&mut self, action: Action) -> Option<(i32, i32)> { match action { Action::Select => { - self.block_elapsed = 0; + self.block_elapsed.reset(); match self.phase { Phase::Row => { self.phase = Phase::Cell; - self.cell = 0; + self.cell.reset(); } Phase::Cell => { self.region = self.cell_rect(); @@ -218,7 +167,6 @@ impl Engine { .clamp(self.region.y, self.region.y + self.region.height - 1.0) as i32, ); - self.reset(); return Some(point); } Phase::Idle => {} @@ -226,26 +174,20 @@ impl Engine { } Action::Next | Action::Back => { self.direction = if action == Action::Next { 1.0 } else { -1.0 }; - self.step(33); + self.step(TICK_MS); } - Action::Pause => self.paused = !self.paused, - Action::Cancel => {} + Action::Pause | Action::Cancel => {} } None } - pub fn tick(&mut self, elapsed_ms: u64) { - if !self.config.automatic || self.paused || self.phase == Phase::Idle { + fn advance_time(&mut self, elapsed_ms: u64) { + if matches!(self.phase, Phase::Row | Phase::Cell) + && !self + .block_elapsed + .elapsed(elapsed_ms, self.config.block_interval_ms) + { return; } - // Android caps delayed timer callbacks at 250 ms to avoid large jumps. - let elapsed_ms = elapsed_ms.min(250); - if matches!(self.phase, Phase::Row | Phase::Cell) { - self.block_elapsed += elapsed_ms; - if self.block_elapsed < self.config.block_interval_ms { - return; - } - self.block_elapsed %= self.config.block_interval_ms; - } self.step(elapsed_ms); } fn step(&mut self, elapsed_ms: u64) { @@ -255,14 +197,8 @@ impl Engine { / 1000.0) .max(1.0); match self.phase { - Phase::Row => { - self.row = (self.row as i32 + self.direction as i32) - .rem_euclid(self.config.grid_size as i32) as usize - } - Phase::Cell => { - self.cell = (self.cell as i32 + self.direction as i32) - .rem_euclid(self.config.grid_size as i32) as usize - } + Phase::Row => self.row.step(self.config.grid_size, self.direction > 0.0), + Phase::Cell => self.cell.step(self.config.grid_size, self.direction > 0.0), Phase::X => { self.x = advance( self.x, @@ -285,7 +221,8 @@ impl Engine { pub fn row_rect(&self) -> Rect { Rect { x: self.screen.x, - y: self.screen.y + self.screen.height * self.row as f64 / self.config.grid_size as f64, + y: self.screen.y + + self.screen.height * self.row.index() as f64 / self.config.grid_size as f64, width: self.screen.width, height: self.screen.height / self.config.grid_size as f64, } @@ -293,7 +230,7 @@ impl Engine { pub fn cell_rect(&self) -> Rect { let row = self.row_rect(); Rect { - x: row.x + row.width * self.cell as f64 / self.config.grid_size as f64, + x: row.x + row.width * self.cell.index() as f64 / self.config.grid_size as f64, width: row.width / self.config.grid_size as f64, ..row } @@ -380,50 +317,68 @@ fn advance(value: f64, start: f64, length: f64, delta: f64) -> f64 { } } -/// Keep injection behind the existing adapter; no tests use the system adapter. -pub fn click( - input: &mut crate::input::DesktopInput, - point: (i32, i32), - modifiers_released: bool, -) -> Result<(), String> { - if !modifiers_released { - return Err("Release modifier keys before selecting a point.".into()); +impl Technique for Engine { + type Selection = (i32, i32); + type Phase = Phase; + fn start(&mut self) { + self.reset(); + self.phase = if self.config.mode == Mode::Grid { + Phase::Row + } else { + Phase::X + }; + } + fn advance(&mut self, ms: u64) { + self.advance_time(ms); } - if input.has_active_switch_session() || input.has_active_drag() { - return Err("End switch forwarding or dragging before using point scan.".into()); + fn handle(&mut self, action: Action) -> Option { + self.select_action(action) + } + fn reset(&mut self) { + Engine::reset(self); + } + fn frame(&self) -> Frame { + Frame { + strips: self.lines(), + } + } + fn phase(&self) -> Phase { + self.phase } - input.move_pointer_absolute(point.0, point.1)?; - input.click_pointer(crate::protocol::MouseButton::Left, 1) } #[cfg(test)] mod tests { use super::*; - fn engine(config: Config) -> Engine { - Engine::new( - config, - Rect { - x: -1000.0, - y: 50.0, - width: 999.0, - height: 701.0, - }, - 1.0, + use crate::scanning::Session; + fn engine(config: Config) -> Session { + Session::new( + Engine::new( + config.point(), + Rect { + x: -1000.0, + y: 50.0, + width: 999.0, + height: 701.0, + }, + 1.0, + ) + .unwrap(), + config.automatic, ) - .unwrap() } #[test] fn line_selects_x_then_y_and_resets() { let mut e = engine(Config::default()); assert_eq!(e.action(Action::Select), None); - e.tick(100); - let x = e.x; + e.tick(100, false); + let x = e.technique.x; e.action(Action::Select); - e.tick(100); - assert_eq!(e.x, x); + e.tick(100, false); + assert_eq!(e.technique.x, x); assert_eq!(e.action(Action::Select), Some((-988, 62))); - assert_eq!(e.phase, Phase::Idle); - assert!(e.lines().is_empty()); + assert_eq!(e.technique.phase, Phase::Idle); + assert!(e.frame().strips.is_empty()); } #[test] fn grid_uses_row_then_cell_and_covers_remainder() { @@ -434,29 +389,29 @@ mod tests { }); e.action(Action::Select); e.action(Action::Back); - assert_eq!(e.row, 2); + assert_eq!(e.technique.row.index(), 2); e.action(Action::Select); e.action(Action::Back); e.action(Action::Select); - assert_eq!(e.region.x + e.region.width, -1.0); - assert!((e.region.y + e.region.height - 751.0).abs() < 0.001); - assert_eq!(e.phase, Phase::X); + assert_eq!(e.technique.region.x + e.technique.region.width, -1.0); + assert!((e.technique.region.y + e.technique.region.height - 751.0).abs() < 0.001); + assert_eq!(e.technique.phase, Phase::X); } #[test] fn pause_resume_wrap_and_delayed_ticks() { let mut e = engine(Config::default()); e.action(Action::Select); e.action(Action::Pause); - e.tick(1000); - assert_eq!(e.x, -1000.0); + e.tick(1000, false); + assert_eq!(e.technique.x, -1000.0); e.action(Action::Pause); - e.tick(10000); - assert_eq!(e.x, -970.0); - e.x = -1000.0; + e.tick(10000, false); + assert_eq!(e.technique.x, -970.0); + e.technique.x = -1000.0; e.action(Action::Back); - assert_eq!(e.x, -2.0); + assert_eq!(e.technique.x, -2.0); e.action(Action::Cancel); - assert_eq!(e.phase, Phase::Idle); + assert_eq!(e.technique.phase, Phase::Idle); } #[test] fn manual_mode_only_moves_on_steps() { @@ -465,10 +420,31 @@ mod tests { ..Config::default() }); e.action(Action::Select); - e.tick(250); - assert_eq!(e.x, -1000.0); + e.tick(250, false); + assert_eq!(e.technique.x, -1000.0); e.action(Action::Next); - assert!(e.x > -1000.0); + assert!(e.technique.x > -1000.0); + } + #[test] + fn existing_flat_settings_round_trip_without_schema_changes() { + let 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(); + config.validate().unwrap(); + assert_eq!(serde_json::to_value(&config).unwrap(), json); + assert!(!config.switches().automatic); + assert_eq!(config.point().grid_size, 7); + let view = crate::scanning_runtime::View { + config, + enabled: true, + phase: Phase::Cell, + paused: true, + message: "Ready".to_string(), + supported: true, + }; + assert_eq!( + serde_json::to_value(view).unwrap(), + serde_json::json!({"config":json,"enabled":true,"phase":"cell","paused":true,"message":"Ready","supported":true}) + ); } #[test] fn legacy_empty_config_and_validation() { @@ -482,7 +458,7 @@ mod tests { .is_err()); assert!(Config { speed: 5, ..c }.validate().is_err()); assert!(Engine::new( - Config::default(), + Config::default().point(), Rect { x: 0.0, y: 0.0, diff --git a/src-tauri/src/point_scan_activation.rs b/src-tauri/src/point_scan_activation.rs new file mode 100644 index 00000000..ccd1e3b9 --- /dev/null +++ b/src-tauri/src/point_scan_activation.rs @@ -0,0 +1,15 @@ +/// Keep injection behind the existing adapter; no tests use the system adapter. +pub fn click( + input: &mut crate::input::DesktopInput, + point: (i32, i32), + modifiers_released: bool, +) -> Result<(), String> { + if !modifiers_released { + return Err("Release modifier keys before selecting a point.".into()); + } + if input.has_active_switch_session() || input.has_active_drag() { + return Err("End switch forwarding or dragging before using point scan.".into()); + } + input.move_pointer_absolute(point.0, point.1)?; + input.click_pointer(crate::protocol::MouseButton::Left, 1) +} diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 44db9186..1e908668 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -1,187 +1,44 @@ +//! Compatibility commands and desktop adapters for the point technique. use crate::{ display_navigation::{self, Display}, - point_scan::{Action, Config, Engine, Phase, Rect, ACTIONS}, - point_scan_host::Host, + point_scan::{Config, Engine, Phase}, + scanning::Rect, + scanning_runtime::{self, Adapter}, }; -use serde::Serialize; -use std::{ - cell::RefCell, - sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, - Mutex, - }, - time::Instant, -}; -use tauri::{AppHandle, Emitter, Manager}; -use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; - -thread_local! {static HOST:RefCell>=const{RefCell::new(None)};} -pub struct Controller { - enabled: AtomicBool, - generation: AtomicU64, - data: Mutex, -} -struct Data { - config: Config, - engine: Option, - display: Option, - registered: Vec, - pressed: [bool; 5], - last_tick: Instant, - message: String, +use tauri::AppHandle; +pub struct PointScan; +pub type Controller = scanning_runtime::Controller; +pub type View = scanning_runtime::View; +pub fn configure(app: &AppHandle, config: Config, enabled: bool) -> Result { + scanning_runtime::configure::(app, config, enabled) } -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct View { - pub config: Config, - pub enabled: bool, - pub phase: Phase, - pub paused: bool, - pub message: String, - pub supported: bool, +pub fn install(app: &AppHandle) { + scanning_runtime::install::(app); } -impl Controller { - pub fn new(app: &AppHandle) -> Self { - let config = config_path(app) - .ok() - .and_then(|p| std::fs::read(p).ok()) - .and_then(|b| serde_json::from_slice::(&b).ok()) - .filter(|c| c.validate().is_ok()) - .unwrap_or_default(); - Self { - enabled: AtomicBool::new(false), - generation: AtomicU64::new(0), - data: Mutex::new(Data { - config, - engine: None, - display: None, - registered: vec![], - pressed: [false; 5], - last_tick: Instant::now(), - message: "Point scan is off.".into(), - }), - } +impl Adapter for PointScan { + type Config = Config; + type Technique = Engine; + type Environment = Display; + const EVENT: &'static str = "point-scan-changed"; + const FILE: &'static str = "point-scan.json"; + fn validate(config: &Config) -> Result<(), String> { + config.validate() } - pub fn view(&self) -> View { - let d = self.data.lock().unwrap_or_else(|p| p.into_inner()); - View { - config: d.config.clone(), - enabled: self.enabled.load(Ordering::SeqCst), - phase: d.engine.as_ref().map_or(Phase::Idle, |e| e.phase), - paused: d.engine.as_ref().is_some_and(|e| e.paused), - message: d.message.clone(), - supported: cfg!(any(target_os = "windows", target_os = "macos")), - } - } -} -fn config_path(app: &AppHandle) -> Result { - app.path() - .app_config_dir() - .map(|p| p.join("point-scan.json")) - .map_err(|e| e.to_string()) -} -fn publish(app: &AppHandle) { - let _ = app.emit("point-scan-changed", app.state::().view()); -} -/// Can be called from transport threads while their input lock is held. -pub fn cancel(app: &AppHandle) { - if let Some(c) = app.try_state::() { - c.enabled.store(false, Ordering::SeqCst); + fn switches(config: &Config) -> crate::scanning::SwitchSettings { + config.switches() } - let handle = app.clone(); - let _ = app.run_on_main_thread(move || disable(&handle, "Point scan stopped.")); -} -fn disable(app: &AppHandle, message: &str) { - let c = app.state::(); - c.enabled.store(false, Ordering::SeqCst); - c.generation.fetch_add(1, Ordering::SeqCst); - let keys = { - let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); - d.engine = None; - d.display = None; - d.pressed = [false; 5]; - d.message = message.into(); - std::mem::take(&mut d.registered) - }; - for key in keys { - let _ = app.global_shortcut().unregister(key.as_str()); + fn create(app: &AppHandle, config: Config) -> Result<(Engine, Display), String> { + new_engine(app, config) } - HOST.with(|host| { - if let Some(host) = host.borrow_mut().as_mut() { - host.hide(); - } - }); - publish(app); -} -pub fn configure(app: &AppHandle, config: Config, enabled: bool) -> Result { - config.validate()?; - let path = config_path(app)?; - disable(app, "Point scan is off."); - if enabled { - crate::point_scan_prepare(app)?; - HOST.with(|slot| { - if slot.borrow().is_none() { - *slot.borrow_mut() = Some(Host::new()?); - } - Ok::<_, String>(()) - })?; - let generation = app.state::().generation.load(Ordering::SeqCst); - for (index, key) in config.keys().iter().enumerate() { - if let Err(error) = app - .global_shortcut() - .on_shortcut(*key, move |app, _, event| { - let handle = app.clone(); - let _ = app.run_on_main_thread(move || { - if handle - .state::() - .generation - .load(Ordering::SeqCst) - == generation - { - switch(&handle, index, event.state == ShortcutState::Pressed); - } - }); - }) - { - disable(app, "A switch key is already in use. Choose another key."); - return Err(format!("Could not reserve {key}: {error}")); - } - app.state::() - .data - .lock() - .unwrap_or_else(|p| p.into_inner()) - .registered - .push((*key).into()); - } + fn validate_environment(app: &AppHandle, display: Option<&Display>) -> Result<(), String> { + validate_display(app, display) } - let save = || -> Result<(), String> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - std::fs::write( - &path, - serde_json::to_vec_pretty(&config).map_err(|e| e.to_string())?, - ) - .map_err(|e| e.to_string()) - }; - if let Err(error) = save() { - disable(app, "Point scan settings could not be saved."); - return Err(error); + fn prepare(app: &AppHandle) -> Result<(), String> { + crate::point_scan_prepare(app) } - let c = app.state::(); - { - let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); - d.config = config; - d.message = if enabled { - "Ready. Press the select switch to begin." - } else { - "Point scan is off." - } - .into(); + fn activate(app: &AppHandle, point: (i32, i32)) -> Result<(), String> { + crate::point_scan_click(app, point) } - c.enabled.store(enabled, Ordering::SeqCst); - publish(app); - Ok(c.view()) } fn new_engine(app: &AppHandle, config: Config) -> Result<(Engine, Display), String> { let (cursor, displays) = display_navigation::displays(app).map_err(|e| e.message)?; @@ -194,7 +51,7 @@ fn new_engine(app: &AppHandle, config: Config) -> Result<(Engine, Display), Stri 1.0 }; let e = Engine::new( - config, + config.point(), Rect { x: display.x.into(), y: display.y.into(), @@ -205,57 +62,6 @@ fn new_engine(app: &AppHandle, config: Config) -> Result<(Engine, Display), Stri )?; Ok((e, display)) } -fn switch(app: &AppHandle, index: usize, pressed: bool) { - let c = app.state::(); - if !c.enabled.load(Ordering::SeqCst) { - return; - } - if ACTIONS[index] == Action::Cancel { - if pressed { - disable(app, "Point scan cancelled. Switch keys released."); - } - return; - } - let result = (|| -> Result<(), String> { - let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); - if pressed { - d.pressed[index] = true; - return Ok(()); - } - if !std::mem::replace(&mut d.pressed[index], false) { - return Ok(()); - } - if d.engine.as_ref().is_none_or(|e| e.phase == Phase::Idle) - && ACTIONS[index] == Action::Select - { - let (engine, display) = new_engine(app, d.config.clone())?; - d.engine = Some(engine); - d.display = Some(display); - crate::point_scan_prepare(app)?; - } - let point = d.engine.as_mut().and_then(|e| e.action(ACTIONS[index])); - d.last_tick = Instant::now(); - let display = d.display.clone(); - drop(d); - if let Some(point) = point { - validate_display(app, display.as_ref())?; - HOST.with(|host| { - if let Some(h) = host.borrow_mut().as_mut() { - h.hide(); - } - }); - if c.enabled.load(Ordering::SeqCst) { - crate::point_scan_click(app, point)?; - } - } - render(app) - })(); - if let Err(error) = result { - disable(app, &error); - } else { - publish(app); - } -} fn validate_display(app: &AppHandle, display: Option<&Display>) -> Result<(), String> { if let Some(expected) = display { let (_, displays) = display_navigation::displays(app).map_err(|e| e.message)?; @@ -265,65 +71,3 @@ fn validate_display(app: &AppHandle, display: Option<&Display>) -> Result<(), St } Ok(()) } -fn render(app: &AppHandle) -> Result<(), String> { - let c = app.state::(); - let d = c.data.lock().unwrap_or_else(|p| p.into_inner()); - let lines = d.engine.as_ref().map_or_else(Vec::new, Engine::lines); - HOST.with(|host| { - if let Some(host) = host.borrow_mut().as_mut() { - host.render(&lines) - } else { - Ok(()) - } - }) -} -fn tick(app: &AppHandle) { - let c = app.state::(); - if !c.enabled.load(Ordering::SeqCst) { - return; - } - if app.state::().snapshot().bluetooth - == crate::state::BluetoothState::Connected - { - disable(app, "Android connected. Local point scan stopped."); - return; - } - let result = (|| -> Result<(), String> { - let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); - validate_display(app, d.display.as_ref())?; - let now = Instant::now(); - let elapsed = now.duration_since(d.last_tick).as_millis() as u64; - d.last_tick = now; - if !d.pressed[0] { - if let Some(engine) = d.engine.as_mut() { - engine.tick(elapsed); - } - } - drop(d); - render(app) - })(); - if let Err(error) = result { - disable(app, &error); - } -} -pub fn install(app: &AppHandle) { - app.manage(Controller::new(app)); - let app = app.clone(); - tauri::async_runtime::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_millis(33)).await; - let handle = app.clone(); - let (tx, rx) = tokio::sync::oneshot::channel(); - if app - .run_on_main_thread(move || { - tick(&handle); - let _ = tx.send(()); - }) - .is_err() - || rx.await.is_err() - { - break; - } - } - }); -} diff --git a/src-tauri/src/point_scan_host.rs b/src-tauri/src/scan_host.rs similarity index 99% rename from src-tauri/src/point_scan_host.rs rename to src-tauri/src/scan_host.rs index b76d61d8..6f08a2ff 100644 --- a/src-tauri/src/point_scan_host.rs +++ b/src-tauri/src/scan_host.rs @@ -1,5 +1,5 @@ //! Native, nonactivating strips, using the same display units as input injection. -use crate::point_scan::Rect; +use crate::scanning::Rect; #[cfg(target_os = "windows")] mod platform { diff --git a/src-tauri/src/scanning.rs b/src-tauri/src/scanning.rs new file mode 100644 index 00000000..45c5200a --- /dev/null +++ b/src-tauri/src/scanning.rs @@ -0,0 +1,366 @@ +//! Reusable scanning behavior. No Tauri, display discovery, persistence or input injection. +use serde::{Deserialize, Serialize}; + +pub const TICK_MS: u64 = 33; +pub const MAX_ELAPSED_MS: u64 = 250; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Action { + Select, + Next, + Back, + Pause, + Cancel, +} +pub const ACTIONS: [Action; 5] = [ + Action::Select, + Action::Next, + Action::Back, + Action::Pause, + Action::Cancel, +]; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct SwitchSettings { + pub automatic: bool, + pub select_key: String, + pub next_key: String, + pub back_key: String, + pub pause_key: String, +} +impl Default for SwitchSettings { + fn default() -> Self { + Self { + automatic: true, + select_key: "Space".into(), + next_key: "Enter".into(), + back_key: "Backspace".into(), + pause_key: "F8".into(), + } + } +} +impl SwitchSettings { + pub fn keys(&self) -> [&str; 5] { + [ + &self.select_key, + &self.next_key, + &self.back_key, + &self.pause_key, + "Escape", + ] + } + pub fn validate(&self) -> Result<(), String> { + let keys = self.keys(); + for (index, key) in keys.iter().enumerate() { + let function = key + .strip_prefix('F') + .and_then(|n| n.parse::().ok()) + .is_some_and(|n| (1..=24).contains(&n)); + if !function + && ![ + "Space", + "Enter", + "Backspace", + "Escape", + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + ] + .contains(key) + { + return Err("Choose a supported switch key.".into()); + } + if keys[..index].contains(key) { + return Err( + "Each switch action must use a different key. Escape is reserved for cancel." + .into(), + ); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} +impl Rect { + pub fn valid(self) -> bool { + [self.x, self.y, self.width, self.height] + .iter() + .all(|n| n.is_finite()) + && self.width >= 2.0 + && self.height >= 2.0 + } +} +/// Native hosts draw filled strips; techniques can compose lines, outlines and highlights. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct Frame { + pub strips: Vec, +} + +pub trait Technique { + type Selection; + type Phase: Clone + Default + Serialize; + fn start(&mut self); + fn advance(&mut self, elapsed_ms: u64); + fn handle(&mut self, action: Action) -> Option; + fn reset(&mut self); + fn frame(&self) -> Frame; + fn phase(&self) -> Self::Phase; +} + +/// Releases produce one semantic action; Select freezes the clock while held. +#[derive(Default)] +pub struct SwitchInput { + pressed: [bool; 5], +} +impl SwitchInput { + pub fn event( + &mut self, + current_generation: u64, + event_generation: u64, + index: usize, + pressed: bool, + ) -> Option { + if current_generation != event_generation { + return None; + } + let action = *ACTIONS.get(index)?; + let was_pressed = std::mem::replace(&mut self.pressed[index], pressed); + if action == Action::Cancel { + return (pressed && !was_pressed).then_some(action); + } + (!pressed && was_pressed).then_some(action) + } + pub fn selecting(&self) -> bool { + self.pressed[0] + } + pub fn reset(&mut self) { + self.pressed = [false; 5]; + } +} + +pub struct Session { + pub technique: T, + automatic: bool, + active: bool, + paused: bool, +} +impl Session { + pub fn new(technique: T, automatic: bool) -> Self { + Self { + technique, + automatic, + active: false, + paused: false, + } + } + pub fn active(&self) -> bool { + self.active + } + pub fn paused(&self) -> bool { + self.paused + } + pub fn action(&mut self, action: Action) -> Option { + if action == Action::Cancel { + self.reset(); + return None; + } + if !self.active { + if action == Action::Select { + self.active = true; + self.technique.start(); + } + return None; + } + if action == Action::Pause { + self.paused = !self.paused; + return None; + } + let selection = self.technique.handle(action); + if selection.is_some() { + self.reset(); + } + selection + } + pub fn tick(&mut self, elapsed_ms: u64, select_held: bool) { + if self.active && self.automatic && !self.paused && !select_held && elapsed_ms > 0 { + self.technique.advance(elapsed_ms.min(MAX_ELAPSED_MS)); + } + } + pub fn reset(&mut self) { + self.active = false; + self.paused = false; + self.technique.reset(); + } + pub fn frame(&self) -> Frame { + if self.active { + self.technique.frame() + } else { + Frame::default() + } + } +} + +#[derive(Default)] +pub struct Interval { + elapsed_ms: u64, +} +impl Interval { + pub fn elapsed(&mut self, delta_ms: u64, period_ms: u64) -> bool { + self.elapsed_ms = self.elapsed_ms.saturating_add(delta_ms); + if self.elapsed_ms < period_ms.max(1) { + return false; + } + self.elapsed_ms %= period_ms.max(1); + true + } + pub fn reset(&mut self) { + self.elapsed_ms = 0; + } +} + +#[derive(Debug, Default)] +pub struct Cycle { + index: usize, +} +impl Cycle { + pub fn index(&self) -> usize { + self.index + } + pub fn step(&mut self, count: usize, forward: bool) { + self.index = if count == 0 { + 0 + } else if forward { + (self.index % count + 1) % count + } else { + (self.index % count + count - 1) % count + }; + } + pub fn reset(&mut self) { + self.index = 0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + // A second, non-pointer technique proves the session is not tied to point scan. + #[derive(Default)] + struct Items { + cursor: Cycle, + interval: Interval, + elapsed: u64, + } + impl Technique for Items { + type Selection = &'static str; + type Phase = usize; + fn start(&mut self) { + self.reset(); + } + fn advance(&mut self, ms: u64) { + self.elapsed += ms; + if self.interval.elapsed(ms, 100) { + self.cursor.step(3, true); + } + } + fn handle(&mut self, action: Action) -> Option { + match action { + Action::Select => return Some(["one", "two", "three"][self.cursor.index()]), + Action::Next => self.cursor.step(3, true), + Action::Back => self.cursor.step(3, false), + _ => {} + } + None + } + fn reset(&mut self) { + self.cursor.reset(); + self.interval.reset(); + } + fn frame(&self) -> Frame { + Frame { + strips: vec![Rect { + x: self.cursor.index() as f64 * 10.0, + y: 0.0, + width: 10.0, + height: 10.0, + }], + } + } + fn phase(&self) -> usize { + self.cursor.index() + } + } + #[test] + fn item_technique_uses_shared_timing_pause_and_selection() { + let mut s = Session::new(Items::default(), true); + s.action(Action::Select); + s.tick(100, false); + s.action(Action::Pause); + s.tick(100, false); + assert_eq!(s.technique.phase(), 1); + s.action(Action::Pause); + s.tick(100, true); + assert_eq!(s.technique.phase(), 1); + assert_eq!(s.action(Action::Select), Some("two")); + assert!(!s.active()); + assert!(s.frame().strips.is_empty()); + s.action(Action::Select); + assert_eq!(s.technique.phase(), 0); + } + #[test] + fn manual_items_wrap_and_cancel() { + let mut s = Session::new(Items::default(), false); + s.action(Action::Select); + s.tick(1000, false); + assert_eq!(s.technique.phase(), 0); + s.action(Action::Back); + assert_eq!(s.technique.phase(), 2); + s.action(Action::Next); + assert_eq!(s.technique.phase(), 0); + s.action(Action::Cancel); + assert!(!s.active()); + assert!(s.frame().strips.is_empty()); + } + #[test] + fn delayed_and_zero_ticks_are_bounded() { + let mut s = Session::new(Items::default(), true); + s.action(Action::Select); + s.tick(0, false); + s.tick(10000, false); + assert_eq!(s.technique.elapsed, MAX_ELAPSED_MS); + } + #[test] + fn switch_latch_ignores_repeat_release_and_stale_generations() { + let mut input = SwitchInput::default(); + assert_eq!(input.event(2, 1, 0, true), None); + assert!(!input.selecting()); + assert_eq!(input.event(2, 2, 0, true), None); + assert!(input.selecting()); + assert_eq!(input.event(2, 2, 0, true), None); + assert_eq!(input.event(2, 2, 0, false), Some(Action::Select)); + assert_eq!(input.event(2, 2, 0, false), None); + assert_eq!(input.event(2, 2, 4, true), Some(Action::Cancel)); + input.reset(); + assert_eq!(input.event(3, 2, 0, false), None); + assert!(!input.selecting()); + } + #[test] + fn cyclic_traversal_handles_empty_and_interval_reset() { + let mut cycle = Cycle::default(); + cycle.step(0, false); + assert_eq!(cycle.index(), 0); + let mut timer = Interval::default(); + assert!(!timer.elapsed(70, 100)); + timer.reset(); + assert!(!timer.elapsed(40, 100)); + assert!(timer.elapsed(60, 100)); + } +} diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs new file mode 100644 index 00000000..8ef26e12 --- /dev/null +++ b/src-tauri/src/scanning_runtime.rs @@ -0,0 +1,365 @@ +//! Shared desktop scan controller. Adapters supply configuration, environment and activation. +use crate::{ + scan_host::Host, + scanning::{Action, Session, SwitchInput, SwitchSettings, Technique, TICK_MS}, +}; +use serde::{de::DeserializeOwned, Serialize}; +pub trait Adapter: Send + Sync + 'static { + type Config: Clone + Default + Serialize + DeserializeOwned + Send + Sync; + type Technique: Technique + Send; + type Environment: Clone + Send; + const EVENT: &'static str; + const FILE: &'static str; + fn validate(config: &Self::Config) -> Result<(), String>; + fn switches(config: &Self::Config) -> SwitchSettings; + fn create( + app: &AppHandle, + config: Self::Config, + ) -> Result<(Self::Technique, Self::Environment), String>; + fn validate_environment( + app: &AppHandle, + environment: Option<&Self::Environment>, + ) -> Result<(), String>; + fn prepare(app: &AppHandle) -> Result<(), String>; + fn activate( + app: &AppHandle, + selection: ::Selection, + ) -> Result<(), String>; +} + +use std::{ + cell::RefCell, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Mutex, + }, + time::Instant, +}; +use tauri::{AppHandle, Emitter, Manager}; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; + +thread_local! {static HOST:RefCell>=const{RefCell::new(None)};} +pub struct Controller { + enabled: AtomicBool, + generation: AtomicU64, + data: Mutex>, +} +struct Data { + config: A::Config, + engine: Option>, + display: Option, + registered: Vec, + pressed: SwitchInput, + last_tick: Instant, + message: String, +} +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct View { + pub config: C, + pub enabled: bool, + pub phase: P, + pub paused: bool, + pub message: String, + pub supported: bool, +} +impl Controller { + pub fn new(app: &AppHandle) -> Self { + let config = config_path::(app) + .ok() + .and_then(|p| std::fs::read(p).ok()) + .and_then(|b| serde_json::from_slice::(&b).ok()) + .filter(|c| A::validate(c).is_ok()) + .unwrap_or_default(); + Self { + enabled: AtomicBool::new(false), + generation: AtomicU64::new(0), + data: Mutex::new(Data { + config, + engine: None, + display: None, + registered: vec![], + pressed: SwitchInput::default(), + last_tick: Instant::now(), + message: "Scanning is off.".into(), + }), + } + } + pub fn view(&self) -> View::Phase> { + let d = self.data.lock().unwrap_or_else(|p| p.into_inner()); + View { + config: d.config.clone(), + enabled: self.enabled.load(Ordering::SeqCst), + phase: d + .engine + .as_ref() + .map_or_else(Default::default, |e| e.technique.phase()), + paused: d.engine.as_ref().is_some_and(|e| e.paused()), + message: d.message.clone(), + supported: cfg!(any(target_os = "windows", target_os = "macos")), + } + } +} +fn config_path(app: &AppHandle) -> Result { + app.path() + .app_config_dir() + .map(|p| p.join(A::FILE)) + .map_err(|e| e.to_string()) +} +fn publish(app: &AppHandle) { + let _ = app.emit(A::EVENT, app.state::>().view()); +} +// One local technique owns the switch keys and overlay at a time. +struct ScanService { + cancel: fn(&AppHandle), +} +pub fn cancel(app: &AppHandle) { + if let Some(service) = app.try_state::() { + (service.cancel)(app); + } +} +fn cancel_for(app: &AppHandle) { + let Some(c) = app.try_state::>() else { + return; + }; + c.enabled.store(false, Ordering::SeqCst); + let generation = c.generation.fetch_add(1, Ordering::SeqCst) + 1; + let handle = app.clone(); + let _ = app.run_on_main_thread(move || { + if handle + .state::>() + .generation + .load(Ordering::SeqCst) + == generation + { + disable::(&handle, "Scanning stopped."); + } + }); +} +fn disable(app: &AppHandle, message: &str) { + let c = app.state::>(); + c.enabled.store(false, Ordering::SeqCst); + c.generation.fetch_add(1, Ordering::SeqCst); + let keys = { + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + d.engine = None; + d.display = None; + d.pressed.reset(); + d.message = message.into(); + std::mem::take(&mut d.registered) + }; + for key in keys { + let _ = app.global_shortcut().unregister(key.as_str()); + } + HOST.with(|host| { + if let Some(host) = host.borrow_mut().as_mut() { + host.hide(); + } + }); + publish::(app); +} +pub fn configure( + app: &AppHandle, + config: A::Config, + enabled: bool, +) -> Result::Phase>, String> { + A::validate(&config)?; + let path = config_path::(app)?; + disable::(app, "Scanning is off."); + let generation = app + .state::>() + .generation + .load(Ordering::SeqCst); + if enabled { + A::prepare(app)?; + HOST.with(|slot| { + if slot.borrow().is_none() { + *slot.borrow_mut() = Some(Host::new()?); + } + Ok::<_, String>(()) + })?; + for (index, key) in A::switches(&config).keys().iter().enumerate() { + if let Err(error) = app + .global_shortcut() + .on_shortcut(*key, move |app, _, event| { + let handle = app.clone(); + let _ = app.run_on_main_thread(move || { + if handle + .state::>() + .generation + .load(Ordering::SeqCst) + == generation + { + switch::( + &handle, + generation, + index, + event.state == ShortcutState::Pressed, + ); + } + }); + }) + { + disable::(app, "A switch key is already in use. Choose another key."); + return Err(format!("Could not reserve {key}: {error}")); + } + app.state::>() + .data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .registered + .push((*key).into()); + } + } + let save = || -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::write( + &path, + serde_json::to_vec_pretty(&config).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string()) + }; + if let Err(error) = save() { + disable::(app, "Scanning settings could not be saved."); + return Err(error); + } + let c = app.state::>(); + { + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + d.config = config; + d.message = if enabled { + "Ready. Press the select switch to begin." + } else { + "Scanning is off." + } + .into(); + } + if enabled && c.generation.load(Ordering::SeqCst) != generation { + disable::(app, "Scanning stopped while enabling."); + return Err("Scanning was cancelled while enabling. Try again.".into()); + } + c.enabled.store(enabled, Ordering::SeqCst); + publish::(app); + Ok(c.view()) +} +fn switch(app: &AppHandle, generation: u64, index: usize, pressed: bool) { + let c = app.state::>(); + if !c.enabled.load(Ordering::SeqCst) { + return; + } + let action = { + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + d.pressed.event( + c.generation.load(Ordering::SeqCst), + generation, + index, + pressed, + ) + }; + let Some(action) = action else { + return; + }; + if action == Action::Cancel { + disable::(app, "Scanning cancelled. Switch keys released."); + return; + } + let result = (|| -> Result<(), String> { + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + if d.engine.as_ref().is_none_or(|e| !e.active()) && action == Action::Select { + let (engine, display) = A::create(app, d.config.clone())?; + d.engine = Some(Session::new(engine, A::switches(&d.config).automatic)); + d.display = Some(display); + A::prepare(app)?; + } + let point = d.engine.as_mut().and_then(|e| e.action(action)); + d.last_tick = Instant::now(); + let display = d.display.clone(); + drop(d); + if let Some(point) = point { + A::validate_environment(app, display.as_ref())?; + HOST.with(|host| { + if let Some(h) = host.borrow_mut().as_mut() { + h.hide(); + } + }); + if c.enabled.load(Ordering::SeqCst) { + A::activate(app, point)?; + } + } + render::(app) + })(); + if let Err(error) = result { + disable::(app, &error); + } else { + publish::(app); + } +} +fn render(app: &AppHandle) -> Result<(), String> { + let c = app.state::>(); + let d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + let frame = d + .engine + .as_ref() + .map_or_else(Default::default, Session::frame); + HOST.with(|host| { + if let Some(host) = host.borrow_mut().as_mut() { + host.render(&frame.strips) + } else { + Ok(()) + } + }) +} +fn tick(app: &AppHandle) { + let c = app.state::>(); + if !c.enabled.load(Ordering::SeqCst) { + return; + } + if app.state::().snapshot().bluetooth + == crate::state::BluetoothState::Connected + { + disable::(app, "Android connected. Local scanning stopped."); + return; + } + let result = (|| -> Result<(), String> { + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + A::validate_environment(app, d.display.as_ref())?; + let now = Instant::now(); + let elapsed = now.duration_since(d.last_tick).as_millis() as u64; + d.last_tick = now; + let held = d.pressed.selecting(); + if let Some(engine) = d.engine.as_mut() { + engine.tick(elapsed, held); + } + drop(d); + render::(app) + })(); + if let Err(error) = result { + disable::(app, &error); + } +} +pub fn install(app: &AppHandle) { + app.manage(Controller::::new(app)); + app.manage(ScanService { + cancel: cancel_for::, + }); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(TICK_MS)).await; + let handle = app.clone(); + let (tx, rx) = tokio::sync::oneshot::channel(); + if app + .run_on_main_thread(move || { + tick::(&handle); + let _ = tx.send(()); + }) + .is_err() + || rx.await.is_err() + { + break; + } + } + }); +} diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index 3f9926af..22a0b70f 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -948,7 +948,7 @@ async fn start_gatt( .cancel(&subscribe_app); stop_all_repeats(&subscribe_app); release_input_session(); - crate::point_scan_runtime::cancel(&subscribe_app); + crate::scanning_runtime::cancel(&subscribe_app); subscribe_app.state::().end_session(); subscribe_app.state::().end_session(); } @@ -1640,7 +1640,7 @@ fn complete_desktop( } } if command.command_type == "connection.disconnecting" { - crate::point_scan_runtime::cancel(app); + crate::scanning_runtime::cancel(app); overlay.end_session(); } } @@ -2218,7 +2218,7 @@ pub fn disconnect_all(app: &AppHandle, shared: &SharedModel) -> Result<(), Strin } fn reset_transport(app: &AppHandle, shared: &SharedModel, bluetooth: BluetoothState) { - crate::point_scan_runtime::cancel(app); + crate::scanning_runtime::cancel(app); app.state::().cancel(app); stop_all_repeats(app); take_runtime(); diff --git a/src/App.tsx b/src/App.tsx index 4ca0a0b8..2e458b0f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,9 +10,9 @@ import { applyLocalSettings, changedSettingKeys } from "./settings/diff"; import { SettingsView } from "./settings/SettingsView"; import { updateDescription, updateInFlight, updateLiveness, updateProgress, updateStanding, type UpdateAction } from "./settings/UpdatesSection"; import { TabPanel, Tabs } from "./Tabs"; -import { PointScan } from "./PointScan"; +import { useScanning } from "./scanning/useScanning"; -type View = "home" | "devices" | "profiles" | "pointScan" | "settings" | "support"; +type View = "home" | "devices" | "profiles" | "settings" | "support"; const brandIconUrl = new URL("../src-tauri/icons/icon.png", import.meta.url).href; const androidQrUrl = new URL("./assets/android-download-qr.png", import.meta.url).href; @@ -436,6 +436,7 @@ function PairingDialog({ requests, connectedDeviceName, busy, approve, reject }: } export function App() { + const scanning=useScanning(); const [state, setState] = useState(null); const [view, setView] = useState("home"); const viewRef = useRef("home"); @@ -672,7 +673,6 @@ export function App() { ["home", "Home", ], ["devices", "Devices", ], ["profiles", "Switch Forwarding", ], ["settings", "Settings", ], ["support", "Support", ], - ["pointScan", "Point scan", ], ] as const, []); const selectView = (next: View) => { @@ -727,9 +727,8 @@ export function App() {

{updateNotice}

{view === "home" && void perform(api.disconnectAll)} onAccessibility={() => void perform(() => api.checkAccessibility(true))} onSetup={openSetup} />} {view === "devices" && void perform(() => api.forgetDevice(id))} />} - {view === "pointScan" && } {view === "profiles" && { profileEditorDirty.current = dirty; }} nativeExitRequest={profileExitRequest} onConfirmNativeExit={confirmProfileExit} onCancelNativeExit={cancelProfileExit} />} - {view === "settings" && void perform(() => api.setTelemetryConsent(enabled))} updateAction={(action) => void runUpdate(action)} cancelUpdate={() => void cancelUpdate()} busy={busy} focusUpdates={focusUpdates} onUpdatesFocused={() => setFocusUpdates(false)} updateAttention={updateFailure?.text ?? null} onUpdatesShown={setUpdatesShown} />} + {view === "settings" && void perform(() => api.setTelemetryConsent(enabled))} updateAction={(action) => void runUpdate(action)} cancelUpdate={() => void cancelUpdate()} busy={busy} focusUpdates={focusUpdates} onUpdatesFocused={() => setFocusUpdates(false)} updateAttention={updateFailure?.text ?? null} onUpdatesShown={setUpdatesShown} />} {view === "support" && void perform(operation)} openSetup={openSetup} openUpdates={openUpdates} />} {setupOpen && perform(() => api.checkAccessibility(true))} reject={(requestId) => perform(() => api.rejectPairing(requestId))} approve={(requestId) => perform(() => api.approvePairing(requestId))} />} diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index 7facf35f..718ef921 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -1,35 +1,174 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, expect, it, vi } from "vitest"; -import { PointScan, defaultPointScanConfig, type PointScanState } from "./PointScan"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { + useScanning, + defaultPointScanConfig, + type PointScanState, +} from "./scanning/useScanning"; +import { ScanningSection } from "./settings/ScanningSection"; +function PointScan() { + const controller = useScanning(); + return ; +} const mocks = vi.hoisted(() => ({ invoke: vi.fn(), listen: vi.fn() })); vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen })); -const initial: PointScanState = { config: defaultPointScanConfig, enabled: false, phase: "idle", paused: false, supported: true, message: "Point scan is off." }; +const initial: PointScanState = { + config: defaultPointScanConfig, + enabled: false, + phase: "idle", + paused: false, + supported: true, + message: "Point scan is off.", +}; beforeEach(() => { - Object.defineProperty(window, "__TAURI_INTERNALS__", { configurable: true, value: {} }); + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: {}, + }); mocks.listen.mockReset().mockResolvedValue(vi.fn()); - mocks.invoke.mockReset().mockImplementation(async (command, args) => command === "get_point_scan" ? initial : { ...initial, ...args }); + mocks.invoke + .mockReset() + .mockImplementation(async (command, args) => + command === "get_point_scan" ? initial : { ...initial, ...args }, + ); }); it("enables native point scan and locks its configuration until disabled", async () => { render(); await screen.findByText("Point scan is off."); fireEvent.click(screen.getByRole("button", { name: "Enable point scan" })); await screen.findByRole("button", { name: "Disable point scan" }); - expect(mocks.invoke).toHaveBeenCalledWith("configure_point_scan", { config: defaultPointScanConfig, enabled: true }); - expect(screen.getByLabelText("Mode")).toBeDisabled(); + expect(mocks.invoke).toHaveBeenCalledWith("configure_point_scan", { + config: defaultPointScanConfig, + enabled: true, + }); + expect(screen.getByRole("button", { name: "Line only" })).toBeDisabled(); }); it("rejects duplicate switch keys and exposes grid settings", async () => { - render(); await screen.findByText("Point scan is off."); - fireEvent.change(screen.getByLabelText("Mode"), { target: { value: "grid" } }); + render(); + await screen.findByText("Point scan is off."); + fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); expect(screen.getByLabelText("Grid size")).toHaveValue("4"); - fireEvent.change(screen.getByLabelText("Forward switch"), { target: { value: "Space" } }); - expect(screen.getByRole("button", { name: "Enable point scan" })).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Forward switch"), { + target: { value: "Space" }, + }); + expect( + screen.getByRole("button", { name: "Enable point scan" }), + ).toBeDisabled(); }); it("reports native registration failure without claiming scanning started", async () => { - render(); await screen.findByText("Point scan is off."); + render(); + await screen.findByText("Point scan is off."); mocks.invoke.mockRejectedValueOnce("Space is already in use."); fireEvent.click(screen.getByRole("button", { name: "Enable point scan" })); - await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("Space is already in use.")); - expect(screen.getByRole("button", { name: "Enable point scan" })).toBeEnabled(); + await waitFor(() => + expect(screen.getByRole("alert")).toHaveTextContent( + "Space is already in use.", + ), + ); + expect( + screen.getByRole("button", { name: "Enable point scan" }), + ).toBeEnabled(); +}); + +afterEach(() => { + Reflect.deleteProperty(window, "__TAURI_INTERNALS__"); +}); + +it("keeps newer edits across old events and saves before enabling", async () => { + let resolveSave!: (value: PointScanState) => void; + render(); + await screen.findByText("Point scan is off."); + mocks.invoke.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); + await waitFor(() => expect(resolveSave).toBeTypeOf("function")); + fireEvent.change(screen.getByLabelText("Grid size"), { + target: { value: "7" }, + }); + const event = mocks.listen.mock.calls[0][1]; + act(() => event({ payload: initial })); + expect(screen.getByLabelText("Grid size")).toHaveValue("7"); + fireEvent.click(screen.getByRole("button", { name: "Enable point scan" })); + expect( + mocks.invoke.mock.calls.filter(([, args]) => args?.enabled), + ).toHaveLength(0); + await act(async () => + resolveSave({ ...initial, config: { ...initial.config, mode: "grid" } }), + ); + await screen.findByRole("button", { name: "Disable point scan" }); + expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { + config: { ...initial.config, mode: "grid", gridSize: 7 }, + enabled: true, + }); +}); + +it("retains failed edits and requires a successful retry before enabling", async () => { + render(); + await screen.findByText("Point scan is off."); + mocks.invoke.mockRejectedValueOnce("Disk is full."); + fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); + await screen.findByText("Disk is full."); + expect( + screen.getByRole("button", { name: "Grid then line" }), + ).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "Enable point scan" })); + await screen.findByText(/Save the scanning settings before enabling/); + expect( + mocks.invoke.mock.calls.filter(([, args]) => args?.enabled), + ).toHaveLength(0); + fireEvent.click(screen.getByRole("button", { name: "Retry save" })); + await screen.findByText("Scanning settings save automatically."); + fireEvent.click(screen.getByRole("button", { name: "Enable point scan" })); + await screen.findByRole("button", { name: "Disable point scan" }); +}); + +it("keeps pending saves and an enabled scan when its settings panel unmounts", async () => { + function Shell({ visible }: { visible: boolean }) { + const controller = useScanning(); + return visible ? ( + + ) : ( +

Another view

+ ); + } + let resolveSave!: (value: PointScanState) => void; + const view = render(); + await screen.findByText("Point scan is off."); + mocks.invoke.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); + await waitFor(() => expect(resolveSave).toBeTypeOf("function")); + view.rerender(); + await act(async () => + resolveSave({ ...initial, config: { ...initial.config, mode: "grid" } }), + ); + view.rerender(); + expect( + screen.getByRole("button", { name: "Grid then line" }), + ).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "Enable point scan" })); + await screen.findByRole("button", { name: "Disable point scan" }); + const calls = mocks.invoke.mock.calls.length; + view.rerender(); + view.rerender(); + expect( + screen.getByRole("button", { name: "Disable point scan" }), + ).toBeEnabled(); + expect(mocks.invoke).toHaveBeenCalledTimes(calls); }); diff --git a/src/PointScan.tsx b/src/PointScan.tsx deleted file mode 100644 index 22f988ac..00000000 --- a/src/PointScan.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useEffect, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; - -export type PointScanConfig = { - mode: "line" | "grid"; automatic: boolean; speed: number; gridSize: number; blockIntervalMs: number; - selectKey: string; nextKey: string; backKey: string; pauseKey: string; -}; -export type PointScanState = { config: PointScanConfig; enabled: boolean; phase: "idle" | "row" | "cell" | "x" | "y"; paused: boolean; message: string; supported: boolean }; -export const defaultPointScanConfig: PointScanConfig = { mode: "line", automatic: true, speed: 2, gridSize: 4, blockIntervalMs: 1000, selectKey: "Space", nextKey: "Enter", backKey: "Backspace", pauseKey: "F8" }; -const keys = ["Space", "Enter", "Backspace", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", ...Array.from({ length: 24 }, (_, i) => `F${i + 1}`)]; -const phases = { idle: "Ready to begin", row: "Choose a row", cell: "Choose a cell", x: "Choose the horizontal position", y: "Choose the vertical position" }; - -export function PointScan() { - const [state, setState] = useState(null); - const [config, setConfig] = useState(defaultPointScanConfig); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - useEffect(() => { - let alive = true; - let unlisten: (() => void) | undefined; - const receive = (next: PointScanState) => { if (alive) { setState(next); setConfig(next.config); } }; - if (!("__TAURI_INTERNALS__" in window)) { - setState({ config: defaultPointScanConfig, enabled: false, phase: "idle", paused: false, supported: false, message: "Open the desktop app to use point scan." }); - return; - } - void listen("point-scan-changed", (event) => receive(event.payload)).then((stop) => { - if (alive) unlisten = stop; else stop(); - return invoke("get_point_scan"); - }).then(receive).catch((reason) => { if (alive) setError(String(reason)); }); - return () => { alive = false; unlisten?.(); }; - }, []); - const apply = async (enabled: boolean) => { - setBusy(true); setError(null); - try { const next = await invoke("configure_point_scan", { config, enabled }); setState(next); setConfig(next.config); } - catch (reason) { setError(String(reason)); } - finally { setBusy(false); } - }; - const update = (key: K, value: PointScanConfig[K]) => setConfig({ ...config, [key]: value }); - const invalidKeys = new Set([config.selectKey, config.nextKey, config.backKey, config.pauseKey]).size !== 4; - return
-

Point scan

Select a point using switches connected to this computer.

-
-

Local switch control

-

Enable point scan, focus the application you want to use, then press Select. The display under the pointer is scanned. Select the X position, then the Y position to click once.

-

Switch keys are reserved while enabled. Escape stops scanning and releases them. Disconnect Android before enabling local point scan.

-

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

- {error &&

{error}

} - {invalidKeys &&

Each switch action needs a different key.

} -
- - -
-
-
- Scanning settings - - - - {config.mode === "grid" && <> - - - } - {([["selectKey", "Select switch"], ["nextKey", "Forward switch"], ["backKey", "Backward switch"], ["pauseKey", "Pause / resume switch"]] as const).map(([key, label]) => )} -
-

Select takes effect on release. Holding Select freezes scanning. Forward and Backward step once and set direction. After clicking, press Select to start again. Settings are saved, but scanning stays off when the app restarts.

-
; -} diff --git a/src/scanning/useScanning.ts b/src/scanning/useScanning.ts new file mode 100644 index 00000000..aeb5da75 --- /dev/null +++ b/src/scanning/useScanning.ts @@ -0,0 +1,185 @@ +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +export type PointScanConfig = { + mode: "line" | "grid"; + automatic: boolean; + speed: number; + gridSize: number; + blockIntervalMs: number; + selectKey: string; + nextKey: string; + backKey: string; + pauseKey: string; +}; +export type PointScanState = { + config: PointScanConfig; + enabled: boolean; + phase: "idle" | "row" | "cell" | "x" | "y"; + paused: boolean; + message: string; + supported: boolean; +}; +export const defaultPointScanConfig: PointScanConfig = { + mode: "line", + automatic: true, + speed: 2, + gridSize: 4, + blockIntervalMs: 1000, + selectKey: "Space", + nextKey: "Enter", + backKey: "Backspace", + pauseKey: "F8", +}; + +export function validSwitches(config: PointScanConfig) { + return ( + new Set([ + config.selectKey, + config.nextKey, + config.backKey, + config.pauseKey, + "Escape", + ]).size === 5 + ); +} +// App owns this hook so changing tabs or views never drops edits or stops a scan. +export function useScanning() { + const [state, setState] = useState(null); + const [config, setConfig] = useState(defaultPointScanConfig); + const [pending, setPending] = useState(0); + const [error, setError] = useState(null); + const [toggling, setToggling] = useState(false); + const model = useRef({ + config: defaultPointScanConfig, + revision: 0, + saved: 0, + pending: 0, + enabled: false, + toggling: false, + supported: false, + }); + const queue = useRef(Promise.resolve()); + useEffect(() => { + let alive = true; + let stop: (() => void) | undefined; + const receive = (next: PointScanState) => { + if (!alive) return; + model.current.enabled = next.enabled; + model.current.supported = next.supported; + setState(next); + if ( + model.current.pending === 0 && + model.current.revision === model.current.saved + ) { + model.current.config = next.config; + setConfig(next.config); + } + }; + if (!("__TAURI_INTERNALS__" in window)) { + receive({ + config: defaultPointScanConfig, + enabled: false, + phase: "idle", + paused: false, + supported: false, + message: "Open the desktop app to use point scan.", + }); + return; + } + void listen("point-scan-changed", (event) => + receive(event.payload), + ) + .then((unlisten) => { + if (alive) stop = unlisten; + else unlisten(); + return invoke("get_point_scan"); + }) + .then(receive) + .catch((reason) => { + if (alive) setError(String(reason)); + }); + return () => { + alive = false; + stop?.(); + }; + }, []); + const enqueue = (work: () => Promise) => { + model.current.pending++; + setPending(model.current.pending); + queue.current = queue.current + .then(work) + .catch((reason) => setError(String(reason))) + .finally(() => { + model.current.pending--; + setPending(model.current.pending); + }); + }; + const save = (revision: number, next: PointScanConfig) => + enqueue(async () => { + if (revision !== model.current.revision) return; + setError(null); + const result = await invoke("configure_point_scan", { + config: next, + enabled: false, + }); + model.current.saved = revision; + model.current.enabled = result.enabled; + setState(result); + }); + const update = ( + key: K, + value: PointScanConfig[K], + ) => { + const m = model.current; + if (m.enabled || m.toggling || !m.supported) return; + const next = { ...m.config, [key]: value }; + m.config = next; + m.revision++; + setConfig(next); + setError(null); + if (validSwitches(next)) save(m.revision, next); + }; + const retry = () => { + if (validSwitches(model.current.config)) + save(model.current.revision, model.current.config); + }; + const toggle = () => { + const m = model.current; + if (m.toggling || !m.supported || (!m.enabled && !validSwitches(m.config))) + return; + const enabled = !m.enabled; + m.toggling = true; + setToggling(true); + enqueue(async () => { + try { + if (enabled && m.saved !== m.revision) + throw new Error( + "Save the scanning settings before enabling point scan. Use Retry save.", + ); + setError(null); + const result = await invoke("configure_point_scan", { + config: m.config, + enabled, + }); + m.enabled = result.enabled; + setState(result); + } finally { + m.toggling = false; + setToggling(false); + } + }); + }; + return { + state, + config, + pending, + error, + toggling, + update, + retry, + toggle, + unsaved: model.current.revision !== model.current.saved, + }; +} +export type ScanningController = ReturnType; diff --git a/src/settings/ScanningSection.tsx b/src/settings/ScanningSection.tsx new file mode 100644 index 00000000..ae64517e --- /dev/null +++ b/src/settings/ScanningSection.tsx @@ -0,0 +1,212 @@ +import { + type ScanningController, + type PointScanConfig, + validSwitches, +} from "../scanning/useScanning"; + +import { SettingGroup, Toggle, OptionGroup, secondsOptions } from "./controls"; + +const keys = [ + "Space", + "Enter", + "Backspace", + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + ...Array.from({ length: 24 }, (_, i) => `F${i + 1}`), +]; + +const phases = { + idle: "Ready to begin", + row: "Choose a row", + cell: "Choose a cell", + x: "Choose the horizontal position", + y: "Choose the vertical position", +}; + +export function ScanningSection({ + controller, +}: { + controller: ScanningController; +}) { + const { + state, + config, + pending, + error, + toggling, + update, + retry, + toggle, + unsaved, + } = controller; + + const disabled = !state?.supported || !!state.enabled || toggling; + + return ( + <> + +

+ Focus the application you want to use, then press Select to scan the + display under the pointer. Choose the X position, then the Y position + to click once. +

+ +

+ Switch keys are reserved while enabled. Escape stops scanning and + releases them. Disconnect Android before enabling local point scan. +

+ +

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

+ + + +

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

+ + {error &&

{error}

} + + {error && unsaved && ( + + )} + + {!validSwitches(config) && ( +

+ Each switch action needs a different key. Escape is reserved for + cancel. +

+ )} +
+ + + update("automatic", value)} + /> + + {( + [ + ["selectKey", "Select switch"], + ["nextKey", "Forward switch"], + ["backKey", "Backward switch"], + ["pauseKey", "Pause / resume switch"], + ] as const + ).map(([key, label]) => ( + + ))} + +

+ Select takes effect on release. Holding Select freezes scanning. + Forward and Backward step once and set direction. After clicking, + press Select to start again. +

+
+ + + + legend="Mode" + disabled={disabled} + value={config.mode} + onChange={(value) => update("mode", value)} + options={[ + { value: "line", label: "Line only" }, + { value: "grid", label: "Grid then line" }, + ]} + /> + + + legend="Line speed" + columns="five" + disabled={disabled} + value={config.speed} + onChange={(value) => update("speed", value)} + options={["Very slow", "Slow", "Medium", "Fast", "Very fast"].map( + (label, value) => ({ label, value }), + )} + /> + + {config.mode === "grid" && ( + <> + + + + legend="Grid interval" + disabled={disabled} + value={config.blockIntervalMs} + onChange={(value) => update("blockIntervalMs", value)} + options={secondsOptions([ + 250, 500, 750, 1000, 1500, 2000, 3000, 4000, 5000, + ])} + /> + + )} + +

Settings are saved, but scanning stays off when the app restarts.

+
+ + ); +} diff --git a/src/settings/SettingsView.tsx b/src/settings/SettingsView.tsx index a2ff676e..e136658f 100644 --- a/src/settings/SettingsView.tsx +++ b/src/settings/SettingsView.tsx @@ -8,9 +8,12 @@ import { CursorSection } from "./CursorSection"; import { PrivacySection } from "./PrivacySection"; import { UpdatesSection, type UpdateAction } from "./UpdatesSection"; -type SettingsTabId = "general" | "pointer" | "cursor" | "privacy" | "updates"; +import { ScanningSection } from "./ScanningSection"; +import type { ScanningController } from "../scanning/useScanning"; -export function SettingsView({ state, settings, onChange, chooseTelemetry, updateAction, cancelUpdate, busy, focusUpdates, onUpdatesFocused, updateAttention, onUpdatesShown }: { state: AppState; settings: AppSettings; onChange: (next: AppSettings) => void; chooseTelemetry: (enabled: boolean) => void; updateAction: (action: UpdateAction) => void; cancelUpdate: () => void; busy: boolean; focusUpdates: boolean; onUpdatesFocused: () => void; updateAttention: string | null; onUpdatesShown: (shown: boolean) => void }) { +type SettingsTabId = "general" | "scanning" | "pointer" | "cursor" | "privacy" | "updates"; + +export function SettingsView({ scanning, state, settings, onChange, chooseTelemetry, updateAction, cancelUpdate, busy, focusUpdates, onUpdatesFocused, updateAttention, onUpdatesShown }: { scanning: ScanningController; state: AppState; settings: AppSettings; onChange: (next: AppSettings) => void; chooseTelemetry: (enabled: boolean) => void; updateAction: (action: UpdateAction) => void; cancelUpdate: () => void; busy: boolean; focusUpdates: boolean; onUpdatesFocused: () => void; updateAttention: string | null; onUpdatesShown: (shown: boolean) => void }) { const updatesRef = useRef(null); // Opening straight to Updates starts there, rather than committing General // for one frame and letting App announce a failure for a tab already being @@ -27,6 +30,7 @@ export function SettingsView({ state, settings, onChange, chooseTelemetry, updat const tabs = useMemo[]>(() => [ { id: "general" as const, label: "General" }, { id: "pointer" as const, label: "Controls" }, + { id: "scanning" as const, label: "Scanning" }, ...(state.capabilities.cursorOverlay ? [{ id: "cursor" as const, label: "Cursor appearance" }] : []), { id: "privacy" as const, label: "Privacy" }, // On the Updates tab the panel itself shows the reason, so no marker there. @@ -55,6 +59,7 @@ export function SettingsView({ state, settings, onChange, chooseTelemetry, updat {active === "general" && } {active === "pointer" && } + {active === "scanning" && } {active === "cursor" && } {active === "privacy" && } {active === "updates" && } diff --git a/src/settings/settings.test.tsx b/src/settings/settings.test.tsx index c5303d01..5a47e802 100644 --- a/src/settings/settings.test.tsx +++ b/src/settings/settings.test.tsx @@ -32,6 +32,17 @@ describe("Switchify PC settings", () => { vi.restoreAllMocks(); }); + it("opens scanning inside Settings without a sidebar destination", async()=> { + render(); + await screen.findByRole("heading",{name:"Switchify PC"}); + expect(screen.queryByRole("button",{name:"Point scan"})).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button",{name:"Settings"})); + fireEvent.click(screen.getByRole("tab",{name:"Scanning"})); + expect(screen.getByRole("tabpanel")).toHaveAccessibleName("Scanning"); + expect(screen.getByRole("heading",{name:"Switch controls"})).toBeInTheDocument(); + expect(screen.getByRole("heading",{name:"Point scan"})).toBeInTheDocument(); + }); + it("shows update progress and exposes cancellation in Settings", async () => { browserState.updater = { status: "downloading", version: "1.0.0-beta.2", downloadedBytes: 50, totalBytes: 200, error: null, retryAction: null }; const cancel = vi.spyOn(api, "cancelUpdateDownload").mockResolvedValue(structuredClone(browserState)); @@ -405,7 +416,7 @@ describe("Switchify PC settings", () => { const tablist = screen.getByRole("tablist", { name: "Settings sections" }); expect(within(tablist).getAllByRole("tab").map((tab) => tab.textContent)) - .toEqual(["General", "Controls", "Cursor appearance", "Privacy", "Updates"]); + .toEqual(["General", "Controls", "Scanning", "Cursor appearance", "Privacy", "Updates"]); expect(screen.getByRole("tab", { name: "General" })).toHaveAttribute("aria-selected", "true"); expect(screen.getByRole("tabpanel")).toHaveAccessibleName("General"); }); @@ -454,7 +465,7 @@ describe("Switchify PC settings", () => { expect(screen.queryByRole("tab", { name: "Cursor appearance" })).not.toBeInTheDocument(); expect(screen.getAllByRole("tab").map((tab) => tab.textContent)) - .toEqual(["General", "Controls", "Privacy", "Updates"]); + .toEqual(["General", "Controls", "Scanning", "Privacy", "Updates"]); selectTab("Controls"); expect(screen.queryByRole("checkbox", { name: "Show cursor overlay" })).not.toBeInTheDocument(); }); diff --git a/src/styles.css b/src/styles.css index 0ee1a349..5d754a89 100644 --- a/src/styles.css +++ b/src/styles.css @@ -317,7 +317,3 @@ button:disabled { cursor: default; opacity: 0.55; } .settings-view > .tab-bar { grid-template-columns: repeat(var(--tab-count), auto); } .settings-view > .tab-bar button { padding: 0 4px; } - -.point-scan-options { border: 1px solid var(--border); border-radius: 12px; padding: 20px; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 18px; } -.point-scan-options label { display: flex; flex-direction: column; gap: 8px; } -.point-scan-view .button-row { display: flex; gap: 12px; margin: 18px 0; } From 18d742249f49ce4ee7e9244ea1689e16bb8f1409 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 12 Sep 2026 13:18:08 +0100 Subject: [PATCH 4/4] Preserve newer scan events across delayed command responses --- src/PointScan.test.tsx | 29 +++++++++++++++++++++++++++++ src/scanning/useScanning.ts | 28 +++++++++++++++++++--------- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index 718ef921..e4009db8 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -172,3 +172,32 @@ it("keeps pending saves and an enabled scan when its settings panel unmounts", a ).toBeEnabled(); expect(mocks.invoke).toHaveBeenCalledTimes(calls); }); + +it("keeps a newer cancellation event when an older enable response arrives", async () => { + render(); + await screen.findByText("Point scan is off."); + let resolveEnable!: (value: PointScanState) => void; + mocks.invoke.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveEnable = resolve; + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Enable point scan" })); + await waitFor(() => expect(resolveEnable).toBeTypeOf("function")); + act(() => + mocks.listen.mock.calls[0][1]({ + payload: { + ...initial, + message: "Android connected. Local scanning stopped.", + }, + }), + ); + await act(async () => resolveEnable({ ...initial, enabled: true })); + expect( + screen.getByRole("button", { name: "Enable point scan" }), + ).toBeEnabled(); + expect( + screen.getByText("Android connected. Local scanning stopped."), + ).toBeInTheDocument(); +}); diff --git a/src/scanning/useScanning.ts b/src/scanning/useScanning.ts index aeb5da75..fc25b137 100644 --- a/src/scanning/useScanning.ts +++ b/src/scanning/useScanning.ts @@ -60,6 +60,7 @@ export function useScanning() { supported: false, }); const queue = useRef(Promise.resolve()); + const runtimeRevision = useRef(0); useEffect(() => { let alive = true; let stop: (() => void) | undefined; @@ -87,15 +88,18 @@ export function useScanning() { }); return; } - void listen("point-scan-changed", (event) => - receive(event.payload), - ) + void listen("point-scan-changed", (event) => { + runtimeRevision.current++; + receive(event.payload); + }) .then((unlisten) => { if (alive) stop = unlisten; else unlisten(); - return invoke("get_point_scan"); + const revision = runtimeRevision.current; + return invoke("get_point_scan").then((next) => { + if (revision === runtimeRevision.current) receive(next); + }); }) - .then(receive) .catch((reason) => { if (alive) setError(String(reason)); }); @@ -119,13 +123,16 @@ export function useScanning() { enqueue(async () => { if (revision !== model.current.revision) return; setError(null); + const runtime = runtimeRevision.current; const result = await invoke("configure_point_scan", { config: next, enabled: false, }); model.current.saved = revision; - model.current.enabled = result.enabled; - setState(result); + if (runtime === runtimeRevision.current) { + model.current.enabled = result.enabled; + setState(result); + } }); const update = ( key: K, @@ -158,12 +165,15 @@ export function useScanning() { "Save the scanning settings before enabling point scan. Use Retry save.", ); setError(null); + const runtime = runtimeRevision.current; const result = await invoke("configure_point_scan", { config: m.config, enabled, }); - m.enabled = result.enabled; - setState(result); + if (runtime === runtimeRevision.current) { + m.enabled = result.enabled; + setState(result); + } } finally { m.toggling = false; setToggling(false);