diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 72f2c25a..089908e9 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -6,6 +6,9 @@ fn main() { let app_manifest = tauri_build::AppManifest::new().commands(&[ "get_app_state", "get_switches", + "begin_switch_practice", + "get_switch_practice", + "end_switch_practice", "get_remote_switches", "save_remote_switches", "save_switches", diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index 474820ed..893c2bee 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -7,6 +7,9 @@ "core:default", "allow-get-app-state", "allow-get-switches", + "allow-begin-switch-practice", + "allow-get-switch-practice", + "allow-end-switch-practice", "allow-get-remote-switches", "allow-save-remote-switches", "allow-save-switches", diff --git a/src-tauri/permissions/autogenerated/begin_switch_practice.toml b/src-tauri/permissions/autogenerated/begin_switch_practice.toml new file mode 100644 index 00000000..747cea9b --- /dev/null +++ b/src-tauri/permissions/autogenerated/begin_switch_practice.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-begin-switch-practice" +description = "Enables the begin_switch_practice command without any pre-configured scope." +commands.allow = ["begin_switch_practice"] + +[[permission]] +identifier = "deny-begin-switch-practice" +description = "Denies the begin_switch_practice command without any pre-configured scope." +commands.deny = ["begin_switch_practice"] diff --git a/src-tauri/permissions/autogenerated/end_switch_practice.toml b/src-tauri/permissions/autogenerated/end_switch_practice.toml new file mode 100644 index 00000000..304a4662 --- /dev/null +++ b/src-tauri/permissions/autogenerated/end_switch_practice.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-end-switch-practice" +description = "Enables the end_switch_practice command without any pre-configured scope." +commands.allow = ["end_switch_practice"] + +[[permission]] +identifier = "deny-end-switch-practice" +description = "Denies the end_switch_practice command without any pre-configured scope." +commands.deny = ["end_switch_practice"] diff --git a/src-tauri/permissions/autogenerated/get_switch_practice.toml b/src-tauri/permissions/autogenerated/get_switch_practice.toml new file mode 100644 index 00000000..bb7fe632 --- /dev/null +++ b/src-tauri/permissions/autogenerated/get_switch_practice.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-get-switch-practice" +description = "Enables the get_switch_practice command without any pre-configured scope." +commands.allow = ["get_switch_practice"] + +[[permission]] +identifier = "deny-get-switch-practice" +description = "Denies the get_switch_practice command without any pre-configured scope." +commands.deny = ["get_switch_practice"] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 132f3ed9..ebadb099 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -37,6 +37,7 @@ mod state; mod storage; mod switch_gestures; mod switch_input; +mod switch_practice; mod switch_runtime; mod switches; mod telemetry; @@ -1272,8 +1273,38 @@ fn save_remote_switches( config: remote_scan::Config, ) -> Result { require_main(&window)?; + if switch_practice::active(&app) { + return Err("Finish practice before changing switches.".into()); + } remote_scan::save(&app, config) } +#[tauri::command] +fn begin_switch_practice( + window: tauri::WebviewWindow, + app: AppHandle, + remote: bool, +) -> Result { + require_main(&window)?; + if !main_window_focused(&window)? { + return Err("Focus Switchify before testing switches.".into()); + } + switch_practice::begin(&app, remote) +} +#[tauri::command] +fn get_switch_practice( + window: tauri::WebviewWindow, + app: AppHandle, +) -> Result { + require_main(&window)?; + Ok(switch_practice::view(&app)) +} +#[tauri::command] +fn end_switch_practice(window: tauri::WebviewWindow, app: AppHandle) -> Result<(), String> { + require_main(&window)?; + switch_practice::end(&app); + Ok(()) +} + #[tauri::command] fn get_switches( window: tauri::WebviewWindow, @@ -1289,6 +1320,9 @@ fn save_switches( settings: switches::Settings, ) -> Result { require_main(&window)?; + if switch_practice::active(&app) { + return Err("Finish practice before changing switches.".into()); + } // Sync commands run on the main thread, which pausing the overlay needs. point_scan_runtime::pause(&app); let view = app @@ -1331,6 +1365,9 @@ fn set_switch_keyboard_entry( require_main(&window)?; let switches = app.state::(); if active { + if switch_practice::active(&app) { + return Err("Finish practice before typing with the keyboard.".into()); + } if !main_window_focused(&window)? { return Err("Focus Switchify PC before typing with the keyboard.".into()); } @@ -1362,7 +1399,12 @@ async fn configure_point_scan( 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)); + let result = if switch_practice::active(&handle) { + Err("Finish practice before changing scanning settings.".into()) + } else { + point_scan_runtime::configure(&handle, config) + }; + let _ = tx.send(result); }) .map_err(|e| e.to_string())?; rx.await @@ -1376,6 +1418,9 @@ fn point_scan_ready(app: &AppHandle) -> Result<(), String> { "Typing with the keyboard. Resume switch control in Switches when finished.".into(), ); } + if switch_practice::active(app) { + return Err("Finish switch practice before scanning or editing switches.".into()); + } let state = app.state::().snapshot(); if state.bluetooth == state::BluetoothState::Connected && !remote_scan::active(app) { return Err("Local scanning pauses while Android is connected.".into()); @@ -1439,6 +1484,7 @@ pub fn run() { .manage(PendingProfileExit::default()) .manage(PendingNavigation::default()) .setup(move |app| { + app.manage(switch_practice::Controller::default()); switch_runtime::install(app.handle()); remote_scan::install(app.handle()); point_scan_runtime::install(app.handle()); @@ -1526,12 +1572,16 @@ pub fn run() { } } if let tauri::WindowEvent::CloseRequested { api, .. } = event { + switch_practice::end(window.app_handle()); api.prevent_close(); request_profile_exit(window.app_handle(), ProfileExitAction::Hide); } }) .invoke_handler(tauri::generate_handler![ get_switches, + begin_switch_practice, + get_switch_practice, + end_switch_practice, get_remote_switches, save_remote_switches, save_switches, diff --git a/src-tauri/src/remote_scan.rs b/src-tauri/src/remote_scan.rs index 001d2d3d..035382b8 100644 --- a/src-tauri/src/remote_scan.rs +++ b/src-tauri/src/remote_scan.rs @@ -378,6 +378,9 @@ pub fn input_available(session_active: bool, cleanup_required: bool) -> bool { !session_active && !cleanup_required } pub fn allow_direct(app: &AppHandle) -> Result<(), String> { + if crate::switch_practice::active(app) { + return Err("Finish switch practice before using other PC controls.".into()); + } let c = app.state::(); let d = c.data.lock().unwrap_or_else(|p| p.into_inner()); if !input_available(d.session.is_some(), d.cleanup_required) { @@ -418,6 +421,11 @@ pub fn route( command: &str, payload: &Value, ) -> Option> { + if command == "switch.session.start" && crate::switch_practice::active(app) { + return Some(Err( + "Exit switch practice before starting forwarding.".into() + )); + } let scanning_start = command == "switch.session.start" && payload["profileId"] == PROFILE_ID; if command == "switch.session.start" { crate::point_scan_runtime::pause(app); diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 6d4fa9c0..46c55afc 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -621,6 +621,9 @@ fn render( Ok(()) } fn tick(app: &AppHandle) { + if crate::switch_practice::tick(app) { + return; + } let c = app.state::>(); let remote = crate::remote_scan::poll(app); let (mut events, local_now, _) = app.state::().poll(app); diff --git a/src-tauri/src/switch_practice.rs b/src-tauri/src/switch_practice.rs new file mode 100644 index 00000000..0afbc50b --- /dev/null +++ b/src-tauri/src/switch_practice.rs @@ -0,0 +1,402 @@ +//! Bounded switch practice: real gestures and scan engine, no native execution. +use crate::{ + point_scan, + point_workflow::Workflow, + scanning::{Action, Rect, Session, Technique}, + switch_gestures::Gestures, + switch_input::Event, + switch_runtime, + switches::Settings, +}; +use serde::Serialize; +use std::{sync::Mutex, time::Instant}; +use tauri::{AppHandle, Manager}; + +#[derive(Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct View { + pub active: bool, + pub source: String, + pub message: String, + pub input: String, + pub action: Option, + pub completed: u32, + pub rectangles: Vec<[f64; 4]>, + pub tiles: Vec, + pub label: String, +} +#[derive(Clone, Serialize)] +pub struct Tile { + pub rect: [f64; 4], + pub text: String, + pub selected: bool, +} +fn rect(r: Rect) -> [f64; 4] { + [r.x, r.y, r.width, r.height] +} +struct Practice { + view: View, + engine: Session, + gestures: Gestures, + settings: Settings, + generation: u64, + remote: bool, + started: Instant, + last_tick: Instant, + held_since: Option, +} +impl Practice { + fn new( + config: point_scan::Config, + settings: Settings, + generation: u64, + remote: bool, + ) -> Result { + let screen = Rect { + x: 0., + y: 0., + width: 1280., + height: 720., + }; + Ok(Self { + view: View { active: true, source: if remote { "Remote" } else { "Local keyboard" }.into(), message: "Press and release Select to start. Next/Previous move an active scan, not keyboard focus.".into(), ..View::default() }, + engine: Session::new(Workflow::new(config.point(), screen, 1.)?, config.automatic), + gestures: Gestures::default(), settings, generation, remote, + started: Instant::now(), last_tick: Instant::now(), held_since: None, + }) + } + fn edge(&mut self, id: &str, pressed: bool, now: u64) { + let Some(binding) = self.settings.bindings.iter().find(|b| b.id == id) else { + return; + }; + self.view.input = binding.name.clone(); + if pressed { + self.held_since.get_or_insert(now); + self.gestures.pressed_for_scan( + id, + now, + &self.settings, + self.engine.technique.auto_selecting(), + ); + self.view.message = format!( + "{} received. Release for {} or hold for another action.", + binding.name, + binding.press_action.label() + ); + } else { + if let Some(action) = self.gestures.released(id, now) { + self.action(action); + } + if !self.gestures.held() { + self.held_since = None; + } + } + } + fn action(&mut self, action: Action) { + self.view.action = Some(action); + if matches!(action, Action::Stop | Action::Cancel) { + self.view.active = false; + return; + } + let was_idle = !self.engine.active(); + if self.engine.action(action).is_some() { + self.complete(); + } else if was_idle && matches!(action, Action::Next | Action::Back) { + self.view.message = + "Input received. Select starts a scan; Next/Previous only move an active scan." + .into(); + } else { + self.view.message = format!( + "{} received. This practice cannot click or type outside this box.", + action.label() + ); + } + } + fn complete(&mut self) { + // Intentionally discard every request, including keyboard, drag and settings requests. + self.view.completed += 1; + self.view.message = "Practice action completed safely. Nothing was sent to another application. Select starts again.".into(); + self.engine.reset(); + } + fn tick(&mut self, now: u64) { + if self.started.elapsed().as_secs() >= 120 + || self + .held_since + .is_some_and(|s| now.saturating_sub(s) >= self.settings.escape_ms()) + { + self.view.active = false; + return; + } + if let Some(prompt) = self.gestures.prompt(now) { + self.view.message = format!( + "{}: release for {}", + prompt.switch_name, + prompt.action.label() + ); + } + self.engine.tick( + self.last_tick.elapsed().as_millis().min(250) as u64, + self.gestures.held(), + ); + self.last_tick = Instant::now(); + if self.engine.take_selection().is_some() { + self.complete(); + } + let frame = self.engine.frame(); + self.view.rectangles = frame + .rectangles() + .into_iter() + .map(|r| rect(r.rect)) + .collect(); + self.view.tiles = frame + .tiles + .into_iter() + .map(|t| Tile { + rect: rect(t.rect), + text: t.text, + selected: t.selected, + }) + .collect(); + self.view.label = frame.label.map_or_else(String::new, |l| l.text); + } +} +#[derive(Default)] +pub struct Controller { + session: Mutex>, + last: Mutex, +} +pub fn active(app: &AppHandle) -> bool { + app.try_state::().is_some_and(|c| { + c.session + .lock() + .unwrap_or_else(|p| p.into_inner()) + .is_some() + }) +} +pub fn view(app: &AppHandle) -> View { + let c = app.state::(); + let session = c.session.lock().unwrap_or_else(|p| p.into_inner()); + session.as_ref().map_or_else( + || c.last.lock().unwrap_or_else(|p| p.into_inner()).clone(), + |s| s.view.clone(), + ) +} +pub fn stop(app: &AppHandle, message: &str) { + let Some(c) = app.try_state::() else { + return; + }; + let mut lock = c.session.lock().unwrap_or_else(|p| p.into_inner()); + let Some(s) = lock.as_mut() else { + return; + }; + s.gestures.cancel(); + s.view.active = false; + s.view.message = message.into(); + drop(lock); + crate::remote_scan::cancel(app); + crate::point_scan_runtime::pause(app); +} +pub fn end(app: &AppHandle) { + stop(app, "Practice finished."); + if let Some(c) = app.try_state::() { + if let Some(s) = c.session.lock().unwrap_or_else(|p| p.into_inner()).take() { + *c.last.lock().unwrap_or_else(|p| p.into_inner()) = s.view; + } + } +} +pub fn begin(app: &AppHandle, remote: bool) -> Result { + if active(app) { + if view(app).active { + return Err("Finish the current practice first.".into()); + } + end(app); + } + let switches = app.state::(); + if switches.view().capture.active { + return Err("Finish learning before testing switches.".into()); + } + if remote && !crate::remote_scan::active(app) { + return Err( + "No remote forwarding session. Connect Android and start switch forwarding first." + .into(), + ); + } + crate::point_scan_prepare(app)?; + let config = app + .state::() + .view() + .config; + // Clear stale edges before the practice owns input. + let remote_state = crate::remote_scan::poll(app); + let (settings, generation) = if remote { + let Some((g, s, _, _)) = remote_state else { + return Err( + "No remote forwarding session. Connect Android and start switch forwarding first." + .into(), + ); + }; + (s, g) + } else { + if remote_state.is_some() { + return Err("Stop remote forwarding before testing local switches.".into()); + } + (switches.settings(), 0) + }; + let mut session = Practice::new(config, settings, generation, remote)?; + crate::point_scan_runtime::pause(app); + let result = if remote { + switches.enable_escape() + } else { + switches.enable(true) + }; + result?; + if !remote { + session.generation = switches.generation(); + } + *app.state::() + .session + .lock() + .unwrap_or_else(|p| p.into_inner()) = Some(session); + Ok(view(app)) +} +/// Own the whole tick while testing, so no test edge reaches the native executor. +pub fn tick(app: &AppHandle) -> bool { + if !active(app) { + return false; + } + if !view(app).active { + return true; + } + if !app + .get_webview_window("main") + .is_some_and(|w| w.is_visible().unwrap_or(false) && w.is_focused().unwrap_or(false)) + { + stop(app, "Practice stopped because Switchify lost focus."); + return true; + } + let remote = crate::remote_scan::poll(app); + let (events, local_now, _) = app.state::().poll(app); + let c = app.state::(); + let mut lock = c.session.lock().unwrap_or_else(|p| p.into_inner()); + let Some(s) = lock.as_mut() else { + return true; + }; + let mut ended = false; + for event in events { + match event { + Event::Stopped { .. } => ended = true, + Event::Switch { + generation, + switch_id, + action, + monotonic_ms, + } if !s.remote && generation == s.generation => s.edge( + &switch_id, + action == crate::switch_input::Action::Pressed, + monotonic_ms, + ), + _ => {} + } + } + let now = if s.remote { + if let Some((generation, _, edges, now)) = remote { + if generation != s.generation { + ended = true; + } else { + for edge in edges { + match edge { + crate::remote_scan::Edge::Down(id) => s.edge(&id.to_string(), true, now), + crate::remote_scan::Edge::Up(id) => s.edge(&id.to_string(), false, now), + crate::remote_scan::Edge::Reset => ended = true, + } + } + } + now + } else { + ended = true; + local_now + } + } else { + if remote.is_some() { + ended = true; + } + local_now + }; + if !ended { + s.tick(now); + } + ended |= !s.view.active; + drop(lock); + if ended { + stop(app, "Practice stopped. Exit or start another test. Remote forwarding must be started again after testing."); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + fn practice(mode: point_scan::Mode) -> Practice { + let config = point_scan::Config { + mode, + automatic: false, + ..Default::default() + }; + let settings = Settings { + bindings: vec![crate::switches::Binding { + id: "one".into(), + name: "Blue".into(), + key: "Space".into(), + press_action: Action::Select, + hold_actions: vec![Action::Next], + }], + ..Default::default() + }; + Practice::new(config, settings, 7, false).unwrap() + } + #[test] + fn input_feedback_and_hold_actions_use_saved_binding() { + let mut p = practice(point_scan::Mode::Line); + p.edge("one", true, 0); + assert_eq!(p.view.input, "Blue"); + p.edge("one", false, 1001); + assert_eq!(p.view.action, Some(Action::Next)); + assert!(!p.engine.active()); + assert!(p.view.message.contains("Select starts")); + p.edge("one", true, 1200); + p.edge("one", false, 1300); + assert!(p.engine.active()); + } + #[test] + fn line_and_grid_selections_are_discarded_not_executed() { + for mode in [point_scan::Mode::Line, point_scan::Mode::Grid] { + let mut p = practice(mode); + for _ in 0..12 { + p.action(Action::Select); + if p.view.completed > 0 { + break; + } + } + assert!(p.view.completed > 0); + assert!(!p.engine.active()); + } + } + #[test] + fn stop_and_emergency_hold_end_practice() { + let mut p = practice(point_scan::Mode::Line); + p.action(Action::Stop); + assert!(!p.view.active); + let mut p = practice(point_scan::Mode::Line); + p.edge("one", true, 0); + p.tick(p.settings.escape_ms()); + assert!(!p.view.active); + } + #[test] + fn unassigned_input_and_release_without_press_do_nothing() { + let mut p = practice(point_scan::Mode::Line); + p.edge("unknown", true, 0); + p.edge("one", false, 1); + assert!(p.view.action.is_none()); + assert!(!p.engine.active()); + } +} diff --git a/src/scanning/SwitchPractice.test.tsx b/src/scanning/SwitchPractice.test.tsx new file mode 100644 index 00000000..5ffe0ec9 --- /dev/null +++ b/src/scanning/SwitchPractice.test.tsx @@ -0,0 +1,96 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SwitchPractice } from "./SwitchPractice"; +const invoke = vi.hoisted(() => vi.fn()); +vi.mock("@tauri-apps/api/core", () => ({ invoke })); +const view = { active: true, source: "Local keyboard", message: "Press Select", input: "", action: null, completed: 0, rectangles: [], tiles: [], label: "" }; +beforeEach(() => { + Object.defineProperty(window, "__TAURI_INTERNALS__", { configurable: true, value: {} }); + invoke.mockReset(); invoke.mockImplementation(async (command: string) => command === "end_switch_practice" ? undefined : view); +}); +afterEach(() => { cleanup(); delete (window as unknown as Record).__TAURI_INTERNALS__; }); +describe("safe switch practice", () => { + it("uses saved physical input via the backend, then explicitly releases ownership on exit", async () => { + render(); fireEvent.click(screen.getByRole("button", { name: "Test switches" })); + const dialog = await screen.findByRole("dialog", { name: "Safe switch practice" }); + expect(invoke).toHaveBeenCalledWith("begin_switch_practice", { remote: false }); + expect(dialog).toHaveFocus(); + expect(screen.queryByRole("button", { name: "Start another test" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Exit practice" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(invoke).toHaveBeenCalledWith("end_switch_practice"); + expect(screen.getByRole("button", { name: "Test switches" })).toHaveFocus(); + }); + it("shows disconnected remote errors rather than reporting successful input", async () => { + invoke.mockRejectedValueOnce(new Error("No remote forwarding session")); + render(); fireEvent.change(screen.getByLabelText("Test input"), { target: { value: "remote" } }); + fireEvent.click(screen.getByRole("button", { name: "Test switches" })); + expect(await screen.findByRole("alert")).toHaveTextContent("No remote forwarding session"); + expect(invoke).toHaveBeenCalledWith("begin_switch_practice", { remote: true }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + it("retains the dialog and recovery when releasing capture fails", async () => { + render(); fireEvent.click(screen.getByRole("button", { name: "Test switches" })); + await screen.findByRole("dialog"); + invoke.mockImplementation(async (command: string) => { if (command === "end_switch_practice") throw new Error("Release failed"); return view; }); + fireEvent.click(screen.getByRole("button", { name: "Exit practice" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Release failed"); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + it("ends a late successful start after its component has unmounted", async () => { + let resolve!: (v: typeof view) => void; + invoke.mockImplementation((command: string) => command === "begin_switch_practice" ? new Promise(r => { resolve = r; }) : Promise.resolve()); + const result = render(); fireEvent.click(screen.getByRole("button", { name: "Test switches" })); result.unmount(); + await act(async () => { resolve(view); }); + expect(invoke.mock.calls.filter(c => c[0] === "end_switch_practice")).toHaveLength(2); + }); + it("acknowledges a switch emergency stop and closes without requiring mouse assistance", async () => { + invoke.mockImplementation(async (command: string) => command === "get_switch_practice" ? { ...view, active: false, message: "Practice stopped" } : command === "end_switch_practice" ? undefined : view); + render(); fireEvent.click(screen.getByRole("button", { name: "Test switches" })); + await screen.findByRole("dialog"); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(invoke).toHaveBeenCalledWith("end_switch_practice"); + expect(screen.getByRole("button", { name: "Test switches" })).toHaveFocus(); + }); + it("closes after confirmed end when feedback fails, without requiring mouse assistance", async () => { + invoke.mockImplementation(async (command: string) => { + if (command === "get_switch_practice") throw new Error("Feedback failed"); + return command === "end_switch_practice" ? undefined : view; + }); + render(); fireEvent.click(screen.getByRole("button", { name: "Test switches" })); + await screen.findByRole("dialog"); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(invoke).toHaveBeenCalledWith("end_switch_practice"); + expect(screen.getByRole("button", { name: "Test switches" })).toHaveFocus(); + }); + it("ignores a stale failed poll after exit and reopening", async () => { + let reject!: (reason: Error) => void; + let first = true; + invoke.mockImplementation((command: string) => { + if (command === "get_switch_practice" && first) { first = false; return new Promise((_, r) => { reject = r; }); } + return Promise.resolve(command === "end_switch_practice" ? undefined : view); + }); + render(); fireEvent.click(screen.getByRole("button", { name: "Test switches" })); + await screen.findByRole("dialog"); await waitFor(() => expect(reject).toBeDefined()); + fireEvent.click(screen.getByRole("button", { name: "Exit practice" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: "Test switches" })); await screen.findByRole("dialog"); + await act(async () => { reject(new Error("Old feedback failure")); }); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(invoke.mock.calls.filter(c => c[0] === "end_switch_practice")).toHaveLength(1); + }); + it("exposes readable menu labels outside the scaled scan area", async () => { + invoke.mockResolvedValue({ ...view, label: "Choose an action", tiles: [ { rect: [0,0,100,50], text: "Left click", selected: true }, { rect: [100,0,100,50], text: "Right click", selected: false } ] }); + render(); fireEvent.click(screen.getByRole("button", { name: "Test switches" })); + await screen.findByRole("dialog"); + expect(screen.getByRole("list", { name: "Practice action menu" })).toHaveTextContent("Left clickRight click"); + expect(screen.getByText("Highlighted: Left click")).toBeInTheDocument(); + expect(screen.queryByText("Choose any point to practise")).not.toBeInTheDocument(); + }); + it("does not allow starting with unsaved settings or without a native backend", () => { + const { rerender } = render(); + expect(screen.getByRole("button", { name: "Test switches" })).toBeDisabled(); + delete (window as unknown as Record).__TAURI_INTERNALS__; + rerender(); expect(screen.getByRole("button", { name: "Test switches" })).toBeDisabled(); + }); +}); diff --git a/src/scanning/SwitchPractice.tsx b/src/scanning/SwitchPractice.tsx new file mode 100644 index 00000000..bb12fa6f --- /dev/null +++ b/src/scanning/SwitchPractice.tsx @@ -0,0 +1,85 @@ +import { createPortal } from "react-dom"; +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { actions, type SwitchAction } from "./useSwitches"; + +type View = { active: boolean; source: string; message: string; input: string; action: SwitchAction | null; completed: number; rectangles: number[][]; tiles: { rect: number[]; text: string; selected: boolean }[]; label: string }; +export function SwitchPractice({ disabled = false }: { disabled?: boolean }) { + const [open, setOpen] = useState(false); + const [view, setView] = useState(null); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const [remote, setRemote] = useState(false); + const dialog = useRef(null); + const trigger = useRef(null); + const request = useRef(0); + const restoreFocus = useRef(false); + useEffect(() => { if (!open && !pending && restoreFocus.current) { restoreFocus.current = false; trigger.current?.focus(); } }, [open, pending]); + useEffect(() => () => { request.current++; if ("__TAURI_INTERNALS__" in window) void invoke("end_switch_practice").catch(() => {}); }, []); + useEffect(() => { + if (!open) return; + const background = [...document.body.children].filter((element) => !element.contains(dialog.current)); + const previous = background.map(element => element.hasAttribute("inert")); + background.forEach(element => element.setAttribute("inert", "")); + dialog.current?.focus(); + let alive = true; + let polling = false; + const timer = window.setInterval(() => { + if (polling) return; + polling = true; + void invoke("get_switch_practice").then(async v => { if (alive) { setView(v); if (!v.active) { await close(); } } }).catch(async e => { + if (alive) { + setError(`Practice feedback is unavailable. Ending the test safely. ${String(e)}`); + await close(); + } + }).finally(() => { polling = false; }); + }, 100); + return () => { alive = false; window.clearInterval(timer); background.forEach((element, i) => { if (!previous[i]) element.removeAttribute("inert"); }); }; + }, [open]); + const begin = async () => { + const id = ++request.current; + setError(null); setPending(true); + try { + const next = await invoke("begin_switch_practice", { remote }); + if (id !== request.current) { await invoke("end_switch_practice"); return; } + setView(next); setOpen(true); + } catch (e) { if (id === request.current) setError(String(e)); } + finally { if (id === request.current) setPending(false); } + }; + const close = async () => { + const id = ++request.current; setPending(true); + try { await invoke("end_switch_practice"); if (id === request.current) { restoreFocus.current = true; setOpen(false); setView(null); } } + catch (e) { if (id === request.current) setError(String(e)); } + finally { if (id === request.current) setPending(false); } + }; + return
+

Test your saved switches safely. Select starts a scan; Next and Previous move an active scan, not Tab or Shift+Tab focus. Automatic scanning moves for you; auto-selection can choose a point after a delay. Adjust these in Scanning.

+ + + {!open && error &&

{error}

} + {open && createPortal(
{ + e.stopPropagation(); + if (e.key === "Escape") { e.preventDefault(); void close(); } + if (e.key === "Tab") { + const buttons = [...(dialog.current?.querySelectorAll("button:not(:disabled)") ?? [])]; + if (buttons.length) { e.preventDefault(); const i = buttons.indexOf(document.activeElement as HTMLButtonElement); buttons[(i + (e.shiftKey ? buttons.length - 1 : 1)) % buttons.length].focus(); } + } + }}> +

Safe switch practice

+

Press Select to start, use Next/Previous to move, then Select to choose a point and an action. Nothing here clicks, types, or changes settings. Practice uses your saved line/grid and timing settings.

+

Press Escape, use Stop scanning, or hold any switch through its emergency stop to finish. The practice window closes when testing stops, including on loss of focus, disconnect, or after two minutes. Remote forwarding stops when practice ends.

+

{view?.message ?? "Starting practice…"}

+

{view?.source} · {view?.input ? `Last input: ${view.input}` : "No input received yet"}{view?.action && ` · ${actions[view.action]}`} · Completed: {view?.completed ?? 0}

+ + + {!view?.tiles.length && <>Choose any point to practise} + {view?.rectangles.map(([x,y,width,height],i) => )} + {view?.tiles.map(({rect:[x,y,width,height],text,selected},i) => {text})} + +

{view?.label}

+ {!!view?.tiles.length && <>

Highlighted: {view.tiles.filter(tile => tile.selected).map(tile => tile.text).join(", ") || "No action selected"}

    {view.tiles.map((tile,i) =>
  • {tile.text}
  • )}
} + {error &&

{error}

} +
+
, document.body)} +
; +} diff --git a/src/settings/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx index 03be13e6..1ace23ca 100644 --- a/src/settings/SwitchesSection.tsx +++ b/src/settings/SwitchesSection.tsx @@ -1,3 +1,4 @@ +import { SwitchPractice } from "../scanning/SwitchPractice"; import { createPortal } from "react-dom"; import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { ChevronDown, ChevronUp, Keyboard, Smartphone, Trash2, X } from "lucide-react"; @@ -672,6 +673,7 @@ export function SwitchesSection({ controller, onDraftChange, suspended = false, title="Switches" description="Add keyboard switches or switches forwarded from Switchify Remote, and choose what each one does." > +

{!state ? "Loading local switch configuration..." : settings.bindings.length === 0 ? "No local switches configured. Use Add switch to learn a keyboard key." diff --git a/src/styles.css b/src/styles.css index bf3892b7..4c4d43ec 100644 --- a/src/styles.css +++ b/src/styles.css @@ -446,5 +446,15 @@ button:disabled { cursor: default; opacity: 0.55; } .scan-rate-stepper button { min-width: 44px; min-height: 44px; font-size: 22px; } .scan-rate-stepper output { min-width: 72px; text-align: center; font-variant-numeric: tabular-nums; } +.switch-practice-entry { padding: 12px 0; display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } +.switch-practice-entry p { width: 100%; } +.switch-practice-entry label { display: flex; align-items: center; gap: 8px; } +.switch-practice-dialog { background: var(--surface); color: var(--text); border-radius: 12px; padding: 24px; width: min(900px, 95vw); max-height: 92vh; overflow-y: auto; } +.switch-practice-area { display: block; width: 100%; max-height: 45vh; } +.switch-practice-buttons { display: flex; gap: 12px; justify-content: flex-end; } + +.switch-practice-menu { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; font-size: 15px; } +.switch-practice-menu li { padding: 8px; border: 1px solid var(--border); border-radius: 6px; } +.switch-practice-menu li[data-selected="true"] { background: var(--segment-selected); color: var(--on-brand-container); font-weight: 700; border-color: var(--brand); } .setup-navigation-guidance { flex: 0 0 auto; margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; } .setup-navigation-guidance:not(:empty) { padding-bottom: 16px; }