From dff2e35b4533ad225824dfaef675c9aef5d5dd82 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 12 Sep 2026 21:38:10 +0100 Subject: [PATCH 1/2] Arm scanning automatically whenever the switches allow it Scanning no longer has an enable button. The runtime arms itself when the saved switches cover the current mode and the environment allows, and the tick loop re-arms after anything that stopped it: a switch or settings save, key learning, Escape, an Android session, or a failed key reservation, which backs off two seconds between attempts. Saving switches and starting a capture pause scanning instead of being refused while it runs, so the Switches tab is never locked. Escape resets the scan and the keys are re-reserved on the next tick; nothing waits for window focus. Exit halts the reconciler before shutting the broker. The configure command drops its enabled flag; the view keeps its shape and the Scanning tab shows the backend's message as its status line. Co-Authored-By: Claude Fable 5.1 --- docs/point-scan.md | 6 +- docs/switches.md | 8 +- src-tauri/src/lib.rs | 14 +-- src-tauri/src/point_scan_runtime.rs | 9 +- src-tauri/src/scanning_runtime.rs | 151 ++++++++++++++++++++++------ src-tauri/src/switch_runtime.rs | 4 +- src-tauri/src/switches.rs | 5 +- src/App.tsx | 3 +- src/PointScan.test.tsx | 124 ++++++++++------------- src/Switches.test.tsx | 33 ++---- src/scanning/useScanning.ts | 48 +-------- src/scanning/useSwitches.ts | 22 +--- src/settings/ScanningSection.tsx | 37 ++----- src/settings/SettingsView.tsx | 2 +- src/settings/SwitchesSection.tsx | 14 +-- 15 files changed, 226 insertions(+), 254 deletions(-) diff --git a/docs/point-scan.md b/docs/point-scan.md index 7a0ea5fe..82e6aaa1 100644 --- a/docs/point-scan.md +++ b/docs/point-scan.md @@ -2,15 +2,15 @@ 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 → Switches** to add named keyboard switches and assign normal and hold actions. Then enable scanning in **Settings → Scanning**. Fresh installs have no assignments; old point-scan keys migrate once. See [switch assignments](switches.md). Scanning stays off at startup. +Open **Settings → Switches** to add named keyboard switches and assign normal and hold actions. There is no on/off control: scanning is armed whenever the saved switches cover the current mode (Select for automatic scanning; Select, Next and Previous for manual) and the environment allows it. The runtime re-arms after a save, after key learning, after Escape, when an Android session ends, and retries a failed key reservation every two seconds. Fresh installs have no assignments; old point-scan keys migrate once. See [switch assignments](switches.md). Scanning arms at startup, so assigned keys are reserved from launch. Focus the intended application and press Select to start. Line mode chooses X, then Y, and clicks once. Grid mode chooses a row, then a cell, before the same line sequence. Selecting happens on switch release; holding any switch freezes the position and repeat keydowns do not select again. The scan resets after a click and waits for the next Select. Movement wraps at the selected region's edges. Automatic movement stops after three full passes of the current phase without a selection; the scan resets and waits for the next Select. Manual steps never trigger this limit, and each Select starts a fresh count for the next phase. Android's five speeds are 45, 75, 120, 180, and 270 logical units per second, with delayed ticks capped at 250 ms. The scan uses the monitor under the pointer when it starts. Windows uses native physical coordinates and display scaling; macOS uses Core Graphics display units and converts overlay rectangles to AppKit coordinates. A monitor geometry change cancels scanning. Native overlay strips are topmost, click-through, and nonactivating. The pointer moves only 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. +This first port uses switches attached to the computer. An Android connection pauses local scanning, which resumes when the connection ends. 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. +Changes save automatically, including when you leave Settings, and apply straight away: saving pauses scanning and the runtime re-arms it with the new settings. A failed save keeps your edits and offers Retry save. 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. Switchify's local `switch_input` adapter supplies press/release events on both platforms. See `switches.md` for Windows capture limitations. Only the main window can configure scanning through IPC. diff --git a/docs/switches.md b/docs/switches.md index 3d0d27d8..ec7033ad 100644 --- a/docs/switches.md +++ b/docs/switches.md @@ -1,16 +1,16 @@ # Switch assignments -In Settings → Switches, each switch is a row showing its name, key and actions; Edit expands it. Add switch starts learning immediately: press and release the physical key, then enter a name and choose actions, then save. Learning can also be restarted from Change key. Assign Select, Next, Previous, Reverse direction, Stop scanning or Pause / resume. Each key belongs to one switch; several switches can run the same action. Existing assignments save automatically. Disable scanning before editing. While learning, a modal dialog holds keyboard focus and swallows key events so a switch press cannot scroll Settings or activate a button; Cancel capture is mouse-only and Escape cancels through the backend. Learning ends when Settings loses focus, the panel closes, or Escape is pressed. +In Settings → Switches, each switch is a row showing its name, key and actions; Edit expands it. Add switch starts learning immediately: press and release the physical key, then enter a name and choose actions, then save. Learning can also be restarted from Change key. Assign Select, Next, Previous, Reverse direction, Stop scanning or Pause / resume. Each key belongs to one switch; several switches can run the same action. Existing assignments save automatically and apply straight away; saving or learning pauses scanning until it finishes. While learning, a modal dialog holds keyboard focus and swallows key events so a switch press cannot scroll Settings or activate a button; Cancel capture is mouse-only and Escape cancels through the backend. Learning ends when Settings loses focus, the panel closes, or Escape is pressed. The normal action runs on release. Hold actions are offered in their configured order. The interval defaults to 1 second and accepts 250–5000 milliseconds; five presets are shown and the rest sit behind an exact-interval select. The editor shows the time at which each hold action is offered. Each interval offers the next action in a nonactivating desktop prompt; the last remains selected. Release executes only the offered action. Movement freezes while any switch is held. The first pressed switch owns the gesture; overlapping presses do not activate additional actions. -Stop resets the scan but leaves switches enabled. Select starts again. Pause preserves position. Reverse changes direction without stepping. Automatic scanning requires Select; manual scanning also requires Next and Previous. Hold assignments count toward those requirements. +Stop resets the scan and leaves the keys reserved. Select starts again. Pause preserves position. Reverse changes direction without stepping. Automatic scanning requires Select; manual scanning also requires Next and Previous. Hold assignments count toward those requirements. -Escape disables capture immediately. The emergency hold timeout is at least 4 seconds and extends to (longest hold list + 2) × interval when hold actions exist. Settings shows the resulting duration. Heartbeat loss, capture failure, overflow, Android connection and shutdown cancel gestures without executing release actions. +Escape and the emergency hold reset the scan and release the keys; the runtime re-arms them on its next tick. The emergency hold timeout is at least 4 seconds and extends to (longest hold list + 2) × interval when hold actions exist. Settings shows the resulting duration. Heartbeat loss, capture failure, overflow, Android connection and shutdown cancel gestures without executing release actions. ## Storage and integration -The separate switch-settings.json uses schema version 1. Existing point-scan keys migrate once into four named assignments with empty hold lists. Fresh installs persist an empty list. Malformed or unsupported settings are reported without overwriting them. Saves use atomic replacement. Scanning stays off at startup. +The separate switch-settings.json uses schema version 1. Existing point-scan keys migrate once into four named assignments with empty hold lists. Fresh installs persist an empty list. Malformed or unsupported settings are reported without overwriting them. Saves use atomic replacement. Scanning arms at startup whenever the saved switches allow. Existing point-scan commands and event fields remain compatible for geometry and automatic mode. Legacy key fields remain readable; changing them is rejected with a direction to Settings → Switches. New settings and learning commands are restricted to the main window capability and check its label. On Windows the learning command checks that the main window is the foreground window rather than tao's focus flag, which turns false once keyboard focus moves into the WebView2 child. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6148a35a..929fcf2a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -209,8 +209,8 @@ pub(crate) fn sync_tray_state(app: &AppHandle, state: &AppState) { } fn finish_app_exit(app: &AppHandle) { + scanning_runtime::halt(app); app.state::().shutdown(); - scanning_runtime::cancel(app); app.state::().cancel(app); let model = app.state::(); let _ = platform_disconnect_all(app, &model.shared); @@ -1263,9 +1263,8 @@ fn save_switches( settings: switches::Settings, ) -> Result { require_main(&window)?; - if app.state::().view().enabled { - return Err("Disable scanning before editing switches.".into()); - } + // Sync commands run on the main thread, which pausing the overlay needs. + point_scan_runtime::pause(&app); app.state::() .save(&app, settings) } @@ -1275,13 +1274,11 @@ fn begin_switch_capture( app: AppHandle, ) -> Result { require_main(&window)?; - if app.state::().view().enabled { - return Err("Disable scanning before learning a switch.".into()); - } if !main_window_focused(&window)? { return Err("Focus Switchify PC before learning a switch.".into()); } point_scan_prepare(&app)?; + point_scan_runtime::pause(&app); app.state::() .begin_capture(&app) } @@ -1307,12 +1304,11 @@ fn get_point_scan( 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)); + let _ = tx.send(point_scan_runtime::configure(&handle, config)); }) .map_err(|e| e.to_string())?; rx.await diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 70920054..f4c61c1e 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -9,12 +9,15 @@ use tauri::{AppHandle, Manager}; 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 { +pub fn configure(app: &AppHandle, config: Config) -> Result { let previous = app.state::().view().config; if config.switches().keys() != previous.switches().keys() { return Err("Edit key assignments in Settings → Switches.".into()); } - scanning_runtime::configure::(app, config, enabled) + scanning_runtime::configure::(app, config) +} +pub fn pause(app: &AppHandle) { + scanning_runtime::pause::(app); } pub fn install(app: &AppHandle) { scanning_runtime::install::(app); @@ -70,7 +73,7 @@ fn validate_display(app: &AppHandle, display: Option<&Display>) -> Result<(), St 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()); + return Err("Display geometry changed. Scanning restarts.".into()); } } Ok(()) diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 27aa820e..ee7d53ad 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -40,8 +40,15 @@ use std::{ use tauri::{AppHandle, Emitter, Manager}; thread_local! {static HOST:RefCell>=const{RefCell::new(None)}; static PROMPT:RefCell>=const{RefCell::new(None)};} +/// Scanning has no on/off switch. It is armed whenever the saved switches can +/// drive the current mode and the environment allows it, and the tick loop +/// re-arms it after anything that stopped it: a save, key learning, Escape, an +/// Android session or a failed key reservation. Failed attempts back off by this +/// much so a key held by another application is not hammered every tick. +const RETRY_MS: u64 = 2000; pub struct Controller { enabled: AtomicBool, + halted: AtomicBool, generation: AtomicU64, data: Mutex>, } @@ -54,6 +61,7 @@ struct Data { input_generation: u64, last_tick: Instant, message: String, + next_attempt: Option, } #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -75,6 +83,7 @@ impl Controller { .unwrap_or_default(); Self { enabled: AtomicBool::new(false), + halted: AtomicBool::new(false), generation: AtomicU64::new(0), data: Mutex::new(Data { config, @@ -84,7 +93,8 @@ impl Controller { switches: Settings::default(), input_generation: 0, last_tick: Instant::now(), - message: "Scanning is off.".into(), + message: "Starting switch scanning...".into(), + next_attempt: None, }), } } @@ -115,12 +125,26 @@ fn publish(app: &AppHandle) { // One local technique owns the switch keys and overlay at a time. struct ScanService { cancel: fn(&AppHandle), + halt: fn(&AppHandle), } +/// Stops scanning now; the tick loop re-arms it once conditions allow. pub fn cancel(app: &AppHandle) { if let Some(service) = app.try_state::() { (service.cancel)(app); } } +/// Stops scanning for good, for application exit. +pub fn halt(app: &AppHandle) { + if let Some(service) = app.try_state::() { + (service.halt)(app); + } +} +fn halt_for(app: &AppHandle) { + if let Some(c) = app.try_state::>() { + c.halted.store(true, Ordering::SeqCst); + } + cancel_for::(app); +} fn cancel_for(app: &AppHandle) { let Some(c) = app.try_state::>() else { return; @@ -159,29 +183,21 @@ fn disable(app: &AppHandle, message: &str) { hide_prompt(); publish::(app); } +/// Pauses scanning so switches can be saved or learned. Must run on the main +/// thread, as the commands that call it do; the tick loop re-arms afterwards. +pub fn pause(app: &AppHandle) { + disable::(app, "Scanning paused while switches change."); +} +/// Saves new settings and re-arms scanning with them. A failure to arm is not +/// an error here: the settings are saved and the view's message says why +/// scanning is off, and the tick loop keeps trying. 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>(()) - })?; - app.state::() - .enable(A::switches(&config).automatic)?; - } + disable::(app, "Applying scanning settings..."); let save = || -> Result<(), String> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; @@ -200,24 +216,90 @@ pub fn configure( { let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); d.config = config; - d.switches = app.state::().settings(); - d.pressed = Gestures::default(); - d.input_generation = app.state::().generation(); - d.message = if enabled { - "Ready. Press the select switch to begin." - } else { - "Scanning is off." - } - .into(); + d.next_attempt = None; } - 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); + ensure::(app); publish::(app); Ok(c.view()) } +/// Why scanning should stay off, if it should. Pure, so it is safe every tick. +fn wanted(app: &AppHandle, config: &A::Config) -> Result<(), String> { + if !cfg!(any(target_os = "windows", target_os = "macos")) { + return Err("Switch scanning is not available on this platform.".into()); + } + let switches = app.state::().view(); + if let Some(error) = switches.error { + return Err(error); + } + if switches.capture.active { + return Err("Learning a switch. Scanning resumes afterwards.".into()); + } + switches + .settings + .validate_actions(A::switches(config).automatic) +} +/// Reserves the switch keys and readies the overlay. Leaves nothing behind on +/// failure: the broker only enables after every key registered. +fn arm(app: &AppHandle, config: &A::Config) -> Result<(), String> { + A::prepare(app)?; + HOST.with(|slot| { + if slot.borrow().is_none() { + *slot.borrow_mut() = Some(Host::new()?); + } + Ok::<_, String>(()) + })?; + let switches = app.state::(); + switches.enable(A::switches(config).automatic)?; + let c = app.state::>(); + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + d.engine = None; + d.display = None; + d.switches = switches.settings(); + d.pressed = Gestures::default(); + d.input_generation = switches.generation(); + d.last_tick = Instant::now(); + Ok(()) +} +/// Arms scanning if it is wanted and not already running. Main thread only. +fn ensure(app: &AppHandle) { + let c = app.state::>(); + if c.enabled.load(Ordering::SeqCst) || c.halted.load(Ordering::SeqCst) { + return; + } + let (config, due) = { + let d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + ( + d.config.clone(), + d.next_attempt.is_none_or(|at| Instant::now() >= at), + ) + }; + let outcome = match wanted::(app, &config) { + Err(reason) => Err((reason, false)), + Ok(()) if !due => return, + Ok(()) => arm::(app, &config).map_err(|e| (e, true)), + }; + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + match outcome { + Ok(()) => { + d.next_attempt = None; + d.message = "Ready. Press the select switch to begin.".into(); + drop(d); + c.enabled.store(true, Ordering::SeqCst); + publish::(app); + } + Err((message, failed)) => { + if failed { + d.next_attempt = Some(Instant::now() + std::time::Duration::from_millis(RETRY_MS)); + } + let changed = d.message != message; + d.message = message; + drop(d); + if changed { + publish::(app); + } + } + } +} fn switch(app: &AppHandle, action: Action, input_generation: u64) { let c = app.state::>(); if !c.enabled.load(Ordering::SeqCst) { @@ -231,7 +313,8 @@ fn switch(app: &AppHandle, action: Action, input_generation: u64) { return; } if action == Action::Cancel { - disable::(app, "Scanning cancelled. Switch keys released."); + // Escape resets the scan; the next tick re-arms the keys. + disable::(app, "Escape pressed. Scanning reset."); return; } hide_prompt(); @@ -328,6 +411,7 @@ fn tick(app: &AppHandle) { } } if !c.enabled.load(Ordering::SeqCst) { + ensure::(app); return; } if app.state::().snapshot().bluetooth @@ -359,6 +443,7 @@ pub fn install(app: &AppHandle) { app.manage(Controller::::new(app)); app.manage(ScanService { cancel: cancel_for::, + halt: halt_for::, }); let app = app.clone(); tauri::async_runtime::spawn(async move { diff --git a/src-tauri/src/switch_runtime.rs b/src-tauri/src/switch_runtime.rs index 5d8d3fdf..67e6a57d 100644 --- a/src-tauri/src/switch_runtime.rs +++ b/src-tauri/src/switch_runtime.rs @@ -141,7 +141,7 @@ impl Controller { .mode != Mode::Off { - return Err("Disable scanning and finish capture before changing switches.".into()); + return Err("Finish learning the switch before changing switches.".into()); } { let d = self.data.lock().unwrap_or_else(|p| p.into_inner()); @@ -266,7 +266,7 @@ pub fn stop_message(reason: StopReason) -> &'static str { StopReason::HeartbeatTimeout => "Switch capture stopped because its heartbeat was missed.", StopReason::QueueOverflow => "Switch capture stopped because input could not be processed.", StopReason::CaptureLost => { - "Switch capture was lost. Check input permission and enable scanning again." + "Switch capture was lost. Check input permission; scanning retries." } } } diff --git a/src-tauri/src/switches.rs b/src-tauri/src/switches.rs index 7c9d00f6..8c303c2f 100644 --- a/src-tauri/src/switches.rs +++ b/src-tauri/src/switches.rs @@ -73,12 +73,13 @@ impl Settings { }; if !has(Action::Select) { return Err( - "Assign Select to a switch in Settings → Switches before enabling scanning.".into(), + "Scanning starts once a switch has the Select action. Add one in Settings → Switches." + .into(), ); } if !automatic && (!has(Action::Next) || !has(Action::Back)) { return Err( - "Manual scanning requires Select, Next and Previous switch actions.".into(), + "Manual scanning starts once switches cover Select, Next and Previous.".into(), ); } Ok(()) diff --git a/src/App.tsx b/src/App.tsx index 18ab49a4..2da8dcf9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -438,8 +438,7 @@ function PairingDialog({ requests, connectedDeviceName, busy, approve, reject }: export function App() { const switches=useSwitches(); - const scanning=useScanning(switches.flush); - switches.setLocked(!!scanning.state?.enabled||scanning.toggling); + const scanning=useScanning(); const [state, setState] = useState(null); const [view, setView] = useState("home"); const viewRef = useRef("home"); diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index d6330fb6..c4d2ca70 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -20,13 +20,14 @@ function 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 })); +// The backend decides whether scanning runs; the panel only reports its message. const initial: PointScanState = { config: defaultPointScanConfig, enabled: false, phase: "idle", paused: false, supported: true, - message: "Point scan is off.", + message: "Scanning starts once a switch has the Select action.", }; beforeEach(() => { Object.defineProperty(window, "__TAURI_INTERNALS__", { @@ -40,48 +41,50 @@ beforeEach(() => { 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(); +afterEach(() => { + Reflect.deleteProperty(window, "__TAURI_INTERNALS__"); }); -it("exposes grid settings and directs switch assignments to their own tab", async () => { + +it("shows the backend's reason while scanning is off and offers no toggle", 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"); - expect(screen.queryByLabelText("Forward switch")).not.toBeInTheDocument(); - expect(screen.getByText(/Assign switch actions in the Switches tab/)).toBeInTheDocument(); + await screen.findByText("Scanning starts once a switch has the Select action."); + expect(screen.queryByRole("button", { name: /point scan/i })).toBeNull(); + expect(screen.getByRole("button", { name: "Line only" })).toBeEnabled(); }); -it("reports native registration failure without claiming scanning started", async () => { + +it("saves settings without an enabled flag and keeps them editable while scanning runs", async () => { + mocks.invoke.mockImplementation(async (command, args) => + command === "get_point_scan" + ? { ...initial, enabled: true, message: "Ready. Press the select switch to begin." } + : { ...initial, enabled: true, ...args }, + ); 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 screen.findByText("Ready to begin."); + fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); await waitFor(() => - expect(screen.getByRole("alert")).toHaveTextContent( - "Space is already in use.", - ), + expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { + config: { ...defaultPointScanConfig, mode: "grid" }, + }), ); - expect( - screen.getByRole("button", { name: "Enable point scan" }), - ).toBeEnabled(); + expect(screen.getByLabelText("Grid size")).toHaveValue("4"); + expect(screen.getByText(/Assign switch actions in the Switches tab/)).toBeInTheDocument(); }); -afterEach(() => { - Reflect.deleteProperty(window, "__TAURI_INTERNALS__"); +it("reports the scan phase while enabled", async () => { + mocks.invoke.mockImplementation(async () => ({ + ...initial, + enabled: true, + phase: "x", + paused: true, + })); + render(); + await screen.findByText("Paused. Choose the horizontal position."); }); -it("keeps newer edits across old events and saves before enabling", async () => { +it("keeps newer edits across old events", async () => { let resolveSave!: (value: PointScanState) => void; render(); - await screen.findByText("Point scan is off."); + await screen.findByText(initial.message); mocks.invoke.mockImplementationOnce( () => new Promise((resolve) => { @@ -96,41 +99,32 @@ it("keeps newer edits across old events and saves before enabling", async () => 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, - }); + await waitFor(() => + expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { + config: { ...initial.config, mode: "grid", gridSize: 7 }, + }), + ); + await screen.findByText("Scanning settings save automatically."); }); -it("retains failed edits and requires a successful retry before enabling", async () => { +it("retains failed edits and offers a retry", async () => { render(); - await screen.findByText("Point scan is off."); + await screen.findByText(initial.message); 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); + expect(screen.getByText("Scanning settings have unsaved changes.")).toBeInTheDocument(); 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 () => { +it("keeps pending saves when its settings panel unmounts", async () => { function Shell({ visible }: { visible: boolean }) { const controller = useScanning(); return visible ? ( @@ -141,7 +135,7 @@ it("keeps pending saves and an enabled scan when its settings panel unmounts", a } let resolveSave!: (value: PointScanState) => void; const view = render(); - await screen.findByText("Point scan is off."); + await screen.findByText(initial.message); mocks.invoke.mockImplementationOnce( () => new Promise((resolve) => { @@ -158,29 +152,20 @@ it("keeps pending saves and an enabled scan when its settings panel unmounts", a 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 () => { +it("keeps a newer runtime event when an older save response arrives", async () => { render(); - await screen.findByText("Point scan is off."); - let resolveEnable!: (value: PointScanState) => void; + await screen.findByText(initial.message); + let resolveSave!: (value: PointScanState) => void; mocks.invoke.mockImplementationOnce( () => new Promise((resolve) => { - resolveEnable = resolve; + resolveSave = resolve; }), ); - fireEvent.click(screen.getByRole("button", { name: "Enable point scan" })); - await waitFor(() => expect(resolveEnable).toBeTypeOf("function")); + fireEvent.click(screen.getByRole("button", { name: "Grid then line" })); + await waitFor(() => expect(resolveSave).toBeTypeOf("function")); act(() => mocks.listen.mock.calls[0][1]({ payload: { @@ -189,10 +174,9 @@ it("keeps a newer cancellation event when an older enable response arrives", asy }, }), ); - await act(async () => resolveEnable({ ...initial, enabled: true })); - expect( - screen.getByRole("button", { name: "Enable point scan" }), - ).toBeEnabled(); + await act(async () => + resolveSave({ ...initial, enabled: true, message: "Ready." }), + ); expect( screen.getByText("Android connected. Local scanning stopped."), ).toBeInTheDocument(); diff --git a/src/Switches.test.tsx b/src/Switches.test.tsx index e7cb0211..567cd453 100644 --- a/src/Switches.test.tsx +++ b/src/Switches.test.tsx @@ -44,16 +44,10 @@ const scan = { }; function Shell({ visible = true }: { visible?: boolean }) { const switches = useSwitches(); - const scanning = useScanning(switches.flush); - switches.setLocked(!!scanning.state?.enabled || scanning.toggling); + const scanning = useScanning(); return ( <> - {visible && ( - - )} + {visible && } ); @@ -159,7 +153,7 @@ it("rejects a learned duplicate without changing the existing switch", async () await screen.findByText("That key already belongs to another switch."); expect(current.settings.bindings).toHaveLength(1); }); -it("reorders hold actions and waits for pending switch saves before enabling", async () => { +it("reorders hold actions and reflects the saved order in the summary", async () => { render(); await screen.findByRole("heading", { name: "Head switch" }); open("Head switch"); @@ -174,12 +168,7 @@ it("reorders hold actions and waits for pending switch saves before enabling", a screen.getByRole("button", { name: "Move hold action 2 up" }), ); await waitFor(() => expect(finish).toBeTypeOf("function")); - fireEvent.click(screen.getByRole("button", { name: "Enable point scan" })); - expect( - mocks.invoke.mock.calls.some( - ([c, a]) => c === "configure_point_scan" && a.enabled, - ), - ).toBe(false); + expect(screen.getByText("Saving switches...")).toBeTruthy(); await act(async () => finish({ ...current, @@ -191,9 +180,8 @@ it("reorders hold actions and waits for pending switch saves before enabling", a }, }), ); - await screen.findByRole("button", { name: "Disable point scan" }); - expect(screen.getByLabelText("Normal action for Head switch")).toBeDisabled(); expect(screen.getByText("Select · Hold: Stop scanning, Next")).toBeTruthy(); + expect(screen.getByLabelText("Normal action for Head switch")).toBeEnabled(); }); it("shows hold timing computed from the interval", async () => { render(); @@ -206,7 +194,7 @@ it("shows hold timing computed from the interval", async () => { await screen.findByText("Hold 2s for Next, 4s for Stop scanning. Release to run the action shown."); await screen.findByText(/Holding any switch for 8s disables switch control/); }); -it("preserves failed switch edits and prevents enable until retry succeeds", async () => { +it("preserves failed switch edits until a retry succeeds", async () => { render(); await screen.findByRole("heading", { name: "Head switch" }); open("Head switch"); @@ -215,13 +203,8 @@ it("preserves failed switch edits and prevents enable until retry succeeds", asy target: { value: "New name" }, }); await screen.findByText("Disk full"); - fireEvent.click(screen.getByRole("button", { name: "Enable point scan" })); - await screen.findByText(/Save switch assignments before enabling/); - expect( - mocks.invoke.mock.calls.some( - ([c, a]) => c === "configure_point_scan" && a.enabled, - ), - ).toBe(false); + expect(screen.getByLabelText("Name for Space")).toHaveValue("New name"); + expect(screen.getByText("Switch assignments have unsaved changes.")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Retry save" })); await waitFor(() => expect(current.settings.bindings[0].name).toBe("New name"), diff --git a/src/scanning/useScanning.ts b/src/scanning/useScanning.ts index 9731d93e..94dc01ba 100644 --- a/src/scanning/useScanning.ts +++ b/src/scanning/useScanning.ts @@ -44,19 +44,18 @@ export function validSwitches(config: PointScanConfig) { ); } // App owns this hook so changing tabs or views never drops edits or stops a scan. -export function useScanning(beforeEnable?: () => Promise) { +// Scanning has no toggle: the backend arms it whenever the saved switches allow +// and reports why not through the view's message. +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()); @@ -66,7 +65,6 @@ export function useScanning(beforeEnable?: () => Promise) { let stop: (() => void) | undefined; const receive = (next: PointScanState) => { if (!alive) return; - model.current.enabled = next.enabled; model.current.supported = next.supported; setState(next); if ( @@ -126,20 +124,16 @@ export function useScanning(beforeEnable?: () => Promise) { 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); - } + if (runtime === runtimeRevision.current) setState(result); }); const update = ( key: K, value: PointScanConfig[K], ) => { const m = model.current; - if (m.enabled || m.toggling || !m.supported) return; + if (!m.supported) return; const next = { ...m.config, [key]: value }; m.config = next; m.revision++; @@ -151,45 +145,13 @@ export function useScanning(beforeEnable?: () => Promise) { 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) await beforeEnable?.(); - 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, }; } diff --git a/src/scanning/useSwitches.ts b/src/scanning/useSwitches.ts index 88cf0204..cb8c4a36 100644 --- a/src/scanning/useSwitches.ts +++ b/src/scanning/useSwitches.ts @@ -46,7 +46,6 @@ export function useSwitches() { revision: 0, saved: 0, pending: 0, - locked: false, state: null as SwitchState | null, }); const captureRequest = useRef(0); @@ -120,27 +119,20 @@ export function useSwitches() { }; const update = (next: SwitchSettings) => { const m = model.current; - if (m.locked || m.state?.capture.active || !m.state?.supported) return; + if (m.state?.capture.active || !m.state?.supported) return; m.settings = next; m.revision++; setSettings(next); save(next, m.revision); }; - const flush = async () => { - await queue.current; - if (model.current.revision !== model.current.saved) - throw new Error( - "Save switch assignments before enabling scanning. Use Retry save.", - ); - if (model.current.state?.capture.active) - throw new Error("Finish learning the switch before enabling scanning."); - }; const capture = async () => { - if (model.current.locked) return; const request = ++captureRequest.current; setCapturing(true); try { - await flush(); + // Let queued saves land first so learning starts from the saved state. + await queue.current; + if (model.current.revision !== model.current.saved) + throw new Error("Save switch assignments before learning. Use Retry save."); if (request !== captureRequest.current) return; setError(null); if (model.current.state) @@ -180,14 +172,10 @@ export function useSwitches() { error, capturing, update, - flush, capture, cancelCapture, retry: () => save(model.current.settings, model.current.revision), unsaved: model.current.revision !== model.current.saved, - setLocked: (value: boolean) => { - model.current.locked = value; - }, }; } export type SwitchController = ReturnType; diff --git a/src/settings/ScanningSection.tsx b/src/settings/ScanningSection.tsx index 3417bda4..a11e69c2 100644 --- a/src/settings/ScanningSection.tsx +++ b/src/settings/ScanningSection.tsx @@ -19,19 +19,9 @@ export function ScanningSection({ }: { controller: ScanningController; }) { - const { - state, - config, - pending, - error, - toggling, - update, - retry, - toggle, - unsaved, - } = controller; - - const disabled = !state?.supported || !!state.enabled || toggling; + const { state, config, pending, error, update, retry, unsaved } = controller; + + const disabled = !state?.supported; return ( <> @@ -46,8 +36,9 @@ export function ScanningSection({

