diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..3014e3b1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Tauri regenerates these on every dev build; pin them to LF so a Windows +# checkout with core.autocrlf does not report them as modified. +src-tauri/permissions/autogenerated/*.toml text eol=lf +src-tauri/gen/** text eol=lf diff --git a/docs/point-scan.md b/docs/point-scan.md index 1d54dead..7a0ea5fe 100644 --- a/docs/point-scan.md +++ b/docs/point-scan.md @@ -2,9 +2,9 @@ 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. +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. -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. +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. @@ -12,15 +12,15 @@ This first port uses switches attached to the computer. Android must be disconne 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. +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. 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. +`scanning.rs` is the pure shared core. `Session` owns start, pause, completion, cancellation and bounded automatic timing. The shared gesture engine turns physical presses and releases into normal and hold actions; the controller rejects stale capture 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`. +`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 embedded switch dispatch, 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. diff --git a/docs/switches.md b/docs/switches.md new file mode 100644 index 00000000..3d0d27d8 --- /dev/null +++ b/docs/switches.md @@ -0,0 +1,25 @@ +# 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. + +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. + +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. + +## 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. + +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. + +Switchify owns local input through `switch_input`, a platform adapter that supplies generation-tagged press/release events. There is no USAHP runtime or dependency. Output still uses Switchify's existing input adapters. The capture state and macOS event tap were adapted from MIT-licensed code; attribution is retained beside the source. + +Windows reserves unmodified assigned keys and Escape with `RegisterHotKey`, uses `MOD_NOREPEAT` for press deduplication, and receives releases through Raw Input on a dedicated message thread. Startup fails and rolls back all registrations if an assigned key cannot be reserved. F12 is unavailable because Windows reserves it for the debugger. During learning, only keys successfully reserved for that capture can be learned; a key reserved by another app will not be learned. Disable, emergency cancellation and heartbeat loss release unused reservations on the input thread. Keys already consumed stay reserved until release, so holding a cancelled switch cannot leak autorepeat. App exit removes all reservations. + +Windows does not provide complete suppression through this adapter: key releases can reach other applications, and combinations with Shift, Ctrl, Alt or Windows are not reserved. Use plain switch keys. Raw Input alone does not start scan actions. macOS retains its event-tap capture and suppression behavior. Physical Windows activation, hold behavior, background operation and cleanup need manual verification on each supported setup. + +The reusable gesture engine handles normal and ordered hold actions. The shared scan controller dispatches them to Session. Native prompts are click-through and nonactivating. + +Automated tests use fake events and never send desktop input. Manual checks should cover physical suppression and learning, focus retention, hold timing, emergency exits, capture loss, permission changes, display changes and shutdown. Use npm run macos:run for macOS permission testing with the established app identity. This version supports keyboard switches and scanning actions. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e5d65fa4..7f707f46 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1608,24 +1608,6 @@ 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" @@ -4005,8 +3987,10 @@ dependencies = [ name = "switchify-pc" version = "1.0.0-rc.6" dependencies = [ + "anyhow", "base64 0.22.1", "block2", + "core-foundation", "core-graphics", "corebluetooth-rs", "directories", @@ -4026,13 +4010,13 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-autostart", - "tauri-plugin-global-shortcut", "tauri-plugin-single-instance", "tauri-plugin-updater", "tiny-skia", "tokio", "uuid", "windows 0.62.2", + "windows-sys 0.61.2", "winreg 0.55.0", ] @@ -4313,21 +4297,6 @@ 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 04e1c227..050fdac9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,6 +14,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] +anyhow = "1" base64 = "0.22" directories = "6.0.0" enigo = "=0.6.1" @@ -26,7 +27,6 @@ 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" @@ -36,14 +36,16 @@ uuid = { version = "1", features = ["v4", "serde"] } [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6.2" corebluetooth-rs = "=0.3.6" +core-foundation = "0.10" core-graphics = { version = "0.25", features = ["highsierra"] } libc = "0.2" objc2 = "0.6.4" -objc2-app-kit = { version = "0.3.2", features = ["NSBitmapImageRep", "NSColor", "NSControl", "NSEvent", "NSGraphics", "NSImage", "NSImageRep", "NSImageView", "NSPanel", "NSResponder", "NSScreen", "NSView", "NSWindow", "NSWorkspace", "objc2-core-foundation"] } +objc2-app-kit = { version = "0.3.2", features = ["NSBitmapImageRep", "NSColor", "NSControl", "NSEvent", "NSGraphics", "NSImage", "NSImageRep", "NSImageView", "NSPanel", "NSTextField", "NSFont", "NSResponder", "NSScreen", "NSView", "NSWindow", "NSWorkspace", "objc2-core-foundation"] } objc2-core-graphics = { version = "0.3.2", default-features = false, features = ["CGEventSource", "CGEventTypes"] } objc2-foundation = { version = "0.3.2", features = ["NSArray", "NSGeometry", "NSHost", "NSNotification", "NSObject", "NSOperation", "NSString", "NSThread", "NSURL", "block2"] } [target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] } windows = { version = "0.62.2", features = [ "Devices_Bluetooth", "Devices_Bluetooth_GenericAttributeProfile", diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 127d27e2..2b02733f 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -5,6 +5,10 @@ fn main() { add_command_line_tools_swift_library_path(); let app_manifest = tauri_build::AppManifest::new().commands(&[ "get_app_state", + "get_switches", + "save_switches", + "begin_switch_capture", + "cancel_switch_capture", "get_point_scan", "configure_point_scan", "check_accessibility", diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index f89c3a2c..049b6b74 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -6,6 +6,10 @@ "permissions": [ "core:default", "allow-get-app-state", + "allow-get-switches", + "allow-save-switches", + "allow-begin-switch-capture", + "allow-cancel-switch-capture", "allow-get-point-scan", "allow-configure-point-scan", "allow-check-accessibility", diff --git a/src-tauri/permissions/autogenerated/begin_switch_capture.toml b/src-tauri/permissions/autogenerated/begin_switch_capture.toml new file mode 100644 index 00000000..8f463850 --- /dev/null +++ b/src-tauri/permissions/autogenerated/begin_switch_capture.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-begin-switch-capture" +description = "Enables the begin_switch_capture command without any pre-configured scope." +commands.allow = ["begin_switch_capture"] + +[[permission]] +identifier = "deny-begin-switch-capture" +description = "Denies the begin_switch_capture command without any pre-configured scope." +commands.deny = ["begin_switch_capture"] diff --git a/src-tauri/permissions/autogenerated/cancel_switch_capture.toml b/src-tauri/permissions/autogenerated/cancel_switch_capture.toml new file mode 100644 index 00000000..438a9181 --- /dev/null +++ b/src-tauri/permissions/autogenerated/cancel_switch_capture.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-cancel-switch-capture" +description = "Enables the cancel_switch_capture command without any pre-configured scope." +commands.allow = ["cancel_switch_capture"] + +[[permission]] +identifier = "deny-cancel-switch-capture" +description = "Denies the cancel_switch_capture command without any pre-configured scope." +commands.deny = ["cancel_switch_capture"] diff --git a/src-tauri/permissions/autogenerated/get_switches.toml b/src-tauri/permissions/autogenerated/get_switches.toml new file mode 100644 index 00000000..6b5cd4f1 --- /dev/null +++ b/src-tauri/permissions/autogenerated/get_switches.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-get-switches" +description = "Enables the get_switches command without any pre-configured scope." +commands.allow = ["get_switches"] + +[[permission]] +identifier = "deny-get-switches" +description = "Denies the get_switches command without any pre-configured scope." +commands.deny = ["get_switches"] diff --git a/src-tauri/permissions/autogenerated/save_switches.toml b/src-tauri/permissions/autogenerated/save_switches.toml new file mode 100644 index 00000000..c4c21a5d --- /dev/null +++ b/src-tauri/permissions/autogenerated/save_switches.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-save-switches" +description = "Enables the save_switches command without any pre-configured scope." +commands.allow = ["save_switches"] + +[[permission]] +identifier = "deny-save-switches" +description = "Denies the save_switches command without any pre-configured scope." +commands.deny = ["save_switches"] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e8e1953b..6148a35a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,10 @@ mod scanning; mod scanning_runtime; mod state; mod storage; +mod switch_gestures; +mod switch_input; +mod switch_runtime; +mod switches; mod telemetry; mod updater; #[cfg(target_os = "windows")] @@ -205,6 +209,7 @@ pub(crate) fn sync_tray_state(app: &AppHandle, state: &AppState) { } fn finish_app_exit(app: &AppHandle) { + app.state::().shutdown(); scanning_runtime::cancel(app); app.state::().cancel(app); let model = app.state::(); @@ -1219,6 +1224,78 @@ fn install_tray(app: &mut tauri::App) -> tauri::Result<()> { Ok(()) } +// tao tracks focus with a flag flipped by WM_SETFOCUS and WM_KILLFOCUS on the +// top-level window, but keyboard focus lives in the WebView2 child, so after the +// first click inside the page `is_focused` stays false while the app is plainly +// in front. Ask the shell which window is in the foreground instead. +fn main_window_focused(window: &tauri::WebviewWindow) -> Result { + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::UI::WindowsAndMessaging::GetForegroundWindow; + let hwnd = window.hwnd().map_err(|e| e.to_string())?; + // SAFETY: GetForegroundWindow takes no arguments and only reads shell state. + let foreground = unsafe { GetForegroundWindow() }; + Ok(foreground as isize == hwnd.0 as isize) + } + #[cfg(not(target_os = "windows"))] + { + window.is_focused().map_err(|e| e.to_string()) + } +} +fn require_main(window: &tauri::WebviewWindow) -> Result<(), String> { + if window.label() != "main" { + return Err("Switch settings are available only in the main window.".into()); + } + Ok(()) +} +#[tauri::command] +fn get_switches( + window: tauri::WebviewWindow, + app: AppHandle, +) -> Result { + require_main(&window)?; + Ok(app.state::().view()) +} +#[tauri::command] +fn save_switches( + window: tauri::WebviewWindow, + app: AppHandle, + settings: switches::Settings, +) -> Result { + require_main(&window)?; + if app.state::().view().enabled { + return Err("Disable scanning before editing switches.".into()); + } + app.state::() + .save(&app, settings) +} +#[tauri::command] +fn begin_switch_capture( + window: tauri::WebviewWindow, + 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)?; + app.state::() + .begin_capture(&app) +} +#[tauri::command] +fn cancel_switch_capture( + window: tauri::WebviewWindow, + app: AppHandle, +) -> Result { + require_main(&window)?; + let controller = app.state::(); + controller.cancel_capture(&app); + Ok(controller.view()) +} + #[tauri::command] fn get_point_scan( controller: State<'_, point_scan_runtime::Controller>, @@ -1286,7 +1363,6 @@ 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; @@ -1307,6 +1383,7 @@ pub fn run() { .manage(PendingProfileExit::default()) .manage(PendingNavigation::default()) .setup(move |app| { + switch_runtime::install(app.handle()); point_scan_runtime::install(app.handle()); install_tray(app)?; if updater_is_configured(app.config().plugins.0.get("updater")) { @@ -1376,6 +1453,12 @@ pub fn run() { Ok(()) }) .on_window_event(|window, event| { + if window.label() == "main" && matches!(event, tauri::WindowEvent::Focused(false)) { + window + .app_handle() + .state::() + .cancel_capture(window.app_handle()); + } #[cfg(target_os = "macos")] if window.label() == "main" && matches!(event, tauri::WindowEvent::Focused(true)) { let model = window.app_handle().state::(); @@ -1391,6 +1474,10 @@ pub fn run() { } }) .invoke_handler(tauri::generate_handler![ + get_switches, + save_switches, + begin_switch_capture, + cancel_switch_capture, get_point_scan, configure_point_scan, get_app_state, diff --git a/src-tauri/src/modifier_overlay.rs b/src-tauri/src/modifier_overlay.rs index 18e409f4..c76545b0 100644 --- a/src-tauri/src/modifier_overlay.rs +++ b/src-tauri/src/modifier_overlay.rs @@ -17,7 +17,7 @@ use crate::macos_overlay_window; use crate::state::{emit_state, set_activity, ActivityKind, SharedModel}; #[cfg(target_os = "windows")] #[path = "modifier_overlay_windows.rs"] -mod windows_backend; +pub(crate) mod windows_backend; const OVERLAY_WINDOW_LABEL: &str = "modifier-overlay"; #[cfg_attr(target_os = "windows", allow(dead_code))] diff --git a/src-tauri/src/modifier_overlay_windows.rs b/src-tauri/src/modifier_overlay_windows.rs index 31d8f9c1..be9f0bbf 100644 --- a/src-tauri/src/modifier_overlay_windows.rs +++ b/src-tauri/src/modifier_overlay_windows.rs @@ -579,6 +579,32 @@ fn copy_rgba_to_bgra(source: &[u8], target: &mut [u8]) -> Result<(), String> { Ok(()) } +pub(crate) fn present_scan_prompt( + window: HWND, + text: &str, + x: i32, + y: i32, + width: i32, + scale: f64, +) -> Result<(), String> { + let padding = (12.0 * scale).round() as i32; + let height = (64.0 * scale).round() as i32; + let layout = Layout { + x, + y, + width, + height, + scale, + chips: vec![RECT { + left: padding, + top: padding, + right: width - padding, + bottom: height - padding, + }], + }; + present(window, &[text.to_string()], &layout) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index f4b1c87f..29ddbf2c 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -1,5 +1,7 @@ //! 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 crate::scanning::{ + Action, Cycle, Frame, Interval, Rect, SwitchSettings, Technique, MAX_SCAN_CYCLES, TICK_MS, +}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] @@ -100,6 +102,8 @@ pub struct Engine { pub units_per_logical_pixel: f64, direction: f64, block_elapsed: Interval, + /// Full automatic passes of the current phase since it began. + cycles: usize, } impl Engine { pub fn new( @@ -123,6 +127,7 @@ impl Engine { cell: Cycle::default(), direction: 1.0, block_elapsed: Interval::default(), + cycles: 0, units_per_logical_pixel, }) } @@ -135,11 +140,13 @@ impl Engine { self.cell.reset(); self.direction = 1.0; self.block_elapsed.reset(); + self.cycles = 0; } fn select_action(&mut self, action: Action) -> Option<(i32, i32)> { match action { Action::Select => { self.block_elapsed.reset(); + self.cycles = 0; match self.phase { Phase::Row => { self.phase = Phase::Cell; @@ -176,7 +183,8 @@ impl Engine { self.direction = if action == Action::Next { 1.0 } else { -1.0 }; self.step(TICK_MS); } - Action::Pause | Action::Cancel => {} + Action::Reverse => self.direction = -self.direction, + Action::Pause | Action::Stop | Action::Cancel => {} } None } @@ -188,34 +196,56 @@ impl Engine { { return; } - self.step(elapsed_ms); + if self.step(elapsed_ms) { + self.cycles += 1; + } } - fn step(&mut self, elapsed_ms: u64) { + /// Moves the current phase and reports whether it wrapped past an edge. + /// Manual Next and Back call this too, but only automatic passes count + /// towards the cycle limit: a user who is stepping is not unattended. + fn step(&mut self, elapsed_ms: u64) -> bool { 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); + let forward = self.direction > 0.0; + // A cyclic index wraps when it leaves its last (or first) slot; comparing + // positions would miss a pass whose step lands exactly where it began. + let last = self.config.grid_size.saturating_sub(1); + let cycle_wraps = |before: usize| if forward { before == last } else { before == 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::Row => { + let before = self.row.index(); + self.row.step(self.config.grid_size, forward); + cycle_wraps(before) + } + Phase::Cell => { + let before = self.cell.index(); + self.cell.step(self.config.grid_size, forward); + cycle_wraps(before) + } Phase::X => { - self.x = advance( + let (x, wrapped) = advance( self.x, self.region.x, self.region.width, self.direction * amount, - ) + ); + self.x = x; + wrapped } Phase::Y => { - self.y = advance( + let (y, wrapped) = advance( self.y, self.region.y, self.region.height, self.direction * amount, - ) + ); + self.y = y; + wrapped } - Phase::Idle => {} + Phase::Idle => false, } } pub fn row_rect(&self) -> Rect { @@ -306,14 +336,17 @@ fn outline(r: Rect, t: f64) -> [Rect; 4] { }, ] } -fn advance(value: f64, start: f64, length: f64, delta: f64) -> f64 { +/// Moves along one axis and reports whether the move wrapped past an edge. The +/// flag comes from the overflow itself, so a step of exactly the region's +/// length still counts as a completed pass even though it lands where it began. +fn advance(value: f64, start: f64, length: f64, delta: f64) -> (f64, bool) { let next = value + delta; if next > start + length - 1.0 { - start + (start, true) } else if next < start { - start + length - 1.0 + (start + length - 1.0, true) } else { - next + (next, false) } } @@ -345,6 +378,9 @@ impl Technique for Engine { fn phase(&self) -> Phase { self.phase } + fn exhausted(&self) -> bool { + self.cycles >= MAX_SCAN_CYCLES + } } #[cfg(test)] @@ -413,6 +449,92 @@ mod tests { e.action(Action::Cancel); assert_eq!(e.technique.phase, Phase::Idle); } + // The 999 px line at speed 2 (120 px/s) wraps roughly every 8.3 s. + fn run(e: &mut Session, ms: u64) { + for _ in 0..ms / 250 { + e.tick(250, false); + } + } + #[test] + fn automatic_scan_stops_after_max_cycles_without_selection() { + let mut e = engine(Config::default()); + e.action(Action::Select); + run(&mut e, 20_000); + assert!(e.active(), "two passes keep scanning"); + run(&mut e, 6_000); + assert!(!e.active()); + assert_eq!(e.technique.phase, Phase::Idle); + assert!(e.frame().strips.is_empty()); + e.action(Action::Select); + assert_eq!(e.technique.phase, Phase::X); + } + #[test] + fn each_selected_phase_starts_a_fresh_cycle_count() { + let mut e = engine(Config::default()); + e.action(Action::Select); + run(&mut e, 20_000); + e.action(Action::Select); + assert_eq!(e.technique.phase, Phase::Y); + // The 701 px column wraps every 5.8 s, so 14 s is two passes; had the + // X passes carried over, this would already have stopped. + run(&mut e, 14_000); + assert!(e.active(), "the Y phase counts its own passes"); + run(&mut e, 4_000); + assert!(!e.active()); + let mut g = engine(Config { + mode: Mode::Grid, + grid_size: 2, + block_interval_ms: 250, + ..Config::default() + }); + g.action(Action::Select); + run(&mut g, 1_250); + assert!(g.active(), "five row steps are two and a half passes"); + run(&mut g, 500); + assert!(!g.active()); + } + #[test] + fn a_step_of_exactly_the_region_length_counts_as_a_pass() { + // At speed 4 a 250 ms tick moves 67.5 units, so a 67.5-wide grid cell + // lands back on its own left edge every tick and must still be counted. + let mut e = Session::new( + Engine::new( + Config { + speed: 4, + ..Config::default() + } + .point(), + Rect { + x: 0.0, + y: 0.0, + width: 67.5, + height: 67.5, + }, + 1.0, + ) + .unwrap(), + true, + ); + e.action(Action::Select); + e.tick(250, false); + e.tick(250, false); + assert!(e.active()); + e.tick(250, false); + assert!(!e.active(), "three exact-length passes exhaust the scan"); + } + #[test] + fn manual_steps_never_exhaust_the_scan() { + let mut e = engine(Config { + automatic: false, + ..Config::default() + }); + e.action(Action::Select); + for _ in 0..2000 { + e.action(Action::Next); + } + assert!(e.active()); + assert_eq!(e.technique.phase, Phase::X); + } #[test] fn manual_mode_only_moves_on_steps() { let mut e = engine(Config { diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 1e908668..70920054 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -5,11 +5,15 @@ use crate::{ scanning::Rect, scanning_runtime::{self, Adapter}, }; -use tauri::AppHandle; +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 { + 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) } pub fn install(app: &AppHandle) { diff --git a/src-tauri/src/scan_host.rs b/src-tauri/src/scan_host.rs index 6f08a2ff..e0c17a91 100644 --- a/src-tauri/src/scan_host.rs +++ b/src-tauri/src/scan_host.rs @@ -76,6 +76,17 @@ mod platform { } Ok(()) } + pub fn prompt(&mut self, text: &str, rect: Rect, scale: f64) -> Result<(), String> { + self.render(&[rect])?; + crate::modifier_overlay::windows_backend::present_scan_prompt( + self.windows[0], + text, + rect.x as i32, + rect.y as i32, + rect.width as i32, + scale, + ) + } pub fn hide(&mut self) { for window in &self.windows { unsafe { @@ -137,6 +148,22 @@ mod platform { } Ok(()) } + pub fn prompt(&mut self, text: &str, rect: Rect, _scale: f64) -> Result<(), String> { + use objc2_app_kit::{NSFont, NSTextField}; + use objc2_foundation::NSString; + let mtm = MainThreadMarker::new().ok_or("Prompt requires the main thread.")?; + self.render(&[rect])?; + let label = NSTextField::labelWithString(&NSString::from_str(text), mtm); + label.setFont(Some(&NSFont::systemFontOfSize(20.0))); + label.setTextColor(Some(&NSColor::whiteColor())); + label.setFrame(NSRect::new( + NSPoint::new(12.0, 12.0), + NSSize::new(rect.width - 24.0, rect.height - 24.0), + )); + self.panels[0].setBackgroundColor(Some(&NSColor::blackColor())); + self.panels[0].setContentView(Some(&label)); + Ok(()) + } pub fn hide(&mut self) { for panel in &self.panels { panel.orderOut(None); @@ -155,6 +182,9 @@ mod platform { pub fn render(&mut self, _: &[Rect]) -> Result<(), String> { Err("Point scan is unavailable.".into()) } + pub fn prompt(&mut self, _: &str, _: Rect, _: f64) -> Result<(), String> { + Err("Scanning is unavailable.".into()) + } pub fn hide(&mut self) {} } } diff --git a/src-tauri/src/scanning.rs b/src-tauri/src/scanning.rs index 45c5200a..b7ced199 100644 --- a/src-tauri/src/scanning.rs +++ b/src-tauri/src/scanning.rs @@ -4,21 +4,30 @@ use serde::{Deserialize, Serialize}; pub const TICK_MS: u64 = 33; pub const MAX_ELAPSED_MS: u64 = 250; -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub enum Action { Select, Next, Back, Pause, + Reverse, + Stop, Cancel, } -pub const ACTIONS: [Action; 5] = [ - Action::Select, - Action::Next, - Action::Back, - Action::Pause, - Action::Cancel, -]; +impl Action { + pub fn label(self) -> &'static str { + match self { + Self::Select => "Select", + Self::Next => "Next", + Self::Back => "Previous", + Self::Pause => "Pause / resume", + Self::Reverse => "Reverse direction", + Self::Stop => "Stop scanning", + Self::Cancel => "Disable scanning", + } + } +} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] @@ -105,6 +114,10 @@ pub struct Frame { pub strips: Vec, } +/// Automatic movement gives up after this many full passes of the current +/// phase without a selection, so an unattended scan does not sweep forever. +pub const MAX_SCAN_CYCLES: usize = 3; + pub trait Technique { type Selection; type Phase: Clone + Default + Serialize; @@ -114,36 +127,10 @@ pub trait Technique { 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]; + /// True once automatic movement has completed `MAX_SCAN_CYCLES` passes + /// without a selection; the session then resets and waits for Select. + fn exhausted(&self) -> bool { + false } } @@ -169,7 +156,7 @@ impl Session { self.paused } pub fn action(&mut self, action: Action) -> Option { - if action == Action::Cancel { + if matches!(action, Action::Cancel | Action::Stop) { self.reset(); return None; } @@ -193,6 +180,9 @@ impl Session { 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)); + if self.technique.exhausted() { + self.reset(); + } } } pub fn reset(&mut self) { @@ -338,21 +328,6 @@ mod tests { 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); diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 8ef26e12..27aa820e 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -1,7 +1,7 @@ //! Shared desktop scan controller. Adapters supply configuration, environment and activation. use crate::{ scan_host::Host, - scanning::{Action, Session, SwitchInput, SwitchSettings, Technique, TICK_MS}, + scanning::{Action, Session, SwitchSettings, Technique, TICK_MS}, }; use serde::{de::DeserializeOwned, Serialize}; pub trait Adapter: Send + Sync + 'static { @@ -27,6 +27,8 @@ pub trait Adapter: Send + Sync + 'static { ) -> Result<(), String>; } +use crate::switch_input::Event; +use crate::{switch_gestures::Gestures, switch_runtime, switches::Settings}; use std::{ cell::RefCell, sync::{ @@ -36,9 +38,8 @@ use std::{ time::Instant, }; use tauri::{AppHandle, Emitter, Manager}; -use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; -thread_local! {static HOST:RefCell>=const{RefCell::new(None)};} +thread_local! {static HOST:RefCell>=const{RefCell::new(None)}; static PROMPT:RefCell>=const{RefCell::new(None)};} pub struct Controller { enabled: AtomicBool, generation: AtomicU64, @@ -48,8 +49,9 @@ struct Data { config: A::Config, engine: Option>, display: Option, - registered: Vec, - pressed: SwitchInput, + pressed: Gestures, + switches: Settings, + input_generation: u64, last_tick: Instant, message: String, } @@ -78,8 +80,9 @@ impl Controller { config, engine: None, display: None, - registered: vec![], - pressed: SwitchInput::default(), + pressed: Gestures::default(), + switches: Settings::default(), + input_generation: 0, last_tick: Instant::now(), message: "Scanning is off.".into(), }), @@ -140,22 +143,20 @@ 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.pressed.cancel(); d.message = message.into(); - std::mem::take(&mut d.registered) - }; - for key in keys { - let _ = app.global_shortcut().unregister(key.as_str()); } + app.state::().stop(); HOST.with(|host| { if let Some(host) = host.borrow_mut().as_mut() { host.hide(); } }); + hide_prompt(); publish::(app); } pub fn configure( @@ -178,38 +179,8 @@ pub fn configure( } 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()); - } + app.state::() + .enable(A::switches(&config).automatic)?; } let save = || -> Result<(), String> { if let Some(parent) = path.parent() { @@ -229,6 +200,9 @@ 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 { @@ -244,27 +218,23 @@ pub fn configure( publish::(app); Ok(c.view()) } -fn switch(app: &AppHandle, generation: u64, index: usize, pressed: bool) { +fn switch(app: &AppHandle, action: Action, input_generation: u64) { 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 { + if !app + .state::() + .active_generation(input_generation) + { + disable::(app, "Switch capture stopped."); return; - }; + } if action == Action::Cancel { disable::(app, "Scanning cancelled. Switch keys released."); return; } + hide_prompt(); 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 { @@ -284,7 +254,11 @@ fn switch(app: &AppHandle, generation: u64, index: usize, pressed: b h.hide(); } }); - if c.enabled.load(Ordering::SeqCst) { + if c.enabled.load(Ordering::SeqCst) + && app + .state::() + .active_generation(input_generation) + { A::activate(app, point)?; } } @@ -313,6 +287,46 @@ fn render(app: &AppHandle) -> Result<(), String> { } fn tick(app: &AppHandle) { let c = app.state::>(); + let (events, now_ms, _) = app.state::().poll(app); + for event in events { + if !c.enabled.load(Ordering::SeqCst) { + break; + } + match event { + Event::Stopped { reason, .. } => { + disable::(app, switch_runtime::stop_message(reason)); + return; + } + Event::Switch { + generation, + switch_id, + action, + monotonic_ms, + } => { + let selected = { + let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); + if generation != d.input_generation + || !app + .state::() + .active_generation(generation) + { + continue; + } + if action == crate::switch_input::Action::Pressed { + let settings = d.switches.clone(); + d.pressed.pressed(&switch_id, monotonic_ms, &settings); + None + } else { + d.pressed.released(&switch_id, monotonic_ms) + } + }; + if let Some(action) = selected { + switch::(app, action, generation); + } + } + _ => {} + } + } if !c.enabled.load(Ordering::SeqCst) { return; } @@ -328,11 +342,13 @@ fn tick(app: &AppHandle) { 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(); + let held = d.pressed.held(); + let prompt = d.pressed.prompt(now_ms); if let Some(engine) = d.engine.as_mut() { engine.tick(elapsed, held); } drop(d); + show_prompt(app, prompt.as_ref())?; render::(app) })(); if let Err(error) = result { @@ -363,3 +379,52 @@ pub fn install(app: &AppHandle) { } }); } + +fn hide_prompt() { + PROMPT.with(|p| { + if let Some(host) = p.borrow_mut().as_mut() { + host.hide(); + } + }); +} +fn show_prompt( + app: &AppHandle, + prompt: Option<&crate::switch_gestures::Prompt>, +) -> Result<(), String> { + let Some(prompt) = prompt else { + hide_prompt(); + return Ok(()); + }; + let (cursor, displays) = crate::display_navigation::displays(app).map_err(|e| e.message)?; + let display = crate::display_navigation::current_display(cursor, &displays) + .ok_or("No display for the switch prompt.")?; + let scale = if cfg!(target_os = "windows") { + display.scale_factor + } else { + 1.0 + }; + let width = (720.0 * scale) + .min(f64::from(display.width) - 32.0 * scale) + .max(1.0); + let rect = crate::scanning::Rect { + x: f64::from(display.x) + (f64::from(display.width) - width) / 2.0, + y: f64::from(display.y) + 20.0 * scale, + width, + height: 64.0 * scale, + }; + PROMPT.with(|p| { + let mut p = p.borrow_mut(); + if p.is_none() { + *p = Some(Host::new()?); + } + p.as_mut().unwrap().prompt( + &format!( + "Release {} for {}", + prompt.switch_name, + prompt.action.label() + ), + rect, + scale, + ) + }) +} diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index fbefa8e9..b99241dd 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -277,6 +277,20 @@ impl AppStorage { } } + /// Atomically replace switch assignments using the same platform semantics as app state. + pub(crate) fn write_switch_settings( + path: &std::path::Path, + settings: &crate::switches::Settings, + ) -> Result<(), String> { + let temp = path.with_extension("json.tmp"); + std::fs::write( + &temp, + serde_json::to_vec_pretty(settings).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + replace_file(&temp, path).map_err(|e| e.to_string()) + } + #[cfg(test)] pub fn at(path: PathBuf) -> Self { let pairing_tokens = platform_pairing_token_store(&path); diff --git a/src-tauri/src/switch_gestures.rs b/src-tauri/src/switch_gestures.rs new file mode 100644 index 00000000..64523ab0 --- /dev/null +++ b/src-tauri/src/switch_gestures.rs @@ -0,0 +1,130 @@ +//! Pure Android-style release/hold selection. Cancellation never becomes an action. +use crate::{ + scanning::Action, + switches::{Binding, Settings}, +}; +use serde::Serialize; +use std::collections::HashSet; +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Prompt { + pub switch_name: String, + pub action: Action, +} +struct Press { + binding: Binding, + started: u64, +} +#[derive(Default)] +pub struct Gestures { + held: HashSet, + press: Option, + interval_ms: u64, +} +impl Gestures { + pub fn pressed(&mut self, id: &str, now: u64, settings: &Settings) { + if !self.held.insert(id.into()) || self.held.len() != 1 { + return; + } + if let Some(binding) = settings.bindings.iter().find(|b| b.id == id) { + self.interval_ms = settings.hold_interval_ms; + self.press = Some(Press { + binding: binding.clone(), + started: now, + }); + } + } + fn candidate(&self, now: u64) -> Option { + let p = self.press.as_ref()?; + let elapsed = now.saturating_sub(p.started); + if elapsed < self.interval_ms || p.binding.hold_actions.is_empty() { + return None; + } + let index = + (elapsed / self.interval_ms - 1).min(p.binding.hold_actions.len() as u64 - 1) as usize; + Some(p.binding.hold_actions[index]) + } + pub fn prompt(&self, now: u64) -> Option { + Some(Prompt { + switch_name: self.press.as_ref()?.binding.name.clone(), + action: self.candidate(now)?, + }) + } + pub fn released(&mut self, id: &str, now: u64) -> Option { + if !self.held.remove(id) || self.press.as_ref().is_none_or(|p| p.binding.id != id) { + return None; + } + let action = self + .candidate(now) + .unwrap_or_else(|| self.press.as_ref().unwrap().binding.press_action); + self.press = None; + Some(action) + } + pub fn held(&self) -> bool { + !self.held.is_empty() + } + pub fn cancel(&mut self) { + self.held.clear(); + self.press = None; + } +} +#[cfg(test)] +mod tests { + use super::*; + fn settings() -> Settings { + Settings { + bindings: vec![ + Binding { + id: "one".into(), + name: "Head switch".into(), + key: "Space".into(), + press_action: Action::Select, + hold_actions: vec![Action::Next, Action::Stop], + }, + Binding { + id: "two".into(), + name: "Other".into(), + key: "Enter".into(), + press_action: Action::Back, + hold_actions: vec![], + }, + ], + ..Settings::default() + } + } + #[test] + fn boundary_and_last_action_match_android() { + for (duration, expected) in [ + (999, Action::Select), + (1000, Action::Next), + (1999, Action::Next), + (2000, Action::Stop), + (9000, Action::Stop), + ] { + let mut g = Gestures::default(); + g.pressed("one", 0, &settings()); + assert_eq!(g.released("one", duration), Some(expected)); + assert_eq!(g.released("one", duration), None); + } + } + #[test] + fn cancellation_discards_hold_and_release() { + let mut g = Gestures::default(); + g.pressed("one", 0, &settings()); + assert_eq!(g.prompt(1000).unwrap().action, Action::Next); + g.cancel(); + assert_eq!(g.released("one", 2000), None); + assert!(g.prompt(2000).is_none()); + } + #[test] + fn first_switch_owns_gesture_and_repeats_do_not_restart_it() { + let mut g = Gestures::default(); + g.pressed("one", 0, &settings()); + g.pressed("one", 700, &settings()); + g.pressed("two", 800, &settings()); + assert_eq!(g.released("one", 1000), Some(Action::Next)); + assert!(g.held()); + assert_eq!(g.released("two", 1200), None); + assert!(!g.held()); + } +} diff --git a/src-tauri/src/switch_input/LICENSE-MIT b/src-tauri/src/switch_input/LICENSE-MIT new file mode 100644 index 00000000..988feaae --- /dev/null +++ b/src-tauri/src/switch_input/LICENSE-MIT @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 Owen McGirr + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/src-tauri/src/switch_input/keys.rs b/src-tauri/src/switch_input/keys.rs new file mode 100644 index 00000000..8ebbc958 --- /dev/null +++ b/src-tauri/src/switch_input/keys.rs @@ -0,0 +1,42 @@ +/// Canonical keyboard names used by embedded capture and persisted mappings. +pub fn normalize(code: &str) -> Option { + let value = match code { + "Return" => "Enter", + "Up" | "UpArrow" => "ArrowUp", + "Down" | "DownArrow" => "ArrowDown", + "Left" | "LeftArrow" => "ArrowLeft", + "Right" | "RightArrow" => "ArrowRight", + other => other, + }; + let named = [ + "Space", + "Enter", + "Backspace", + "Tab", + "Escape", + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "Home", + "End", + "PageUp", + "PageDown", + "Insert", + "Delete", + ]; + if named.contains(&value) + || (value.len() == 1 + && value + .bytes() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())) + || value + .strip_prefix('F') + .and_then(|n| n.parse::().ok()) + .is_some_and(|n| (1..=24).contains(&n) && value == format!("F{n}")) + { + Some(value.into()) + } else { + None + } +} diff --git a/src-tauri/src/switch_input/macos.rs b/src-tauri/src/switch_input/macos.rs new file mode 100644 index 00000000..38fe2ce3 --- /dev/null +++ b/src-tauri/src/switch_input/macos.rs @@ -0,0 +1,177 @@ +use super::Driver; +use anyhow::{bail, Result}; +use core_foundation::runloop::{kCFRunLoopDefaultMode, CFRunLoop}; +use core_graphics::event::{ + CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, CGEventType, + CallbackResult, +}; +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, + }, + time::Duration, +}; +#[link(name = "CoreGraphics", kind = "framework")] +unsafe extern "C" { + fn CGEventSourceKeyState(state: i32, key: u16) -> bool; +} +const CODES: &[(&str, u16)] = &[ + ("Space", 49), + ("Enter", 36), + ("Backspace", 51), + ("Tab", 48), + ("Escape", 53), + ("ArrowUp", 126), + ("ArrowDown", 125), + ("ArrowLeft", 123), + ("ArrowRight", 124), + ("Home", 115), + ("End", 119), + ("PageUp", 116), + ("PageDown", 121), + ("Insert", 114), + ("Delete", 117), + ("A", 0), + ("B", 11), + ("C", 8), + ("D", 2), + ("E", 14), + ("F", 3), + ("G", 5), + ("H", 4), + ("I", 34), + ("J", 38), + ("K", 40), + ("L", 37), + ("M", 46), + ("N", 45), + ("O", 31), + ("P", 35), + ("Q", 12), + ("R", 15), + ("S", 1), + ("T", 17), + ("U", 32), + ("V", 9), + ("W", 13), + ("X", 7), + ("Y", 16), + ("Z", 6), + ("0", 29), + ("1", 18), + ("2", 19), + ("3", 20), + ("4", 21), + ("5", 23), + ("6", 22), + ("7", 26), + ("8", 28), + ("9", 25), + ("F1", 122), + ("F2", 120), + ("F3", 99), + ("F4", 118), + ("F5", 96), + ("F6", 97), + ("F7", 98), + ("F8", 100), + ("F9", 101), + ("F10", 109), + ("F11", 103), + ("F12", 111), + ("F13", 105), + ("F14", 107), + ("F15", 113), + ("F16", 106), + ("F17", 64), + ("F18", 79), + ("F19", 80), + ("F20", 90), +]; +pub fn code_for_name(name: &str) -> Option { + CODES.iter().find_map(|(n, c)| (*n == name).then_some(*c)) +} +pub struct Capture { + stop: Arc, + thread: Option>, +} +impl Capture { + pub fn start(driver: Driver) -> Result { + let stop = Arc::new(AtomicBool::new(false)); + let stopping = stop.clone(); + let (tx, rx) = mpsc::sync_channel(1); + let thread = std::thread::Builder::new() + .name("switchify-keyboard-tap".into()) + .spawn(move || { + let events = driver.clone(); + let installed = CGEventTap::with_enabled( + CGEventTapLocation::Session, + CGEventTapPlacement::HeadInsertEventTap, + CGEventTapOptions::Default, + vec![CGEventType::KeyDown, CGEventType::KeyUp], + move |_, kind, event| { + if !matches!(kind, CGEventType::KeyDown | CGEventType::KeyUp) { + events.lost(); + return CallbackResult::Keep; + } + let code = event.get_integer_value_field(9) as u16; + if let Some((name, _)) = CODES.iter().find(|(_, c)| *c == code) { + if events.key(name, matches!(kind, CGEventType::KeyDown)) { + return CallbackResult::Drop; + } + } + CallbackResult::Keep + }, + || { + let mut down = std::collections::HashSet::new(); + for (name, code) in CODES { + if unsafe { CGEventSourceKeyState(1, *code) } { down.insert((*name).to_string()); } + } + driver.ready(down); + if tx.send(Ok(())).is_err() { + return; + } + while !stopping.load(Ordering::Acquire) { + unsafe { + CFRunLoop::run_in_mode( + kCFRunLoopDefaultMode, + Duration::from_millis(20), + true, + ); + } + driver.tick(); + } + }, + ); + if installed.is_err() { + let _ = tx.send(Err( + "Grant Accessibility permission to the host application before enabling switches." + .to_string(), + )); + } + })?; + match rx.recv_timeout(Duration::from_secs(3)) { + Ok(Ok(())) => Ok(Self { + stop, + thread: Some(thread), + }), + Ok(Err(message)) => { + let _ = thread.join(); + bail!(message); + } + Err(_) => { + stop.store(true, Ordering::Release); + bail!("macOS switch capture did not acknowledge startup."); + } + } + } +} +impl Drop for Capture { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} diff --git a/src-tauri/src/switch_input/mod.rs b/src-tauri/src/switch_input/mod.rs new file mode 100644 index 00000000..1239788a --- /dev/null +++ b/src-tauri/src/switch_input/mod.rs @@ -0,0 +1,565 @@ +//! App-owned capture and generation-tagged switch edges. +//! State and macOS adapter adapted from usahp-core under LICENSE-MIT in this directory. +//! Native callbacks do bounded state work only; consumers drain typed events. +mod keys; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; +use anyhow::{bail, Result}; +pub use keys::normalize as normalize_key; +use std::{ + collections::{HashMap, HashSet, VecDeque}, + sync::{Arc, Mutex}, + time::Instant, +}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + Pressed, + Released, +} +pub struct Mapping { + pub id: String, + pub code: String, +} + +pub const HEARTBEAT_INTERVAL_MS: u64 = 500; +pub const HEARTBEAT_TIMEOUT_MS: u64 = 1500; +const QUEUE_LIMIT: usize = 256; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StopReason { + Disabled, + Escape, + HoldEscape, + HeartbeatTimeout, + QueueOverflow, + CaptureLost, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Event { + Switch { + generation: u64, + switch_id: String, + action: Action, + monotonic_ms: u64, + }, + Learned { + generation: u64, + code: String, + }, + Stopped { + generation: u64, + reason: StopReason, + }, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Off, + Learning, + Active, +} +#[derive(Debug, Clone, Copy)] +pub struct Status { + pub generation: u64, + pub mode: Mode, + pub reason: Option, +} +#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))] +struct Core { + status: Status, + native_lost: bool, + mappings: HashMap, + physical: HashSet, + down: HashSet, + held: HashMap, + learned: Option, + escape_ms: u64, + last_heartbeat: u64, + events: VecDeque, +} +impl Default for Core { + fn default() -> Self { + Self { + status: Status { + generation: 0, + mode: Mode::Off, + reason: None, + }, + native_lost: false, + mappings: HashMap::new(), + physical: HashSet::new(), + down: HashSet::new(), + held: HashMap::new(), + learned: None, + escape_ms: 4000, + last_heartbeat: 0, + events: VecDeque::new(), + } + } +} +#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))] +impl Core { + fn stop(&mut self, reason: StopReason) { + self.status.mode = Mode::Off; + self.status.reason = Some(reason); + self.held.clear(); + self.learned = None; + // Discard pending edges atomically. Reset releases are never physical edges. + self.events.clear(); + self.events.push_back(Event::Stopped { + generation: self.status.generation, + reason, + }); + } + fn begin(&mut self, mode: Mode, now: u64) { + self.stop(StopReason::Disabled); + self.events.clear(); + self.status.generation = self.status.generation.wrapping_add(1); + self.status.mode = mode; + self.status.reason = None; + self.last_heartbeat = now; + } + fn emit(&mut self, event: Event) { + if self.events.len() >= QUEUE_LIMIT { + self.stop(StopReason::QueueOverflow); + } else { + self.events.push_back(event); + } + } + fn tick(&mut self, now: u64) { + if self.status.mode == Mode::Off { + return; + } + if now.saturating_sub(self.last_heartbeat) >= HEARTBEAT_TIMEOUT_MS { + self.stop(StopReason::HeartbeatTimeout); + return; + } + if self.status.mode == Mode::Active + && self + .held + .values() + .any(|start| now.saturating_sub(*start) >= self.escape_ms) + { + self.stop(StopReason::HoldEscape); + } + } + fn key(&mut self, code: &str, pressed: bool, now: u64) -> bool { + self.tick(now); + let was_down = if pressed { + !self.down.insert(code.into()) + } else { + self.down.remove(code) + }; + // Drain releases/repeats of consumed keys, even after cancellation. + if self.status.mode == Mode::Off { + return if pressed { + self.physical.contains(code) + } else { + self.physical.remove(code) + }; + } + if code == "Escape" { + if pressed { + self.physical.insert(code.into()); + self.stop(StopReason::Escape); + } else { + self.physical.remove(code); + } + return true; + } + if self.status.mode == Mode::Learning { + if pressed { + if was_down { + return self.physical.contains(code); + } + self.physical.insert(code.into()); + if self.learned.is_none() { + self.learned = Some(code.into()); + } + } else { + if !self.physical.remove(code) { + return false; + } + if self.learned.as_deref() == Some(code) { + self.status.mode = Mode::Off; + self.learned = None; + self.emit(Event::Learned { + generation: self.status.generation, + code: code.into(), + }); + } + } + return true; + } + let Some(mapping_id) = self.mappings.get(code).cloned() else { + return false; + }; + if pressed { + if was_down { + return self.physical.contains(code); + } + if !self.physical.insert(code.into()) { + return true; + } + } else if !self.physical.remove(code) { + return false; + } + let action = if pressed { + Action::Pressed + } else { + Action::Released + }; + if pressed { + self.held.insert(mapping_id.clone(), now); + } else { + self.held.remove(&mapping_id); + } + self.emit(Event::Switch { + generation: self.status.generation, + switch_id: mapping_id, + action, + monotonic_ms: now, + }); + true + } +} +#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))] +#[derive(Clone)] +struct Driver { + core: Arc>, + started: Instant, +} +#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))] +impl Driver { + fn now(&self) -> u64 { + self.started.elapsed().as_millis().min(u64::MAX as u128) as u64 + } + fn key(&self, code: &str, pressed: bool) -> bool { + self.core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .key(code, pressed, self.now()) + } + fn tick(&self) { + self.core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .tick(self.now()); + } + fn lost(&self) { + let mut core = self.core.lock().unwrap_or_else(|p| p.into_inner()); + core.native_lost = true; + core.stop(StopReason::CaptureLost); + } + fn ready(&self, down: HashSet) { + let mut core = self.core.lock().unwrap_or_else(|p| p.into_inner()); + core.physical.retain(|key| down.contains(key)); + core.down = down; + core.native_lost = false; + } +} +/// One owner per process. Creation does not install hooks or capture input. +/// Call heartbeat at least every 500ms, independently of rendering. +/// Configure/learn only while off. Always stop and drain old gestures before re-enabling. +pub struct Capture { + driver: Driver, + #[cfg(target_os = "windows")] + native: Option, + #[cfg(target_os = "macos")] + native: Option, +} +impl Default for Capture { + fn default() -> Self { + Self::new() + } +} +impl Capture { + pub fn new() -> Self { + Self { + driver: Driver { + core: Arc::new(Mutex::new(Core::default())), + started: Instant::now(), + }, + #[cfg(any(target_os = "windows", target_os = "macos"))] + native: None, + } + } + pub fn status(&self) -> Status { + self.driver + .core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .status + } + pub fn heartbeat(&self) { + let mut core = self.driver.core.lock().unwrap_or_else(|p| p.into_inner()); + core.last_heartbeat = self.driver.now(); + } + pub fn drain(&self) -> Vec { + let mut core = self.driver.core.lock().unwrap_or_else(|p| p.into_inner()); + core.tick(self.driver.now()); + core.events.drain(..).collect() + } + pub fn configure(&mut self, mappings: &[Mapping], escape_ms: u64) -> Result<()> { + if self.status().mode != Mode::Off { + bail!("Disable capture before changing mappings."); + } + if mappings.len() > 128 || escape_ms < 4000 { + bail!("Invalid local mapping count or escape duration."); + } + let mut keys = HashMap::new(); + let mut ids = HashSet::new(); + for m in mappings { + if m.id.trim().is_empty() || !ids.insert(m.id.clone()) { + bail!("Invalid local keyboard mapping."); + } + let Some(code) = normalize_key(&m.code) else { + bail!("Unsupported switch key: {}", m.code); + }; + if code == "Escape" || !supported_key(&code) { + bail!("Switch key is reserved or unavailable on this platform: {code}"); + } + if keys.insert(code, m.id.clone()).is_some() { + bail!("Each physical key can be mapped only once."); + } + } + let mut core = self.driver.core.lock().unwrap_or_else(|p| p.into_inner()); + core.mappings = keys; + core.escape_ms = escape_ms; + Ok(()) + } + #[cfg(not(target_os = "windows"))] + fn ensure_native(&mut self) -> Result<()> { + #[cfg(any(target_os = "windows", target_os = "macos"))] + if self + .driver + .core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .native_lost + { + self.native.take(); + } + #[cfg(target_os = "macos")] + { + if self.native.is_none() { + self.native = Some(macos::Capture::start(self.driver.clone())?); + } + Ok(()) + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + bail!("Local keyboard capture is supported on Windows and macOS."); + } + } + fn begin(&mut self, mode: Mode) -> Result { + if self.status().mode != Mode::Off { + bail!("Capture is already active."); + } + #[cfg(target_os = "windows")] + { + if !self + .driver + .core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .physical + .is_empty() + { + bail!("Release the held switch before starting capture."); + } + self.native.take(); + self.native = Some(windows::Capture::start(self.driver.clone(), mode)?); + Ok(self.status().generation) + } + #[cfg(not(target_os = "windows"))] + { + self.ensure_native()?; + let mut core = self.driver.core.lock().unwrap_or_else(|p| p.into_inner()); + if core.native_lost { + bail!("Native switch capture was lost during startup."); + } + if !core.physical.is_empty() || !core.down.is_empty() { + bail!("Release the held switch before starting capture."); + } + core.begin(mode, self.driver.now()); + Ok(core.status.generation) + } + } + pub fn enable(&mut self) -> Result { + self.begin(Mode::Active) + } + pub fn learn(&mut self) -> Result { + self.begin(Mode::Learning) + } + pub fn monotonic_ms(&self) -> u64 { + self.driver.now() + } + pub fn shutdown(&mut self) { + self.stop(); + #[cfg(any(target_os = "windows", target_os = "macos"))] + { + self.native.take(); + } + let mut core = self.driver.core.lock().unwrap_or_else(|p| p.into_inner()); + core.physical.clear(); + core.down.clear(); + } + pub fn stop(&mut self) { + self.driver + .core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .stop(StopReason::Disabled); + } +} +impl Drop for Capture { + fn drop(&mut self) { + self.stop(); + #[cfg(any(target_os = "windows", target_os = "macos"))] + { + self.native.take(); + } + } +} +pub fn supported_key(code: &str) -> bool { + #[cfg(target_os = "windows")] + { + code != "F12" && windows::code_for_name(code).is_some() + } + #[cfg(target_os = "macos")] + { + macos::code_for_name(code).is_some() + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let _ = code; + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn core() -> Core { + let mut c = Core::default(); + let m = Mapping { + id: "space".into(), + code: "Space".into(), + }; + c.mappings.insert("Space".into(), "space".into()); + c.mappings.insert(m.code, m.id); + c.begin(Mode::Active, 0); + c + } + #[test] + fn repeat_and_unmatched_release_never_activate() { + let mut c = core(); + assert!(!c.key("Space", false, 0)); + assert!(c.events.is_empty()); + c.key("Space", true, 1); + c.key("Space", true, 2); + c.key("Space", false, 3); + assert_eq!(c.events.len(), 2); + assert!(!c.key("A", true, 4)); + } + #[test] + fn cancellation_discards_edges_without_synthetic_releases() { + let mut c = core(); + c.key("Space", true, 1); + c.stop(StopReason::Disabled); + assert!(matches!(c.events.front(), Some(Event::Stopped { .. }))); + assert_eq!(c.events.len(), 1); + assert!(c.key("Space", false, 2)); + assert_eq!(c.events.len(), 1); + } + #[test] + fn heartbeat_and_long_hold_fail_open() { + let mut c = core(); + c.key("Space", true, 0); + c.tick(1500); + assert_eq!(c.status.reason, Some(StopReason::HeartbeatTimeout)); + let mut c = core(); + c.escape_ms = 6000; + c.key("Space", true, 0); + for n in 1..=12 { + c.last_heartbeat = n * 500; + c.tick(n * 500); + } + assert_eq!(c.status.reason, Some(StopReason::HoldEscape)); + assert!(!c.key("A", true, 6001)); + } + #[test] + fn learns_only_complete_press_and_escape_cancels() { + let mut c = Core::default(); + c.begin(Mode::Learning, 0); + c.key("Space", false, 1); + assert!(c.events.is_empty()); + c.key("Space", true, 2); + c.key("Space", true, 3); + c.key("Space", false, 4); + assert!(matches!(c.events.front(),Some(Event::Learned{code,..}) if code=="Space")); + c.begin(Mode::Learning, 5); + c.key("Escape", true, 6); + assert_eq!(c.status.reason, Some(StopReason::Escape)); + } + #[test] + fn overflow_cancels_instead_of_delivering_partial_gesture() { + let mut c = core(); + for n in 0..300 { + c.key("Space", n % 2 == 0, n); + } + assert_eq!(c.status.reason, Some(StopReason::QueueOverflow)); + assert_eq!(c.events.len(), 1); + assert!(matches!(c.events[0], Event::Stopped { .. })); + } + #[test] + fn learning_drains_overlapping_keys() { + let mut c = Core::default(); + c.begin(Mode::Learning, 0); + for (code, down) in [("A", true), ("B", true), ("B", false), ("A", false)] { + assert!(c.key(code, down, 1)); + } + assert!(c.physical.is_empty()); + assert!(c.down.is_empty()); + assert!(matches!(c.events.front(),Some(Event::Learned{code,..}) if code=="A")); + } + #[test] + fn prior_passed_press_retains_passed_release() { + let mut c = core(); + c.stop(StopReason::Disabled); + assert!(!c.key("Space", true, 1)); + c.begin(Mode::Active, 2); + assert!(!c.key("Space", true, 3)); + assert!(!c.key("Space", false, 4)); + assert!(c.events.is_empty()); + } + #[test] + fn cleanup_does_not_erase_native_failure() { + let driver = Driver { + core: Arc::new(Mutex::new(Core::default())), + started: Instant::now(), + }; + driver.core.lock().unwrap().begin(Mode::Learning, 0); + driver.key("Space", true); + driver.lost(); + driver.core.lock().unwrap().stop(StopReason::Disabled); + assert!(driver.core.lock().unwrap().native_lost); + driver.ready(HashSet::new()); + assert!(!driver.core.lock().unwrap().native_lost); + assert!(driver.core.lock().unwrap().down.is_empty()); + assert!(driver.core.lock().unwrap().physical.is_empty()); + } + #[test] + fn generations_change_and_names_are_stable() { + let mut c = core(); + let old = c.status.generation; + c.stop(StopReason::Disabled); + c.begin(Mode::Active, 1); + assert_ne!(c.status.generation, old); + assert_eq!(normalize_key("Return").as_deref(), Some("Enter")); + assert!(normalize_key("F24").is_some()); + assert!(normalize_key("F25").is_none()); + } +} diff --git a/src-tauri/src/switch_input/windows.rs b/src-tauri/src/switch_input/windows.rs new file mode 100644 index 00000000..3047d5ba --- /dev/null +++ b/src-tauri/src/switch_input/windows.rs @@ -0,0 +1,478 @@ +//! Windows hotkey presses and Raw Input releases. No low-level hook dependency. +use super::{Driver, Mode}; +use anyhow::{bail, Result}; +use std::{ + cell::RefCell, + collections::HashMap, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, + }, + time::Duration, +}; +use windows_sys::Win32::{ + Foundation::*, + System::{LibraryLoader::GetModuleHandleW, Threading::GetCurrentThreadId}, + UI::{ + Input::{KeyboardAndMouse::*, *}, + WindowsAndMessaging::*, + }, +}; + +pub fn code_for_name(name: &str) -> Option { + Some(match name { + "Space" => 32, + "Enter" => 13, + "Backspace" => 8, + "Tab" => 9, + "Escape" => 27, + "ArrowUp" => 38, + "ArrowDown" => 40, + "ArrowLeft" => 37, + "ArrowRight" => 39, + "Home" => 36, + "End" => 35, + "PageUp" => 33, + "PageDown" => 34, + "Insert" => 45, + "Delete" => 46, + n if n.len() == 1 + && n.bytes() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) => + { + n.as_bytes()[0] as u32 + } + n => { + let f = n.strip_prefix('F')?.parse::().ok()?; + if !(1..=24).contains(&f) { + return None; + } + 0x70 + f - 1 + } + }) +} +fn known_keys() -> Vec<(u32, String)> { + let mut names: Vec = [ + "Space", + "Enter", + "Backspace", + "Tab", + "Escape", + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "Home", + "End", + "PageUp", + "PageDown", + "Insert", + "Delete", + ] + .into_iter() + .map(String::from) + .collect(); + names.extend(('A'..='Z').chain('0'..='9').map(|c| c.to_string())); + names.extend((1..=24).filter(|f| *f != 12).map(|f| format!("F{f}"))); + names + .into_iter() + .map(|name| (code_for_name(&name).unwrap(), name)) + .collect() +} +struct Input { + driver: Driver, + keys: HashMap, +} +impl Input { + fn hotkey(&self, id: i32) { + if let Some(name) = self.keys.get(&id) { + self.driver.key(name, true); + } + } + fn raw(&self, vk: i32, released: bool) { + if released { + if let Some(name) = self.keys.get(&vk) { + self.driver.key(name, false); + } + } + } +} +thread_local! { static INPUT: RefCell> = const { RefCell::new(None) }; } +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(Some(0)).collect() +} +unsafe extern "system" fn window(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT { + if msg == WM_INPUT_DEVICE_CHANGE && wp == GIDC_REMOVAL as usize { + INPUT.with(|slot| { + if let Some(input) = slot.borrow().as_ref() { + input.driver.lost(); + // The removed device may not own a held switch. Off-mode cleanup + // reconciles actual key state before releasing its reservation. + } + }); + } + if msg == WM_HOTKEY { + INPUT.with(|slot| { + if let Some(input) = slot.borrow().as_ref() { + input.hotkey(wp as i32); + } + }); + return 0; + } + if msg == WM_INPUT { + let mut raw: RAWINPUT = unsafe { std::mem::zeroed() }; + let mut size = std::mem::size_of::() as u32; + let count = unsafe { + GetRawInputData( + lp as HRAWINPUT, + RID_INPUT, + &mut raw as *mut _ as *mut _, + &mut size, + std::mem::size_of::() as u32, + ) + }; + if count == u32::MAX { + INPUT.with(|slot| { + if let Some(input) = slot.borrow().as_ref() { + input.driver.lost(); + } + }); + } else if raw.header.dwType == RIM_TYPEKEYBOARD { + let key = unsafe { raw.data.keyboard }; + // Hotkeys own presses. Raw make events can be repeats or modified keys + // that we did not reserve, so they must never start a gesture. + if key.Flags & RI_KEY_BREAK as u16 != 0 { + INPUT.with(|slot| { + if let Some(input) = slot.borrow().as_ref() { + input.raw(key.VKey as i32, true); + } + }); + } + } + } + unsafe { DefWindowProcW(hwnd, msg, wp, lp) } +} + +/// Owns every registration on one dedicated message-loop thread. +pub struct Capture { + stop: Arc, + thread_id: u32, + thread: Option>, +} +impl Capture { + pub fn start(driver: Driver, mode: Mode) -> Result { + let stop = Arc::new(AtomicBool::new(false)); + let stopping = stop.clone(); + let (tx, rx) = mpsc::sync_channel(1); + let thread = std::thread::Builder::new() + .name("switchify-keyboard-input".into()) + .spawn(move || unsafe { run(driver, mode, stopping, tx) })?; + match rx.recv_timeout(Duration::from_secs(3)) { + Ok(Ok(thread_id)) => Ok(Self { + stop, + thread_id, + thread: Some(thread), + }), + Ok(Err(error)) => { + let _ = thread.join(); + bail!(error) + } + Err(_) => { + stop.store(true, Ordering::Release); + bail!("Keyboard capture did not acknowledge startup.") + } + } + } +} +impl Drop for Capture { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + unsafe { + PostThreadMessageW(self.thread_id, WM_QUIT, 0, 0); + } + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +unsafe fn run( + driver: Driver, + mode: Mode, + stopping: Arc, + tx: mpsc::SyncSender>, +) { + let module = GetModuleHandleW(std::ptr::null()); + let class = wide("SwitchifyLocalKeyboardInput"); + let wc = WNDCLASSW { + lpfnWndProc: Some(window), + hInstance: module, + lpszClassName: class.as_ptr(), + ..std::mem::zeroed() + }; + if RegisterClassW(&wc) == 0 && GetLastError() != ERROR_CLASS_ALREADY_EXISTS { + let _ = tx.send(Err( + "Could not register the keyboard input window.".to_string() + )); + return; + } + let hwnd = CreateWindowExW( + 0, + class.as_ptr(), + class.as_ptr(), + 0, + 0, + 0, + 0, + 0, + HWND_MESSAGE, + std::ptr::null_mut(), + module, + std::ptr::null(), + ); + if hwnd.is_null() { + let _ = tx.send(Err( + "Could not create the keyboard input window.".to_string() + )); + return; + } + let raw = RAWINPUTDEVICE { + usUsagePage: 1, + usUsage: 6, + dwFlags: RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, + hwndTarget: hwnd, + }; + let mut registered = HashMap::new(); + let setup = (|| -> Result<()> { + if RegisterRawInputDevices(&raw, 1, std::mem::size_of::() as u32) == 0 { + bail!("Could not receive keyboard releases."); + } + let keys = if mode == Mode::Learning { + known_keys() + } else { + let c = driver.core.lock().unwrap_or_else(|p| p.into_inner()); + c.mappings + .keys() + .chain(std::iter::once(&"Escape".to_string())) + .map(|n| (code_for_name(n).unwrap(), n.clone())) + .collect() + }; + for (vk, name) in keys { + if RegisterHotKey(hwnd, vk as i32, MOD_NOREPEAT, vk) != 0 { + registered.insert(vk as i32, name); + } else if mode == Mode::Active || name == "Escape" { + bail!("The {name} key could not be reserved. It may already be used by another application."); + } + } + let down = known_keys() + .into_iter() + .filter(|(vk, _)| GetAsyncKeyState(*vk as i32) < 0) + .map(|(_, name)| name) + .collect(); + driver.ready(down); + let mut core = driver.core.lock().unwrap_or_else(|p| p.into_inner()); + if !core.down.is_empty() { + bail!("Release held keys before starting capture."); + } + core.begin(mode, driver.now()); + Ok(()) + })(); + let timer = if setup.is_ok() { + SetTimer(hwnd, 1, 20, None) + } else { + 0 + }; + let setup = setup.and_then(|()| { + if timer == 0 { + bail!("Could not start the keyboard watchdog."); + } + Ok(()) + }); + if let Err(e) = setup { + for id in registered.keys() { + UnregisterHotKey(hwnd, *id); + } + let remove = RAWINPUTDEVICE { + dwFlags: RIDEV_REMOVE, + hwndTarget: std::ptr::null_mut(), + ..raw + }; + RegisterRawInputDevices(&remove, 1, std::mem::size_of::() as u32); + DestroyWindow(hwnd); + driver.lost(); + let _ = tx.send(Err(e.to_string())); + return; + } + INPUT.with(|slot| { + *slot.borrow_mut() = Some(Input { + driver: driver.clone(), + keys: registered.clone(), + }) + }); + let mut msg = std::mem::zeroed(); + if tx.send(Ok(GetCurrentThreadId())).is_ok() { + while !stopping.load(Ordering::Acquire) { + if GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) <= 0 { + break; + } + TranslateMessage(&msg); + DispatchMessageW(&msg); + driver.tick(); + let mut core = driver.core.lock().unwrap_or_else(|p| p.into_inner()); + if core.status.mode == Mode::Off { + // Reconcile only after cancellation; a lost raw break must not + // leave a reservation stuck, and must never execute an action. + core.physical.retain(|name| { + code_for_name(name).is_some_and(|vk| GetAsyncKeyState(vk as i32) < 0) + }); + // Drain keys already consumed when cancelled, without releasing + // their autorepeat into the foreground application. + registered.retain(|id, name| { + if core.physical.contains(name) { + true + } else { + UnregisterHotKey(hwnd, *id); + false + } + }); + INPUT.with(|slot| { + if let Some(input) = slot.borrow_mut().as_mut() { + input.keys = registered.clone(); + } + }); + if registered.is_empty() { + break; + } + } + } + } + for id in registered.keys() { + UnregisterHotKey(hwnd, *id); + } + KillTimer(hwnd, timer); + let remove = RAWINPUTDEVICE { + dwFlags: RIDEV_REMOVE, + hwndTarget: std::ptr::null_mut(), + ..raw + }; + RegisterRawInputDevices(&remove, 1, std::mem::size_of::() as u32); + DestroyWindow(hwnd); + INPUT.with(|slot| *slot.borrow_mut() = None); + let active = driver + .core + .lock() + .unwrap_or_else(|p| p.into_inner()) + .status + .mode + != Mode::Off; + if active { + driver.lost(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::switch_input::{Action, Core, Event, StopReason}; + use std::{sync::Mutex, time::Instant}; + fn input() -> Input { + let mut core = Core::default(); + core.mappings.insert("Space".into(), "select".into()); + core.begin(Mode::Active, 0); + Input { + driver: Driver { + core: Arc::new(Mutex::new(core)), + started: Instant::now(), + }, + keys: HashMap::from([(32, "Space".into()), (27, "Escape".into())]), + } + } + #[test] + fn raw_make_and_unmatched_break_cannot_start_gestures() { + let i = input(); + i.raw(32, false); + i.raw(32, true); + assert!(i.driver.core.lock().unwrap().events.is_empty()); + i.hotkey(32); + i.hotkey(32); + i.raw(32, false); + i.raw(32, true); + i.raw(32, true); + let c = i.driver.core.lock().unwrap(); + assert_eq!(c.events.len(), 2); + assert!(matches!( + c.events[0], + Event::Switch { + action: Action::Pressed, + .. + } + )); + assert!(matches!( + c.events[1], + Event::Switch { + action: Action::Released, + .. + } + )); + } + #[test] + fn escape_cancels_pending_action_and_drains_held_switch() { + let i = input(); + i.hotkey(32); + i.hotkey(27); + assert_eq!( + i.driver.core.lock().unwrap().status.reason, + Some(StopReason::Escape) + ); + i.raw(32, true); + i.raw(27, true); + let c = i.driver.core.lock().unwrap(); + assert!(c.physical.is_empty()); + assert_eq!(c.events.len(), 1); + assert!(matches!(c.events[0], Event::Stopped { .. })); + } + #[test] + fn missing_raw_release_cancels_without_inventing_an_action() { + let i = input(); + i.hotkey(32); + let mut c = i.driver.core.lock().unwrap(); + c.last_heartbeat = 4000; + c.tick(4000); + assert_eq!(c.status.reason, Some(StopReason::HoldEscape)); + assert_eq!(c.events.len(), 1); + } + #[test] + fn learning_uses_complete_hotkey_and_raw_release_pair() { + let i = input(); + i.driver.core.lock().unwrap().begin(Mode::Learning, 0); + i.raw(32, true); + i.hotkey(32); + i.raw(32, true); + let c = i.driver.core.lock().unwrap(); + assert_eq!(c.events.len(), 1); + assert!(matches!(&c.events[0],Event::Learned {code,..} if code=="Space")); + } + #[test] + fn f12_is_not_reservable() { + assert!(!super::super::supported_key("F12")); + assert!(!known_keys().iter().any(|(_, name)| name == "F12")); + } + #[test] + fn device_loss_preserves_other_held_keys_until_their_release() { + let i = input(); + i.hotkey(32); + i.driver.lost(); + assert!(i.driver.core.lock().unwrap().physical.contains("Space")); + i.raw(32, true); + let c = i.driver.core.lock().unwrap(); + assert!(c.physical.is_empty()); + assert_eq!(c.events.len(), 1); + assert!(matches!( + c.events[0], + Event::Stopped { + reason: StopReason::CaptureLost, + .. + } + )); + } +} diff --git a/src-tauri/src/switch_runtime.rs b/src-tauri/src/switch_runtime.rs new file mode 100644 index 00000000..5d8d3fdf --- /dev/null +++ b/src-tauri/src/switch_runtime.rs @@ -0,0 +1,356 @@ +//! App-owned switch settings and local keyboard capture. +use crate::switch_input::{Capture, Event, Mode, StopReason}; +use crate::switches::Settings; +use serde::Serialize; +use std::sync::Mutex; +use tauri::{AppHandle, Emitter, Manager}; +#[derive(Debug, Default, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CaptureState { + pub active: bool, + pub key: Option, + pub error: Option, +} +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct View { + pub settings: Settings, + pub capture: CaptureState, + pub supported: bool, + pub error: Option, + pub escape_hold_ms: u64, + pub unavailable_keys: Vec, +} +struct Data { + settings: Settings, + capture: CaptureState, + capture_generation: u64, + error: Option, +} +pub struct Controller { + data: Mutex, + broker: Mutex, +} +fn path(app: &AppHandle) -> Result { + app.path() + .app_config_dir() + .map(|p| p.join("switch-settings.json")) + .map_err(|e| e.to_string()) +} +fn persist(app: &AppHandle, settings: &Settings) -> Result<(), String> { + let path = path(app)?; + persist_path(&path, settings) +} +fn persist_path(path: &std::path::Path, settings: &Settings) -> Result<(), String> { + let parent = path + .parent() + .ok_or("Switch settings directory is unavailable.")?; + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + // Existing storage helper uses atomic replacement for desktop state. + crate::storage::AppStorage::write_switch_settings(path, settings) +} +fn load_settings(path: &std::path::Path) -> Result { + match std::fs::read(path) { + Ok(bytes) => { + let s: Settings = serde_json::from_slice(&bytes) + .map_err(|e| format!("Switch settings could not be read: {e}"))?; + s.validate()?; + Ok(s) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let legacy = path.with_file_name("point-scan.json"); + match std::fs::read(legacy) { + Ok(bytes) => { + let config: crate::point_scan::Config = + serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + config.validate()?; + let settings = Settings::migrate(&config); + persist_path(path, &settings)?; + Ok(settings) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let settings = Settings::default(); + persist_path(path, &settings)?; + Ok(settings) + } + Err(e) => Err(e.to_string()), + } + } + Err(e) => Err(e.to_string()), + } +} +impl Controller { + fn new(app: &AppHandle) -> Self { + let load = || load_settings(&path(app)?); + let (settings, error) = match load() { + Ok(s) => (s, None), + Err(e) => (Settings::default(), Some(e)), + }; + Self { + data: Mutex::new(Data { + settings, + capture: CaptureState::default(), + capture_generation: 0, + error, + }), + broker: Mutex::new(Capture::new()), + } + } + pub fn view(&self) -> View { + let d = self.data.lock().unwrap_or_else(|p| p.into_inner()); + View { + settings: d.settings.clone(), + capture: d.capture.clone(), + supported: cfg!(any(target_os = "windows", target_os = "macos")), + error: d.error.clone(), + escape_hold_ms: d.settings.escape_ms(), + unavailable_keys: d + .settings + .bindings + .iter() + .filter(|b| !crate::switch_input::supported_key(&b.key)) + .map(|b| b.key.clone()) + .collect(), + } + } + pub fn active_generation(&self, generation: u64) -> bool { + let status = self + .broker + .lock() + .unwrap_or_else(|p| p.into_inner()) + .status(); + status.mode == Mode::Active && status.generation == generation + } + pub fn generation(&self) -> u64 { + self.broker + .lock() + .unwrap_or_else(|p| p.into_inner()) + .status() + .generation + } + pub fn settings(&self) -> Settings { + self.view().settings + } + pub fn save(&self, app: &AppHandle, settings: Settings) -> Result { + settings.validate()?; + if self + .broker + .lock() + .unwrap_or_else(|p| p.into_inner()) + .status() + .mode + != Mode::Off + { + return Err("Disable scanning and finish capture before changing switches.".into()); + } + { + let d = self.data.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(error) = &d.error { + return Err(error.clone()); + } + } + persist(app, &settings)?; + self.data.lock().unwrap_or_else(|p| p.into_inner()).settings = settings; + self.publish(app); + Ok(self.view()) + } + pub fn enable(&self, automatic: bool) -> Result<(), String> { + let view = self.view(); + if let Some(e) = view.error { + return Err(e); + } + view.settings.validate_actions(automatic)?; + let mappings = view + .settings + .bindings + .iter() + .map(|b| crate::switch_input::Mapping { + id: b.id.clone(), + code: b.key.clone(), + }) + .collect::>(); + let mut broker = self.broker.lock().unwrap_or_else(|p| p.into_inner()); + broker + .configure(&mappings, view.settings.escape_ms()) + .map_err(|e| e.to_string())?; + broker.enable().map_err(|e| e.to_string())?; + Ok(()) + } + pub fn begin_capture(&self, app: &AppHandle) -> Result { + if let Some(e) = self.view().error { + return Err(e); + } + let generation = self + .broker + .lock() + .unwrap_or_else(|p| p.into_inner()) + .learn() + .map_err(|e| e.to_string())?; + let mut d = self.data.lock().unwrap_or_else(|p| p.into_inner()); + d.capture = CaptureState { + active: true, + ..CaptureState::default() + }; + d.capture_generation = generation; + drop(d); + self.publish(app); + Ok(self.view()) + } + pub fn cancel_capture(&self, app: &AppHandle) { + let mut d = self.data.lock().unwrap_or_else(|p| p.into_inner()); + if !d.capture.active { + return; + } + d.capture = CaptureState::default(); + drop(d); + self.broker.lock().unwrap_or_else(|p| p.into_inner()).stop(); + self.publish(app); + } + pub fn stop(&self) { + self.broker.lock().unwrap_or_else(|p| p.into_inner()).stop(); + } + pub fn shutdown(&self) { + self.broker + .lock() + .unwrap_or_else(|p| p.into_inner()) + .shutdown(); + } + pub fn poll(&self, app: &AppHandle) -> (Vec, u64, u64) { + let broker = self.broker.lock().unwrap_or_else(|p| p.into_inner()); + let events = broker.drain(); + let now = broker.monotonic_ms(); + let generation = broker.status().generation; + drop(broker); + let mut d = self.data.lock().unwrap_or_else(|p| p.into_inner()); + let mut changed = false; + for event in &events { + match event { + Event::Learned { generation, code } + if d.capture.active && *generation == d.capture_generation => + { + d.capture = CaptureState { + active: false, + key: Some(code.clone()), + error: None, + }; + changed = true; + } + Event::Stopped { generation, reason } + if d.capture.active && *generation == d.capture_generation => + { + d.capture = CaptureState { + active: false, + key: None, + error: Some(stop_message(*reason).into()), + }; + changed = true; + } + _ => {} + } + } + drop(d); + if changed { + self.publish(app); + } + (events, now, generation) + } + fn publish(&self, app: &AppHandle) { + let _ = app.emit("switches-changed", self.view()); + } +} +pub fn stop_message(reason: StopReason) -> &'static str { + match reason { + StopReason::Disabled => "Switch capture stopped.", + StopReason::Escape => "Escape pressed. Switch capture stopped.", + StopReason::HoldEscape => "Emergency hold released switch control.", + 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." + } + } +} +pub fn install(app: &AppHandle) { + app.manage(Controller::new(app)); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis( + crate::switch_input::HEARTBEAT_INTERVAL_MS, + )) + .await; + app.state::() + .broker + .lock() + .unwrap_or_else(|p| p.into_inner()) + .heartbeat(); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + struct Files(std::path::PathBuf); + impl Files { + fn new() -> Self { + let p = + std::env::temp_dir().join(format!("switchify-migration-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&p).unwrap(); + Self(p) + } + fn path(&self) -> std::path::PathBuf { + self.0.join("switch-settings.json") + } + } + impl Drop for Files { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + #[test] + fn fresh_install_stays_empty_after_point_settings_are_saved() { + let f = Files::new(); + assert!(load_settings(&f.path()).unwrap().bindings.is_empty()); + std::fs::write( + f.0.join("point-scan.json"), + serde_json::to_vec(&crate::point_scan::Config::default()).unwrap(), + ) + .unwrap(); + assert!(load_settings(&f.path()).unwrap().bindings.is_empty()); + } + #[test] + fn legacy_migration_happens_once_and_preserves_new_assignments() { + let f = Files::new(); + let config = crate::point_scan::Config { + select_key: "F4".into(), + ..Default::default() + }; + std::fs::write( + f.0.join("point-scan.json"), + serde_json::to_vec(&config).unwrap(), + ) + .unwrap(); + let mut settings = load_settings(&f.path()).unwrap(); + assert_eq!(settings.bindings[0].key, "F4"); + settings.bindings.clear(); + persist_path(&f.path(), &settings).unwrap(); + assert!(load_settings(&f.path()).unwrap().bindings.is_empty()); + } + #[test] + fn corrupt_and_future_files_are_never_overwritten() { + let f = Files::new(); + for bytes in [ + b"not json".to_vec(), + serde_json::to_vec(&Settings { + schema_version: 2, + ..Default::default() + }) + .unwrap(), + ] { + std::fs::write(f.path(), &bytes).unwrap(); + assert!(load_settings(&f.path()).is_err()); + assert_eq!(std::fs::read(f.path()).unwrap(), bytes); + } + } +} diff --git a/src-tauri/src/switches.rs b/src-tauri/src/switches.rs new file mode 100644 index 00000000..7c9d00f6 --- /dev/null +++ b/src-tauri/src/switches.rs @@ -0,0 +1,153 @@ +//! Persisted switch assignments, independent of capture and scanning techniques. +use crate::scanning::Action; +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Binding { + pub id: String, + pub name: String, + pub key: String, + pub press_action: Action, + pub hold_actions: Vec, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Settings { + pub schema_version: u32, + pub hold_interval_ms: u64, + pub bindings: Vec, +} +impl Default for Settings { + fn default() -> Self { + Self { + schema_version: 1, + hold_interval_ms: 1000, + bindings: vec![], + } + } +} +impl Settings { + pub fn validate(&self) -> Result<(), String> { + if self.schema_version != 1 + || !(250..=5000).contains(&self.hold_interval_ms) + || self.bindings.len() > 128 + { + return Err("Unsupported switch settings or hold interval.".into()); + } + let mut ids = std::collections::HashSet::new(); + let mut keys = std::collections::HashSet::new(); + for b in &self.bindings { + if b.id.is_empty() + || b.id.len() > 128 + || !ids.insert(&b.id) + || b.name.trim().is_empty() + || b.name.chars().count() > 64 + || b.hold_actions.len() > 32 + { + return Err( + "Each switch needs a name, a unique ID, and at most 32 hold actions.".into(), + ); + } + let key = crate::switch_input::normalize_key(&b.key) + .ok_or("Choose a supported switch key.")?; + if key == "Escape" || !keys.insert(key) { + return Err( + "Each switch needs a different key. Escape is reserved for disable.".into(), + ); + } + if std::iter::once(&b.press_action) + .chain(&b.hold_actions) + .any(|a| *a == Action::Cancel) + { + return Err("Choose a scanning action for each switch.".into()); + } + } + Ok(()) + } + pub fn validate_actions(&self, automatic: bool) -> Result<(), String> { + self.validate()?; + let has = |action| { + self.bindings + .iter() + .any(|b| b.press_action == action || b.hold_actions.contains(&action)) + }; + if !has(Action::Select) { + return Err( + "Assign Select to a switch in Settings → Switches before enabling scanning.".into(), + ); + } + if !automatic && (!has(Action::Next) || !has(Action::Back)) { + return Err( + "Manual scanning requires Select, Next and Previous switch actions.".into(), + ); + } + Ok(()) + } + pub fn escape_ms(&self) -> u64 { + let n = self + .bindings + .iter() + .map(|b| b.hold_actions.len()) + .max() + .unwrap_or(0) as u64; + if n == 0 { + 4000 + } else { + 4000.max((n + 2) * self.hold_interval_ms) + } + } + pub fn migrate(config: &crate::point_scan::Config) -> Self { + Self { + bindings: [ + ("select", &config.select_key, Action::Select), + ("next", &config.next_key, Action::Next), + ("previous", &config.back_key, Action::Back), + ("pause", &config.pause_key, Action::Pause), + ] + .into_iter() + .map(|(id, key, action)| Binding { + id: format!("legacy-{id}"), + name: action.label().into(), + key: key.clone(), + press_action: action, + hold_actions: vec![], + }) + .collect(), + ..Self::default() + } + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn legacy_bindings_migrate_without_losing_keys() { + let c = crate::point_scan::Config::default(); + let s = Settings::migrate(&c); + assert_eq!(s.bindings[2].key, "Backspace"); + assert_eq!(s.bindings[3].key, "F8"); + s.validate_actions(false).unwrap(); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&s).unwrap()).unwrap(), + s + ); + } + #[test] + fn hold_actions_satisfy_requirements_and_extend_escape() { + let mut s = Settings::migrate(&crate::point_scan::Config::default()); + s.bindings.truncate(1); + s.bindings[0].hold_actions = vec![Action::Next, Action::Back, Action::Stop]; + s.validate_actions(false).unwrap(); + assert_eq!(s.escape_ms(), 5000); + s.bindings[0].hold_actions.clear(); + assert!(s.validate_actions(false).is_err()); + } + #[test] + fn duplicate_keys_and_reserved_actions_are_rejected() { + let mut s = Settings::migrate(&crate::point_scan::Config::default()); + s.bindings[1].key = "Space".into(); + assert!(s.validate().is_err()); + s.bindings[1].key = "Escape".into(); + assert!(s.validate().is_err()); + } +} diff --git a/src/App.tsx b/src/App.tsx index 2e458b0f..18ab49a4 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 { useSwitches } from "./scanning/useSwitches"; import { useScanning } from "./scanning/useScanning"; type View = "home" | "devices" | "profiles" | "settings" | "support"; @@ -436,7 +437,9 @@ function PairingDialog({ requests, connectedDeviceName, busy, approve, reject }: } export function App() { - const scanning=useScanning(); + const switches=useSwitches(); + const scanning=useScanning(switches.flush); + switches.setLocked(!!scanning.state?.enabled||scanning.toggling); const [state, setState] = useState(null); const [view, setView] = useState("home"); const viewRef = useRef("home"); @@ -728,7 +731,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 index e4009db8..d6330fb6 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -51,17 +51,13 @@ it("enables native point scan and locks its configuration until disabled", async }); expect(screen.getByRole("button", { name: "Line only" })).toBeDisabled(); }); -it("rejects duplicate switch keys and exposes grid settings", async () => { +it("exposes grid settings and directs switch assignments to their own tab", 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(); + expect(screen.queryByLabelText("Forward switch")).not.toBeInTheDocument(); + expect(screen.getByText(/Assign switch actions in the Switches tab/)).toBeInTheDocument(); }); it("reports native registration failure without claiming scanning started", async () => { render(); diff --git a/src/Switches.test.tsx b/src/Switches.test.tsx new file mode 100644 index 00000000..e7cb0211 --- /dev/null +++ b/src/Switches.test.tsx @@ -0,0 +1,349 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { beforeEach, afterEach, it, expect, vi } from "vitest"; +import { useSwitches, type SwitchState } from "./scanning/useSwitches"; +import { useScanning, defaultPointScanConfig } from "./scanning/useScanning"; +import { SwitchesSection } from "./settings/SwitchesSection"; +import { ScanningSection } from "./settings/ScanningSection"; +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: SwitchState = { + settings: { + schemaVersion: 1, + holdIntervalMs: 1000, + bindings: [ + { + id: "one", + name: "Head switch", + key: "Space", + pressAction: "select", + holdActions: ["next", "stop"], + }, + ], + }, + capture: { active: false, key: null, error: null }, + supported: true, + error: null, + escapeHoldMs: 4000, + unavailableKeys: [], +}; +let current: SwitchState; +const scan = { + config: defaultPointScanConfig, + enabled: false, + phase: "idle", + paused: false, + message: "Scanning is off.", + supported: true, +}; +function Shell({ visible = true }: { visible?: boolean }) { + const switches = useSwitches(); + const scanning = useScanning(switches.flush); + switches.setLocked(!!scanning.state?.enabled || scanning.toggling); + return ( + <> + {visible && ( + + )} + + + ); +} +function event(next: SwitchState) { + act(() => + mocks.listen.mock.calls.find(([name]) => name === "switches-changed")![1]({ + payload: next, + }), + ); +} +beforeEach(() => { + current = structuredClone(initial); + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: {}, + }); + mocks.listen.mockReset().mockResolvedValue(vi.fn()); + mocks.invoke.mockReset().mockImplementation(async (command, args) => { + switch (command) { + case "get_switches": + return current; + case "save_switches": + current = { ...current, settings: args.settings }; + return current; + case "begin_switch_capture": + current = { + ...current, + capture: { active: true, key: null, error: null }, + }; + return current; + case "cancel_switch_capture": + current = { + ...current, + capture: { active: false, key: null, error: null }, + }; + return current; + case "get_point_scan": + return scan; + case "configure_point_scan": + return { ...scan, ...args }; + } + }); +}); +afterEach(() => Reflect.deleteProperty(window, "__TAURI_INTERNALS__")); +const open = (name: string) => + fireEvent.click(screen.getByRole("button", { name: `Edit ${name}` })); +it("shows each switch as a summary row and expands one to edit", async () => { + render(); + await screen.findByRole("heading", { name: "Head switch" }); + expect(screen.getByText("Select · Hold: Next, Stop scanning")).toBeTruthy(); + expect(screen.queryByLabelText("Name for Space")).toBeNull(); + open("Head switch"); + expect(screen.getByLabelText("Name for Space")).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Close Head switch" }).getAttribute("aria-expanded"), + ).toBe("true"); + fireEvent.click(screen.getByRole("button", { name: "Done" })); + expect(screen.queryByLabelText("Name for Space")).toBeNull(); +}); +it("starts learning when a switch is added and commits only after name and key", async () => { + render(); + await screen.findByRole("heading", { name: "Head switch" }); + fireEvent.click(screen.getByRole("button", { name: "Add switch" })); + await screen.findByRole("button", { name: "Cancel capture" }); + expect(mocks.invoke).toHaveBeenCalledWith("begin_switch_capture"); + expect(screen.getByRole("button", { name: "Save switch" })).toBeDisabled(); + event({ ...current, capture: { active: false, key: "Enter", error: null } }); + await screen.findByText("Enter"); + expect(screen.getByRole("button", { name: "Save switch" })).toBeDisabled(); + fireEvent.change(screen.getByLabelText("New switch name"), { + target: { value: "Foot switch" }, + }); + expect(mocks.invoke.mock.calls.some(([c]) => c === "save_switches")).toBe( + false, + ); + fireEvent.click(screen.getByRole("button", { name: "Save switch" })); + await waitFor(() => expect(current.settings.bindings).toHaveLength(2)); + expect(current.settings.bindings[1]).toMatchObject({ + name: "Foot switch", + key: "Enter", + pressAction: "select", + }); + expect(screen.queryByLabelText("New switch name")).toBeNull(); +}); +it("cancelling a new switch also cancels its capture", async () => { + render(); + await screen.findByRole("heading", { name: "Head switch" }); + fireEvent.click(screen.getByRole("button", { name: "Add switch" })); + await screen.findByRole("button", { name: "Cancel capture" }); + event({ ...current, capture: { active: false, key: "Enter", error: null } }); + await screen.findByText("Enter"); + fireEvent.click(screen.getByRole("button", { name: "Cancel new switch" })); + expect(screen.queryByLabelText("New switch name")).toBeNull(); + expect(current.settings.bindings).toHaveLength(1); +}); +it("rejects a learned duplicate without changing the existing switch", async () => { + render(); + await screen.findByRole("heading", { name: "Head switch" }); + fireEvent.click(screen.getByRole("button", { name: "Add switch" })); + await screen.findByRole("button", { name: "Cancel capture" }); + event({ ...current, capture: { active: false, key: "Space", error: null } }); + 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 () => { + render(); + await screen.findByRole("heading", { name: "Head switch" }); + open("Head switch"); + let finish!: (v: SwitchState) => void; + mocks.invoke.mockImplementationOnce( + () => + new Promise((r) => { + finish = r; + }), + ); + fireEvent.click( + 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); + await act(async () => + finish({ + ...current, + settings: { + ...current.settings, + bindings: [ + { ...current.settings.bindings[0], holdActions: ["stop", "next"] }, + ], + }, + }), + ); + 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(); +}); +it("shows hold timing computed from the interval", async () => { + render(); + await screen.findByRole("heading", { name: "Head switch" }); + open("Head switch"); + expect( + screen.getByText("Hold 1s for Next, 2s for Stop scanning. Release to run the action shown."), + ).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "2s" })); + 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 () => { + render(); + await screen.findByRole("heading", { name: "Head switch" }); + open("Head switch"); + mocks.invoke.mockRejectedValueOnce("Disk full"); + fireEvent.change(screen.getByLabelText("Name for Space"), { + 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); + fireEvent.click(screen.getByRole("button", { name: "Retry save" })); + await waitFor(() => + expect(current.settings.bindings[0].name).toBe("New name"), + ); +}); +it("does not reuse an old learned key when starting another capture", async () => { + current = { ...current, capture: { active: false, key: "F2", error: null } }; + render(); + await screen.findByRole("heading", { name: "Head switch" }); + open("Head switch"); + fireEvent.click( + screen.getByRole("button", { name: "Learn another key for Head switch" }), + ); + await screen.findByRole("button", { name: "Cancel capture" }); + expect(current.settings.bindings[0].key).toBe("Space"); + event({ ...current, capture: { active: false, key: "F3", error: null } }); + await waitFor(() => expect(current.settings.bindings[0].key).toBe("F3")); +}); +it("cancels learning when the panel unmounts and retains pending edits", async () => { + const view = render(); + await screen.findByRole("heading", { name: "Head switch" }); + open("Head switch"); + fireEvent.click( + screen.getByRole("button", { name: "Learn another key for Head switch" }), + ); + await screen.findByRole("button", { name: "Cancel capture" }); + view.rerender(); + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith("cancel_switch_capture"), + ); + expect(current.capture.active).toBe(false); +}); +it("holds focus in a modal and swallows keys while learning", async () => { + render(); + await screen.findByRole("heading", { name: "Head switch" }); + open("Head switch"); + fireEvent.click( + screen.getByRole("button", { name: "Learn another key for Head switch" }), + ); + const dialog = await screen.findByRole("dialog", { + name: "Press and release your switch", + }); + expect(document.activeElement).toBe(dialog); + expect(screen.getByText(/Learning the key for Head switch/)).toBeTruthy(); + const up = new KeyboardEvent("keyup", { key: " ", bubbles: true, cancelable: true }); + screen.getByRole("button", { name: "Cancel capture" }).dispatchEvent(up); + expect(up.defaultPrevented).toBe(true); + expect(mocks.invoke).not.toHaveBeenCalledWith("cancel_switch_capture"); + event({ ...current, capture: { active: false, key: "F3", error: null } }); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + await waitFor(() => expect(current.settings.bindings[0].key).toBe("F3")); +}); +it("keeps a refused capture's error on its own row and releases the target", async () => { + render(); + await screen.findByRole("heading", { name: "Head switch" }); + open("Head switch"); + mocks.invoke.mockImplementationOnce(async () => { + throw "Focus Switchify PC before learning a switch."; + }); + fireEvent.click( + screen.getByRole("button", { name: "Learn another key for Head switch" }), + ); + await screen.findByText("Focus Switchify PC before learning a switch."); + expect(screen.queryByRole("dialog")).toBeNull(); + // A later view carrying a remembered key must not land on the refused row. + event({ ...current, capture: { active: false, key: "F9", error: null } }); + await waitFor(() => expect(mocks.invoke.mock.calls.filter(([c]) => c === "save_switches")).toHaveLength(0)); + expect(current.settings.bindings[0].key).toBe("Space"); + fireEvent.click(screen.getByRole("button", { name: "Close Head switch" })); + fireEvent.click(screen.getByRole("button", { name: "Add switch" })); + await screen.findByRole("dialog"); + expect(screen.queryByText("Focus Switchify PC before learning a switch.")).toBeNull(); +}); +it("returns focus to the row after Done and to Add switch after saving a new one", async () => { + render(); + await screen.findByRole("heading", { name: "Head switch" }); + open("Head switch"); + fireEvent.click(screen.getByRole("button", { name: "Done" })); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "Edit Head switch" })); + fireEvent.click(screen.getByRole("button", { name: "Add switch" })); + await screen.findByRole("dialog"); + event({ ...current, capture: { active: false, key: "Enter", error: null } }); + await screen.findByText("Enter"); + fireEvent.change(screen.getByLabelText("New switch name"), { target: { value: "Foot" } }); + fireEvent.click(screen.getByRole("button", { name: "Save switch" })); + await waitFor(() => expect(current.settings.bindings).toHaveLength(2)); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "Add switch" })); +}); +it("rejects a duplicate learned onto an existing switch and says so on that row", async () => { + render(); + await screen.findByRole("heading", { name: "Head switch" }); + fireEvent.click(screen.getByRole("button", { name: "Add switch" })); + await screen.findByRole("dialog"); + current = { ...current, capture: { active: false, key: "Enter", error: null } }; + event(current); + await screen.findByText("Enter"); + fireEvent.change(screen.getByLabelText("New switch name"), { target: { value: "Foot" } }); + fireEvent.click(screen.getByRole("button", { name: "Save switch" })); + await waitFor(() => expect(current.settings.bindings).toHaveLength(2)); + fireEvent.click(await screen.findByRole("button", { name: "Edit Foot" })); + fireEvent.click(screen.getByRole("button", { name: "Learn another key for Foot" })); + await screen.findByRole("dialog"); + event({ ...current, capture: { active: false, key: "Space", error: null } }); + await screen.findByText("That key already belongs to another switch."); + expect(current.settings.bindings[1].key).toBe("Enter"); + expect(screen.getByLabelText("Learn another key for Foot").getAttribute("aria-describedby")).toBeTruthy(); +}); +it("announces an unavailable key in the collapsed row", async () => { + render(); + await screen.findByRole("heading", { name: "Head switch" }); + event({ ...current, unavailableKeys: ["Space"] }); + await screen.findByText(/unavailable on this computer/); +}); +it("keeps focus in the draft when another switch is removed while adding", async () => { + render(); + await screen.findByRole("heading", { name: "Head switch" }); + fireEvent.click(screen.getByRole("button", { name: "Add switch" })); + await screen.findByRole("dialog"); + current = { ...current, capture: { active: false, key: "Enter", error: null } }; + event(current); + await screen.findByText("Enter"); + fireEvent.click(screen.getByRole("button", { name: "Remove Head switch" })); + await waitFor(() => expect(current.settings.bindings).toHaveLength(0)); + expect(document.activeElement).toBe(screen.getByLabelText("New switch name")); +}); diff --git a/src/scanning/useScanning.ts b/src/scanning/useScanning.ts index fc25b137..9731d93e 100644 --- a/src/scanning/useScanning.ts +++ b/src/scanning/useScanning.ts @@ -44,7 +44,7 @@ export function validSwitches(config: PointScanConfig) { ); } // App owns this hook so changing tabs or views never drops edits or stops a scan. -export function useScanning() { +export function useScanning(beforeEnable?: () => Promise) { const [state, setState] = useState(null); const [config, setConfig] = useState(defaultPointScanConfig); const [pending, setPending] = useState(0); @@ -160,6 +160,7 @@ export function useScanning() { 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.", diff --git a/src/scanning/useSwitches.ts b/src/scanning/useSwitches.ts new file mode 100644 index 00000000..88cf0204 --- /dev/null +++ b/src/scanning/useSwitches.ts @@ -0,0 +1,193 @@ +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +export const actions = { + select: "Select", + next: "Next", + back: "Previous", + reverse: "Reverse direction", + stop: "Stop scanning", + pause: "Pause / resume", +} as const; +export type SwitchAction = keyof typeof actions; +export type Binding = { + id: string; + name: string; + key: string; + pressAction: SwitchAction; + holdActions: SwitchAction[]; +}; +export type SwitchSettings = { + schemaVersion: number; + holdIntervalMs: number; + bindings: Binding[]; +}; +export type SwitchState = { + settings: SwitchSettings; + capture: { active: boolean; key: string | null; error: string | null }; + supported: boolean; + error: string | null; + escapeHoldMs: number; + unavailableKeys: string[]; +}; +const defaults: SwitchSettings = { + schemaVersion: 1, + holdIntervalMs: 1000, + bindings: [], +}; +export function useSwitches() { + const [state, setState] = useState(null); + const [settings, setSettings] = useState(defaults); + const [pending, setPending] = useState(0); + const [error, setError] = useState(null); + const [capturing, setCapturing] = useState(false); + const model = useRef({ + settings: defaults, + revision: 0, + saved: 0, + pending: 0, + locked: false, + state: null as SwitchState | null, + }); + const captureRequest = useRef(0); + const queue = useRef(Promise.resolve()); + const runtime = useRef(0); + const receive = (next: SwitchState) => { + model.current.state = next; + setState(next); + if ( + model.current.pending === 0 && + model.current.revision === model.current.saved + ) { + model.current.settings = next.settings; + setSettings(next.settings); + } + }; + useEffect(() => { + let alive = true; + let stop: (() => void) | undefined; + if (!("__TAURI_INTERNALS__" in window)) { + receive({ + settings: defaults, + capture: { active: false, key: null, error: null }, + supported: false, + error: null, + escapeHoldMs: 4000, + unavailableKeys: [], + }); + return; + } + void listen("switches-changed", (e) => { + runtime.current++; + if (alive) receive(e.payload); + }) + .then((unlisten) => { + if (alive) stop = unlisten; + else unlisten(); + const stamp = runtime.current; + return invoke("get_switches").then((next) => { + if (alive && stamp === runtime.current) receive(next); + }); + }) + .catch((e) => { + if (alive) setError(String(e)); + }); + return () => { + alive = false; + stop?.(); + }; + }, []); + const save = (next: SwitchSettings, revision: number) => { + const m = model.current; + m.pending++; + setPending(m.pending); + queue.current = queue.current + .then(async () => { + if (revision !== m.revision) return; + setError(null); + const stamp = runtime.current; + const result = await invoke("save_switches", { + settings: next, + }); + m.saved = revision; + if (stamp === runtime.current) receive(result); + }) + .catch((e) => setError(String(e))) + .finally(() => { + m.pending--; + setPending(m.pending); + }); + }; + const update = (next: SwitchSettings) => { + const m = model.current; + if (m.locked || 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(); + if (request !== captureRequest.current) return; + setError(null); + if (model.current.state) + receive({ + ...model.current.state, + capture: { active: true, key: null, error: null }, + }); + const stamp = runtime.current; + const result = await invoke("begin_switch_capture"); + if (request === captureRequest.current && stamp === runtime.current) + receive(result); + } catch (e) { + setError(String(e)); + if (model.current.state) + receive({ + ...model.current.state, + capture: { active: false, key: null, error: String(e) }, + }); + } finally { + setCapturing(false); + } + }; + const cancelCapture = async () => { + captureRequest.current++; + try { + const stamp = runtime.current; + const result = await invoke("cancel_switch_capture"); + if (stamp === runtime.current) receive(result); + } catch (e) { + setError(String(e)); + } + }; + return { + state, + settings, + pending, + 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 ae64517e..3417bda4 100644 --- a/src/settings/ScanningSection.tsx +++ b/src/settings/ScanningSection.tsx @@ -6,17 +6,6 @@ import { 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", @@ -60,6 +49,10 @@ export function ScanningSection({ Switch keys are reserved while enabled. Escape stops scanning and releases them. Disconnect Android before enabling local point scan.

