diff --git a/docs/point-scan.md b/docs/point-scan.md new file mode 100644 index 00000000..1d54dead --- /dev/null +++ b/docs/point-scan.md @@ -0,0 +1,27 @@ +# 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 **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. + +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. + +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/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..c4b8df48 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -1529,6 +1529,32 @@ 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_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()); + } + + #[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_activation::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_activation::click(&mut input, (30, 40), false).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..e8e1953b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,7 +14,13 @@ mod macos_relaunch; mod modifier_overlay; mod mouse_repeat; mod overlay; +mod point_scan; +mod point_scan_activation; +mod point_scan_runtime; mod protocol; +mod scan_host; +mod scanning; +mod scanning_runtime; mod state; mod storage; mod telemetry; @@ -199,6 +205,7 @@ pub(crate) fn sync_tray_state(app: &AppHandle, state: &AppState) { } fn finish_app_exit(app: &AppHandle) { + scanning_runtime::cancel(app); app.state::().cancel(app); let model = app.state::(); let _ = platform_disconnect_all(app, &model.shared); @@ -365,6 +372,7 @@ fn finish_disconnect( overlay: &overlay::CursorOverlay, modifier_overlay: &modifier_overlay::ModifierOverlay, ) -> AppState { + scanning_runtime::cancel(app); app.state::().cancel(app); overlay.end_session(); modifier_overlay.end_session(); @@ -1211,6 +1219,54 @@ 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_activation::click( + &mut input::DesktopInput::new(injector), + point, + scan_host::modifiers_released(), + ) +} + pub fn run() { #[cfg(target_os = "macos")] if macos_relaunch::run_from_args() { @@ -1230,6 +1286,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 +1307,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 +1391,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..3b9cf0a2 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::scanning_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::scanning_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::scanning_runtime::cancel(&self.app); overlay.end_session(); } } @@ -2013,6 +2016,7 @@ impl MacRuntime { let _ = input.release_all(); input.end_control_session(); } + crate::scanning_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..f4b1c87f --- /dev/null +++ b/src-tauri/src/point_scan.rs @@ -0,0 +1,472 @@ +//! 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)] +#[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 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()); + } + Ok(()) + } +} +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Phase { + #[default] + Idle, + Row, + Cell, + X, + Y, +} +pub struct Engine { + pub config: PointSettings, + pub screen: Rect, + pub region: Rect, + pub phase: Phase, + pub x: f64, + pub y: f64, + pub row: Cycle, + pub cell: Cycle, + pub units_per_logical_pixel: f64, + direction: f64, + block_elapsed: Interval, +} +impl Engine { + 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 + { + return Err("The scanning display has invalid geometry.".into()); + } + Ok(Self { + config, + screen, + region: screen, + phase: Phase::Idle, + x: screen.x, + y: screen.y, + row: Cycle::default(), + cell: Cycle::default(), + direction: 1.0, + block_elapsed: Interval::default(), + units_per_logical_pixel, + }) + } + pub fn reset(&mut self) { + self.phase = Phase::Idle; + self.region = self.screen; + self.x = self.screen.x; + self.y = self.screen.y; + self.row.reset(); + self.cell.reset(); + self.direction = 1.0; + self.block_elapsed.reset(); + } + fn select_action(&mut self, action: Action) -> Option<(i32, i32)> { + match action { + Action::Select => { + self.block_elapsed.reset(); + match self.phase { + Phase::Row => { + self.phase = Phase::Cell; + self.cell.reset(); + } + 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, + ); + return Some(point); + } + Phase::Idle => {} + } + } + Action::Next | Action::Back => { + self.direction = if action == Action::Next { 1.0 } else { -1.0 }; + self.step(TICK_MS); + } + Action::Pause | Action::Cancel => {} + } + None + } + 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; + } + 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.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, + 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.index() 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.index() 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 + } +} + +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); + } + 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 + } +} + +#[cfg(test)] +mod tests { + use super::*; + 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, + ) + } + #[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, false); + let x = e.technique.x; + e.action(Action::Select); + e.tick(100, false); + assert_eq!(e.technique.x, x); + assert_eq!(e.action(Action::Select), Some((-988, 62))); + assert_eq!(e.technique.phase, Phase::Idle); + assert!(e.frame().strips.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.technique.row.index(), 2); + e.action(Action::Select); + e.action(Action::Back); + e.action(Action::Select); + 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, false); + assert_eq!(e.technique.x, -1000.0); + e.action(Action::Pause); + e.tick(10000, false); + assert_eq!(e.technique.x, -970.0); + e.technique.x = -1000.0; + e.action(Action::Back); + assert_eq!(e.technique.x, -2.0); + e.action(Action::Cancel); + assert_eq!(e.technique.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, false); + assert_eq!(e.technique.x, -1000.0); + e.action(Action::Next); + 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() { + 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().point(), + 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_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 new file mode 100644 index 00000000..1e908668 --- /dev/null +++ b/src-tauri/src/point_scan_runtime.rs @@ -0,0 +1,73 @@ +//! Compatibility commands and desktop adapters for the point technique. +use crate::{ + display_navigation::{self, Display}, + point_scan::{Config, Engine, Phase}, + scanning::Rect, + scanning_runtime::{self, Adapter}, +}; +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) +} +pub fn install(app: &AppHandle) { + scanning_runtime::install::(app); +} +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() + } + fn switches(config: &Config) -> crate::scanning::SwitchSettings { + config.switches() + } + fn create(app: &AppHandle, config: Config) -> Result<(Engine, Display), String> { + new_engine(app, config) + } + fn validate_environment(app: &AppHandle, display: Option<&Display>) -> Result<(), String> { + validate_display(app, display) + } + fn prepare(app: &AppHandle) -> Result<(), String> { + crate::point_scan_prepare(app) + } + fn activate(app: &AppHandle, point: (i32, i32)) -> Result<(), String> { + crate::point_scan_click(app, point) + } +} +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.point(), + Rect { + x: display.x.into(), + y: display.y.into(), + width: display.width.into(), + height: display.height.into(), + }, + units, + )?; + Ok((e, display)) +} +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(()) +} diff --git a/src-tauri/src/scan_host.rs b/src-tauri/src/scan_host.rs new file mode 100644 index 00000000..6f08a2ff --- /dev/null +++ b/src-tauri/src/scan_host.rs @@ -0,0 +1,180 @@ +//! Native, nonactivating strips, using the same display units as input injection. +use crate::scanning::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; + +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/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 40ab955a..22a0b70f 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::scanning_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::scanning_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::scanning_runtime::cancel(app); app.state::().cancel(app); stop_all_repeats(app); take_runtime(); 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", diff --git a/src/App.tsx b/src/App.tsx index 1646b99a..2e458b0f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ 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 { useScanning } from "./scanning/useScanning"; type View = "home" | "devices" | "profiles" | "settings" | "support"; @@ -435,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"); @@ -726,7 +728,7 @@ export function App() { {view === "home" && void perform(api.disconnectAll)} onAccessibility={() => void perform(() => api.checkAccessibility(true))} onSetup={openSetup} />} {view === "devices" && void perform(() => api.forgetDevice(id))} />} {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 new file mode 100644 index 00000000..e4009db8 --- /dev/null +++ b/src/PointScan.test.tsx @@ -0,0 +1,203 @@ +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.", +}; +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.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.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(); +}); +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(); +}); + +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); +}); + +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 new file mode 100644 index 00000000..fc25b137 --- /dev/null +++ b/src/scanning/useScanning.ts @@ -0,0 +1,195 @@ +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()); + const runtimeRevision = useRef(0); + 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) => { + runtimeRevision.current++; + receive(event.payload); + }) + .then((unlisten) => { + if (alive) stop = unlisten; + else unlisten(); + const revision = runtimeRevision.current; + return invoke("get_point_scan").then((next) => { + if (revision === runtimeRevision.current) receive(next); + }); + }) + .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 runtime = runtimeRevision.current; + const result = await invoke("configure_point_scan", { + config: next, + enabled: false, + }); + model.current.saved = revision; + if (runtime === runtimeRevision.current) { + 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 runtime = runtimeRevision.current; + const result = await invoke("configure_point_scan", { + config: m.config, + enabled, + }); + if (runtime === runtimeRevision.current) { + 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(); });