- Switch keys are reserved while enabled. Escape stops scanning and - releases them. Disconnect Android before enabling local point scan. + Scanning is on whenever a switch has the Select action, and its keys + stay reserved while Switchify runs. Escape resets the scan. Android + connections pause local scanning until they end.

On Windows, use switch keys without Shift, Ctrl, Alt or Windows held. @@ -60,18 +51,6 @@ export function ScanningSection({ : (state?.message ?? "Loading point scan...")}

- -

{pending ? "Saving scanning settings..." @@ -118,7 +97,7 @@ export function ScanningSection({ legend="Mode" @@ -173,7 +152,7 @@ export function ScanningSection({ )} -

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

+

Settings are saved and scanning resumes when the app restarts.

); diff --git a/src/settings/SettingsView.tsx b/src/settings/SettingsView.tsx index eea2b84e..22abae50 100644 --- a/src/settings/SettingsView.tsx +++ b/src/settings/SettingsView.tsx @@ -62,7 +62,7 @@ export function SettingsView({ switches, scanning, state, settings, onChange, ch {active === "general" && } {active === "pointer" && } - {active === "switches" && } + {active === "switches" && } {active === "scanning" && } {active === "cursor" && } {active === "privacy" && } diff --git a/src/settings/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx index 2551455b..c81cc38f 100644 --- a/src/settings/SwitchesSection.tsx +++ b/src/settings/SwitchesSection.tsx @@ -307,13 +307,7 @@ function CaptureDialog({ name, onCancel }: { name: string; onCancel: () => void ); } -export function SwitchesSection({ - controller, - locked, -}: { - controller: SwitchController; - locked: boolean; -}) { +export function SwitchesSection({ controller }: { controller: SwitchController }) { const { settings, state, pending, unsaved } = controller; // A refused capture sets both the general error and the capture error; the // key field already shows the latter, so the band only carries save errors. @@ -387,7 +381,7 @@ export function SwitchesSection({ if (state.capture.error) setRowError({ id: target, message: state.capture.error }); setTarget(null); }, [capturing, target, state]); - const disabled = locked || !state?.supported || !!state.error || capturing; + const disabled = !state?.supported || !!state.error || capturing; // A learned key arrives while the editor is disabled; focus the name once the // fieldset is enabled again so a new switch can be named straight away. useEffect(() => { @@ -483,9 +477,7 @@ export function SwitchesSection({ ? "Saving switches..." : unsaved ? "Switch assignments have unsaved changes." - : locked - ? "Disable scanning to change switches." - : "Changes save automatically."} + : "Changes save automatically and apply straight away."}

{(error || state?.error) && (
From de79b9e60c52f80df33e204e6771960ccda54f1a Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 13 Sep 2026 08:52:52 +0100 Subject: [PATCH 2/2] Re-check the environment every tick instead of backing off An Android connection or missing input access failed inside arm() and started the two-second retry timer, so scanning stayed off for up to two seconds after the condition cleared. The adapter now exposes a pure ready() check that the reconciler polls each tick; only a failed key reservation or overlay start backs off. Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/lib.rs | 10 ++++++++-- src-tauri/src/point_scan_runtime.rs | 3 +++ src-tauri/src/scanning_runtime.rs | 9 +++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 929fcf2a..23d6784d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1315,14 +1315,20 @@ async fn configure_point_scan( .map_err(|_| "Point scan configuration was cancelled.".to_string())? } -fn point_scan_prepare(app: &AppHandle) -> Result<(), String> { +/// Pure environment check, safe to call every tick while scanning is off. +fn point_scan_ready(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()); + return Err("Local scanning pauses while Android is connected.".into()); } if state.accessibility != state::AccessibilityState::Granted { return Err("Grant input access before using point scan.".into()); } + Ok(()) +} + +fn point_scan_prepare(app: &AppHandle) -> Result<(), String> { + point_scan_ready(app)?; app.state::().cancel(app); platform_stop_mouse_repeat(app); Ok(()) diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index f4c61c1e..50b5a61f 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -40,6 +40,9 @@ impl Adapter for PointScan { fn validate_environment(app: &AppHandle, display: Option<&Display>) -> Result<(), String> { validate_display(app, display) } + fn ready(app: &AppHandle) -> Result<(), String> { + crate::point_scan_ready(app) + } fn prepare(app: &AppHandle) -> Result<(), String> { crate::point_scan_prepare(app) } diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index ee7d53ad..b779e967 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -20,6 +20,9 @@ pub trait Adapter: Send + Sync + 'static { app: &AppHandle, environment: Option<&Self::Environment>, ) -> Result<(), String>; + /// Pure check that the environment allows scanning, polled while it is off. + fn ready(app: &AppHandle) -> Result<(), String>; + /// Side effects needed right before scanning uses the desktop. fn prepare(app: &AppHandle) -> Result<(), String>; fn activate( app: &AppHandle, @@ -236,10 +239,12 @@ fn wanted(app: &AppHandle, config: &A::Config) -> Result<(), String> } switches .settings - .validate_actions(A::switches(config).automatic) + .validate_actions(A::switches(config).automatic)?; + A::ready(app) } /// Reserves the switch keys and readies the overlay. Leaves nothing behind on -/// failure: the broker only enables after every key registered. +/// failure: the broker only enables after every key registered. Only failures +/// here start the retry backoff; environment conditions are re-read each tick. fn arm(app: &AppHandle, config: &A::Config) -> Result<(), String> { A::prepare(app)?; HOST.with(|slot| {