+

+ On Windows, use switch keys without Shift, Ctrl, Alt or Windows held. + Key releases can still reach other applications. +

{state?.enabled @@ -109,7 +102,7 @@ export function ScanningSection({ update("automatic", value)} /> - {( - [ - ["selectKey", "Select switch"], - ["nextKey", "Forward switch"], - ["backKey", "Backward switch"], - ["pauseKey", "Pause / resume switch"], - ] as const - ).map(([key, label]) => ( - - ))} +

Assign switch actions in the Switches tab. All actions run on release. Holding a switch freezes movement. After clicking, or after three passes without a selection, use Select to start again.

-

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

void; chooseTelemetry: (enabled: boolean) => void; updateAction: (action: UpdateAction) => void; cancelUpdate: () => void; busy: boolean; focusUpdates: boolean; onUpdatesFocused: () => void; updateAttention: string | null; onUpdatesShown: (shown: boolean) => void }) { +export function SettingsView({ switches, scanning, state, settings, onChange, chooseTelemetry, updateAction, cancelUpdate, busy, focusUpdates, onUpdatesFocused, updateAttention, onUpdatesShown }: { switches: SwitchController; 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 @@ -30,6 +32,7 @@ export function SettingsView({ scanning, state, settings, onChange, chooseTeleme const tabs = useMemo[]>(() => [ { id: "general" as const, label: "General" }, { id: "pointer" as const, label: "Controls" }, + { id: "switches" as const, label: "Switches" }, { id: "scanning" as const, label: "Scanning" }, ...(state.capabilities.cursorOverlay ? [{ id: "cursor" as const, label: "Cursor appearance" }] : []), { id: "privacy" as const, label: "Privacy" }, @@ -59,6 +62,7 @@ export function SettingsView({ scanning, state, settings, onChange, chooseTeleme {active === "general" && } {active === "pointer" && } + {active === "switches" && } {active === "scanning" && } {active === "cursor" && } {active === "privacy" && } diff --git a/src/settings/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx new file mode 100644 index 00000000..2551455b --- /dev/null +++ b/src/settings/SwitchesSection.tsx @@ -0,0 +1,687 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { ChevronDown, ChevronUp, Keyboard, Trash2, X } from "lucide-react"; +import { + actions, + type Binding, + type SwitchAction, + type SwitchController, +} from "../scanning/useSwitches"; +import { + Disclosure, + OptionGroup, + SettingGroup, + SettingNote, + secondsOptions, +} from "./controls"; + +const holdIntervalPresets = [500, 1000, 1500, 2000, 3000] as const; +const holdIntervalValues = [250, 500, 750, 1000, 1500, 2000, 3000, 4000, 5000]; +const newId = "new"; + +function seconds(ms: number) { + const value = ms / 1000; + return `${Number.isInteger(value) ? value : value.toFixed(2).replace(/0$/, "")}s`; +} + +// The emergency exit must outlast the longest hold list, so it grows with the +// interval. Mirrors the backend rule so the shown number matches behaviour. +export function escapeHoldMs(bindings: Binding[], holdIntervalMs: number) { + const longest = Math.max(0, ...bindings.map((b) => b.holdActions.length)); + return longest ? Math.max(4000, (longest + 2) * holdIntervalMs) : 4000; +} + +function summary(binding: Binding) { + const hold = binding.holdActions.length + ? `Hold: ${binding.holdActions.map((a) => actions[a]).join(", ")}` + : "No hold actions"; + return `${actions[binding.pressAction]} · ${hold}`; +} + +function ActionSelect({ + label, + value, + onChange, +}: { + label: string; + value: SwitchAction; + onChange: (value: SwitchAction) => void; +}) { + return ( + + ); +} + +function KeyBadge({ value, unavailable }: { value: string; unavailable: boolean }) { + return value ? ( + + {value} + {unavailable && (unavailable on this computer)} + + ) : ( + No key yet + ); +} + +function SwitchEditor({ + id, + binding, + isNew, + disabled, + holdIntervalMs, + unavailable, + keyError, + onChange, + onLearn, + onDone, + onRemove, + nameRef, +}: { + id?: string; + binding: Binding; + isNew: boolean; + disabled: boolean; + holdIntervalMs: number; + unavailable: boolean; + keyError: string | null; + onChange: (next: Binding) => void; + onLearn: () => void; + onDone: () => void; + onRemove: () => void; + nameRef: React.RefObject; +}) { + const name = binding.name || "this switch"; + const keyErrorId = useId(); + const canSave = !isNew || (!!binding.name.trim() && !!binding.key); + const move = (index: number, delta: number) => { + const holdActions = [...binding.holdActions]; + [holdActions[index], holdActions[index + delta]] = [ + holdActions[index + delta], + holdActions[index], + ]; + onChange({ ...binding, holdActions }); + }; + return ( +
+ +
+ Key +
+ + +
+ {(keyError || unavailable) && ( + + {keyError ?? + "This key is unavailable on this computer. Learn another key."} + + )} +
+ +
+ Hold + {binding.holdActions.length > 0 && ( +
    + {binding.holdActions.map((action, index) => ( +
  1. + + + onChange({ + ...binding, + holdActions: binding.holdActions.map((a, i) => + i === index ? value : a, + ), + }) + } + /> + + + +
  2. + ))} +
+ )} +

+ {binding.holdActions.length + ? `Hold ${binding.holdActions + .map((a, i) => `${seconds(holdIntervalMs * (i + 1))} for ${actions[a]}`) + .join(", ")}. Release to run the action shown.` + : "Holding only freezes movement. Add actions to offer them one by one while held."} +

+ +
+
+ + +
+
+ ); +} + +// Learning must own the keyboard: on Windows the reserved press never reaches +// the page but its release does, and a Space release on a focused button +// clicks it, while an unreserved key would scroll Settings. A separate window +// is not an option because losing main-window focus cancels the capture. So a +// modal holds focus on itself, not on a control, and swallows every key event +// until learning ends. Escape still reaches the backend through its hotkey. +function CaptureDialog({ name, onCancel }: { name: string; onCancel: () => void }) { + const ref = useRef(null); + const titleId = useId(); + const bodyId = useId(); + useEffect(() => { + const previous = document.activeElement as HTMLElement | null; + ref.current?.focus(); + const swallow = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + const focusBack = () => { + if (!ref.current?.contains(document.activeElement)) ref.current?.focus(); + }; + for (const type of ["keydown", "keyup", "keypress"] as const) + document.addEventListener(type, swallow, true); + document.addEventListener("focusin", focusBack); + return () => { + for (const type of ["keydown", "keyup", "keypress"] as const) + document.removeEventListener(type, swallow, true); + document.removeEventListener("focusin", focusBack); + previous?.focus(); + }; + }, []); + return ( +
+
+
+
+ ); +} + +export function SwitchesSection({ + controller, + locked, +}: { + controller: SwitchController; + locked: boolean; +}) { + 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. + const error = + controller.error && controller.error !== state?.capture.error + ? controller.error + : null; + const [draft, setDraft] = useState(null); + const [expanded, setExpanded] = useState(null); + const [target, setTarget] = useState(null); + // A key error belongs to the row that was learning when it happened, not to + // whichever row is open later; the backend keeps its last capture error + // until the next capture begins, so it is consumed once here. + const [rowError, setRowError] = useState<{ id: string; message: string } | null>(null); + const nameRef = useRef(null); + const focusName = useRef(false); + // Collapsing unmounts the button that was clicked, which would drop focus to + // the page. Remember where focus should land and move it after the render. + const editRefs = useRef(new Map()); + const addRef = useRef(null); + const focusAfter = useRef(null); + useEffect(() => { + if (!focusAfter.current) return; + const id = focusAfter.current; + focusAfter.current = null; + // While a new switch is being drafted the Add switch button is not rendered, + // so a removal elsewhere hands focus to the draft's name field instead. + const target = id === newId ? null : editRefs.current.get(id); + (target ?? addRef.current ?? nameRef.current)?.focus(); + }); + const cancel = useRef(controller.cancelCapture); + cancel.current = controller.cancelCapture; + useEffect( + () => () => { + void cancel.current(); + }, + [], + ); + useEffect(() => { + if ( + controller.capturing || + !target || + !state?.capture.key || + state.capture.active + ) + return; + const key = state.capture.key; + if (settings.bindings.some((b) => b.key === key && b.id !== target)) { + setRowError({ id: target, message: "That key already belongs to another switch." }); + setTarget(null); + return; + } + if (target === newId) { + setDraft((d) => (d ? { ...d, key } : d)); + focusName.current = true; + } else + controller.update({ + ...settings, + bindings: settings.bindings.map((b) => + b.id === target ? { ...b, key } : b, + ), + }); + setTarget(null); + }, [state?.capture.key, state?.capture.active, target, settings, controller]); + const capturing = controller.capturing || !!state?.capture.active; + // A capture that ends without a key, whether refused or cancelled by focus + // loss, must release its target; otherwise the backend's remembered key from + // an earlier capture could be assigned to this row by a later view. + useEffect(() => { + if (capturing || !target || !state || state.capture.key) return; + if (state.capture.error) setRowError({ id: target, message: state.capture.error }); + setTarget(null); + }, [capturing, target, state]); + const disabled = locked || !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(() => { + if (!disabled && focusName.current) { + focusName.current = false; + nameRef.current?.focus(); + } + }, [disabled]); + const edit = (binding: Binding) => + controller.update({ + ...settings, + bindings: settings.bindings.map((b) => + b.id === binding.id ? binding : b, + ), + }); + const remove = (id: string) => { + focusAfter.current = newId; + controller.update({ + ...settings, + bindings: settings.bindings.filter((b) => b.id !== id), + }); + }; + const learn = (id: string) => { + setRowError(null); + setTarget(id); + void controller.capture(); + }; + const startAdd = () => { + setDraft({ id: newId, name: "", key: "", pressAction: "select", holdActions: [] }); + setExpanded(newId); + learn(newId); + }; + const cancelAdd = () => { + setDraft(null); + setExpanded(null); + setRowError(null); + focusAfter.current = newId; + if (target === newId) { + setTarget(null); + void controller.cancelCapture(); + } + }; + const add = () => { + if (!draft?.name.trim() || !draft.key) return; + controller.update({ + ...settings, + bindings: [ + ...settings.bindings, + { ...draft, id: crypto.randomUUID(), name: draft.name.trim() }, + ], + }); + setDraft(null); + setExpanded(null); + focusAfter.current = newId; + }; + const escapeMs = escapeHoldMs(settings.bindings, settings.holdIntervalMs); + const isPreset = (holdIntervalPresets as readonly number[]).includes( + settings.holdIntervalMs, + ); + const [showExact, setShowExact] = useState(!isPreset); + useEffect(() => { + if (!isPreset) setShowExact(true); + }, [isPreset]); + const exactId = useId(); + const listId = useId(); + const captureName = + target === newId + ? "the new switch" + : (settings.bindings.find((b) => b.id === target)?.name ?? "this switch"); + const errorFor = (id: string) => (rowError?.id === id ? rowError.message : null); + return ( + <> + {capturing && target && ( + { + setTarget(null); + void controller.cancelCapture(); + }} + /> + )} + + +

+ {pending + ? "Saving switches..." + : unsaved + ? "Switch assignments have unsaved changes." + : locked + ? "Disable scanning to change switches." + : "Changes save automatically."} +

+ {(error || state?.error) && ( +
+ {error || state?.error} + {error && unsaved && ( + + )} +
+ )} + {!settings.bindings.length && !draft ? ( +
+
+ ) : ( +
+ {settings.bindings.map((binding) => { + const open = expanded === binding.id; + const name = binding.name || "Unnamed switch"; + const unavailable = !!state?.unavailableKeys.includes(binding.key); + return ( +
+
+
+ {open && ( + learn(binding.id)} + onDone={() => { + focusAfter.current = binding.id; + setExpanded(null); + }} + onRemove={() => remove(binding.id)} + nameRef={nameRef} + /> + )} +
+ ); + })} + {draft && ( +
+
+
+ learn(newId)} + onDone={add} + onRemove={cancelAdd} + nameRef={nameRef} + /> +
+ )} + {!draft && ( +
+ +
+ )} +
+ )} +
+ +
+ + legend="Hold action interval" + columns="five" + disabled={disabled} + value={settings.holdIntervalMs} + onChange={(holdIntervalMs) => + controller.update({ ...settings, holdIntervalMs }) + } + options={secondsOptions(holdIntervalPresets)} + /> + setShowExact(!showExact)} + controls={exactId} + > + + +

+ The first action appears after one interval and the next after + each further interval. The last action stays offered. Holding any + switch for {seconds(escapeMs)} disables switch control. +

+
+
+ + ); +} diff --git a/src/settings/settings.test.tsx b/src/settings/settings.test.tsx index 5a47e802..d730be7a 100644 --- a/src/settings/settings.test.tsx +++ b/src/settings/settings.test.tsx @@ -39,7 +39,7 @@ describe("Switchify PC settings", () => { 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:"Scan movement"})).toBeInTheDocument(); expect(screen.getByRole("heading",{name:"Point scan"})).toBeInTheDocument(); }); @@ -416,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", "Scanning", "Cursor appearance", "Privacy", "Updates"]); + .toEqual(["General", "Controls", "Switches", "Scanning", "Cursor appearance", "Privacy", "Updates"]); expect(screen.getByRole("tab", { name: "General" })).toHaveAttribute("aria-selected", "true"); expect(screen.getByRole("tabpanel")).toHaveAccessibleName("General"); }); @@ -465,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", "Scanning", "Privacy", "Updates"]); + .toEqual(["General", "Controls", "Switches", "Scanning", "Privacy", "Updates"]); selectTab("Controls"); expect(screen.queryByRole("checkbox", { name: "Show cursor overlay" })).not.toBeInTheDocument(); }); diff --git a/src/styles.css b/src/styles.css index 5d754a89..c6b4fb37 100644 --- a/src/styles.css +++ b/src/styles.css @@ -76,7 +76,7 @@ main { min-width: 0; } .primary.danger { border-color: var(--status-error); background: var(--status-error); } .text-button { border: 0; color: var(--brand); background: transparent; font-weight: 650; } button:disabled { cursor: default; opacity: 0.55; } -.icon-button { display: grid; place-items: center; width: 36px; height: 36px; border: 0; border-radius: 6px; background: transparent; cursor: pointer; } +.icon-button { display: grid; place-items: center; width: 36px; height: 36px; border: 0; border-radius: 6px; color: inherit; background: transparent; cursor: pointer; } .danger-icon { color: var(--status-error); } .danger-icon:hover { background: var(--status-error-soft); } @@ -317,3 +317,51 @@ button:disabled { cursor: default; opacity: 0.55; } .settings-view > .tab-bar { grid-template-columns: repeat(var(--tab-count), auto); } .settings-view > .tab-bar button { padding: 0 4px; } + +/* Switches: a summary row per switch, expanding to an editor. Switch-access + users step through every control, so the collapsed row exposes only Edit + and Remove. */ +.switch-status { margin-top: 0; } +.switch-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 0; } +.switch-empty { padding: 36px 24px; } +.switch-empty h3 { margin-top: 12px; color: var(--text); font-size: 15px; } +.switch-empty .primary { margin-top: 16px; } +.switch-list { display: grid; gap: 8px; } +.switch-row { border: 1px solid var(--border); border-radius: 8px; background: var(--surface); } +.switch-row[data-open] { border-color: var(--brand-border); } +.switch-row-summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto auto; align-items: center; gap: 12px; min-height: 60px; padding: 8px 8px 8px 14px; } +.switch-row-summary > svg { color: var(--muted); } +.switch-row-summary h3 { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; } +.switch-row-summary p { margin-top: 3px; overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.key-badge { display: inline-flex; align-items: center; min-height: 26px; border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; padding: 0 9px; background: var(--surface-muted); font: 600 12px/1 ui-monospace, Consolas, monospace; white-space: nowrap; } +.key-badge[data-unavailable] { border-color: var(--status-error); color: var(--status-error); background: var(--status-error-soft); } +.key-badge-empty { border-style: dashed; color: var(--muted); font-family: inherit; font-weight: 500; } +.capture-dialog { display: grid; justify-items: center; width: min(100%, 420px); border: 1px solid var(--brand-border); border-radius: 10px; padding: 32px 28px 24px; color: var(--text); background: var(--surface); box-shadow: 0 20px 60px rgba(28, 27, 31, 0.2); text-align: center; animation: capture-pulse 1.4s ease-in-out infinite; } +.capture-dialog:focus { outline: none; } +.capture-dialog > svg { color: var(--brand); } +.capture-dialog h2 { margin-top: 14px; font-size: 18px; } +.capture-dialog p { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.5; } +.capture-dialog button { margin-top: 20px; } +@keyframes capture-pulse { 50% { border-color: var(--brand); box-shadow: 0 20px 60px rgba(28, 27, 31, 0.2), 0 0 0 4px var(--brand-container); } } +@media (prefers-reduced-motion: reduce) { .capture-dialog { animation: none; } } +.switch-editor { display: grid; gap: 16px; min-width: 0; margin: 0; border: 0; border-top: 1px solid var(--border); padding: 4px 14px 14px; } +.switch-editor .field { margin-top: 0; } +.switch-editor .field > span:first-child { font-weight: 600; } +.switch-editor select { min-width: 0; min-height: 36px; border: 1px solid var(--border); border-radius: 5px; padding: 6px 9px; color: var(--text); background: var(--surface); } +.key-row { display: flex; align-items: center; gap: 10px; } +.hold-list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; } +.hold-list li { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto auto auto; align-items: center; gap: 4px; } +.hold-step { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; color: var(--on-brand-container); background: var(--brand-container); font-size: 11px; font-weight: 700; } +.hold-list select { margin-right: 6px; } +.hold-actions .setting-note { margin-top: 2px; } +.hold-actions .disclosure { margin-top: 0; } +.switch-editor-footer { display: flex; gap: 8px; padding-top: 4px; } +.switch-editor-footer .danger { margin-left: auto; } +.switch-list-actions { display: flex; } + +@media (max-width: 680px) { + .switch-row-summary { grid-template-columns: auto minmax(0, 1fr) auto; } + .switch-row-summary .key-badge { grid-column: 2; justify-self: start; } + .switch-row-summary > .secondary { grid-column: 3; grid-row: 1; } + .switch-row-summary > .icon-button { grid-column: 3; grid-row: 2; justify-self: end; } +}