From 7e5e5b501bcbb9f453e7340ca67bcf3ad0bc27bf Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 12 Sep 2026 14:43:35 +0100 Subject: [PATCH 01/13] Add named switch assignments with embedded USAHP hold actions --- docs/point-scan.md | 10 +- docs/switches.md | 21 + src-tauri/Cargo.lock | 617 ++++++++++++++++-- src-tauri/Cargo.toml | 5 +- src-tauri/build.rs | 4 + src-tauri/capabilities/main.json | 4 + .../autogenerated/begin_switch_capture.toml | 11 + .../autogenerated/cancel_switch_capture.toml | 11 + .../autogenerated/get_switches.toml | 11 + .../autogenerated/save_switches.toml | 11 + src-tauri/src/lib.rs | 70 +- src-tauri/src/modifier_overlay.rs | 2 +- src-tauri/src/modifier_overlay_windows.rs | 26 + src-tauri/src/point_scan.rs | 3 +- src-tauri/src/point_scan_runtime.rs | 6 +- src-tauri/src/scan_host.rs | 30 + src-tauri/src/scanning.rs | 73 +-- src-tauri/src/scanning_runtime.rs | 168 +++-- src-tauri/src/storage.rs | 14 + src-tauri/src/switch_gestures.rs | 130 ++++ src-tauri/src/switch_runtime.rs | 281 ++++++++ src-tauri/src/switches.rs | 153 +++++ src/App.tsx | 7 +- src/PointScan.test.tsx | 10 +- src/Switches.test.tsx | 212 ++++++ src/scanning/useScanning.ts | 3 +- src/scanning/useSwitches.ts | 193 ++++++ src/settings/ScanningSection.tsx | 40 +- src/settings/SettingsView.tsx | 8 +- src/settings/SwitchesSection.tsx | 361 ++++++++++ src/settings/settings.test.tsx | 6 +- 31 files changed, 2271 insertions(+), 230 deletions(-) create mode 100644 docs/switches.md create mode 100644 src-tauri/permissions/autogenerated/begin_switch_capture.toml create mode 100644 src-tauri/permissions/autogenerated/cancel_switch_capture.toml create mode 100644 src-tauri/permissions/autogenerated/get_switches.toml create mode 100644 src-tauri/permissions/autogenerated/save_switches.toml create mode 100644 src-tauri/src/switch_gestures.rs create mode 100644 src-tauri/src/switch_runtime.rs create mode 100644 src-tauri/src/switches.rs create mode 100644 src/Switches.test.tsx create mode 100644 src/scanning/useSwitches.ts create mode 100644 src/settings/SwitchesSection.tsx diff --git a/docs/point-scan.md b/docs/point-scan.md index 1d54dead..26035575 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. 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. Embedded USAHP supplies physical press/release events and owns native capture on both platforms. 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..5d622f5c --- /dev/null +++ b/docs/switches.md @@ -0,0 +1,21 @@ +# Switch assignments + +In Settings → Switches, add a switch, enter its name, learn its physical key by pressing and releasing it, then save the new switch. 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. 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. 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. + +USAHP owns native Windows and macOS capture, suppression, learning, physical key state, release draining and emergency cancellation. Switchify embeds its typed API and drains generation-tagged physical edges. It opens no USAHP socket. Output still uses Switchify's existing input adapters. The pinned upstream extension is [usahp-core PR17](https://github.com/usahp/usahp-core/pull/17). + +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..e90eac7e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -52,6 +52,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -341,6 +391,24 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -604,6 +672,46 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.58" @@ -613,6 +721,27 @@ dependencies = [ "cc", ] +[[package]] +name = "cocoa" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667fdc068627a2816b9ff831201dd9864249d6ee8d190b9532357f1fc0f61ea7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics 0.21.0", + "foreign-types 0.3.2", + "libc", + "objc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -642,22 +771,72 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171" +dependencies = [ + "core-foundation-sys 0.7.0", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys 0.8.7", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] +[[package]] +name = "core-foundation-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3889374e6ea6ab25dba90bb5d96202f61108058361f6dc72e8b03e6f8bbe923" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.7.0", + "foreign-types 0.3.2", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a67c4378cf203eace8fb6567847eb641fd6ff933c1145a115c6ee820ebb978" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "foreign-types 0.3.2", + "libc", +] + [[package]] name = "core-graphics" version = "0.25.0" @@ -665,9 +844,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -678,7 +857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -826,6 +1005,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "dbus" version = "0.9.12" @@ -1093,9 +1278,9 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71c6c56e50f7acae2906a0dcbb34529ca647e40421119ad5d12e7f8ba6e50010" dependencies = [ - "core-foundation", - "core-graphics", - "foreign-types-shared", + "core-foundation 0.10.1", + "core-graphics 0.25.0", + "foreign-types-shared 0.3.1", "libc", "log", "nom", @@ -1129,6 +1314,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "epoll" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e74d68fe2927dbf47aa976d14d93db9b23dced457c7bb2bdc6925a16d31b736e" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1156,6 +1351,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "evdev" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b686663ba7f08d92880ff6ba22170f1df4e83629341cba34cf82cd65ebea99" +dependencies = [ + "bitvec", + "cfg-if", + "libc", + "nix", +] + +[[package]] +name = "evdev-rs" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b92abc30d5fd1e4f6440dee4d626abc68f4a9b5014dc1de575901e23c2e02321" +dependencies = [ + "bitflags 1.3.2", + "evdev-sys", + "libc", + "log", +] + +[[package]] +name = "evdev-sys" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcf0d489f4d9a80ac2b3b35b92fdd8fcf68d33bb67f947afe5cd36e482de576" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "event-listener" version = "5.4.2" @@ -1239,6 +1469,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1246,7 +1485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1260,6 +1499,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1281,6 +1526,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures-channel" version = "0.3.33" @@ -1608,24 +1859,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" @@ -1857,7 +2090,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", - "core-foundation-sys", + "core-foundation-sys 0.8.7", "iana-time-zone-haiku", "js-sys", "log", @@ -2038,6 +2271,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46dd0a94b393c730779ccfd2a872b67b1eb67be3fc33082e733bdb38b5fde4d4" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -2054,6 +2307,12 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -2232,6 +2491,12 @@ dependencies = [ "log", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libappindicator" version = "0.9.0" @@ -2323,6 +2588,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -2334,6 +2608,15 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.3" @@ -2452,6 +2735,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "8.0.0" @@ -2461,6 +2756,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "num" version = "0.4.3" @@ -2561,6 +2865,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "objc2" version = "0.6.4" @@ -2797,6 +3110,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -3207,6 +3526,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.7" @@ -3214,10 +3539,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" @@ -3239,6 +3574,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -3248,6 +3593,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" @@ -3269,6 +3623,25 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rdev" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00552ca2dc2f93b84cd7b5581de49549411e4e41d89e1c691bcb93dc4be360c3" +dependencies = [ + "cocoa", + "core-foundation 0.7.0", + "core-foundation-sys 0.7.0", + "core-graphics 0.19.2", + "epoll", + "evdev-rs", + "inotify", + "lazy_static", + "libc", + "winapi", + "x11", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3474,8 +3847,8 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", - "core-foundation-sys", + "core-foundation 0.10.1", + "core-foundation-sys 0.8.7", "jni 0.22.4", "log", "once_cell", @@ -3614,8 +3987,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.1", - "core-foundation", - "core-foundation-sys", + "core-foundation 0.10.1", + "core-foundation-sys 0.8.7", "libc", "security-framework-sys", ] @@ -3626,7 +3999,7 @@ version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] @@ -3817,6 +4190,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3828,6 +4212,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -4007,7 +4400,7 @@ version = "1.0.0-rc.6" dependencies = [ "base64 0.22.1", "block2", - "core-graphics", + "core-graphics 0.25.0", "corebluetooth-rs", "directories", "enigo", @@ -4026,11 +4419,12 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-autostart", - "tauri-plugin-global-shortcut", "tauri-plugin-single-instance", "tauri-plugin-updater", "tiny-skia", "tokio", + "usahp-core", + "usahp-daemon", "uuid", "windows 0.62.2", "winreg 0.55.0", @@ -4109,8 +4503,8 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.1", "block2", - "core-foundation", - "core-graphics", + "core-foundation 0.10.1", + "core-graphics 0.25.0", "crossbeam-channel", "dbus", "dispatch2", @@ -4152,6 +4546,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tar" version = "0.4.46" @@ -4313,21 +4713,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" @@ -4539,6 +4924,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.55" @@ -4630,6 +5024,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -4656,6 +5051,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -4863,6 +5270,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -4893,6 +5330,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.19", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -5000,12 +5454,56 @@ dependencies = [ "url", ] +[[package]] +name = "usahp-core" +version = "0.1.0" +source = "git+https://github.com/usahp/usahp-core?rev=af823e1ca58eaceafe0dcfa2908d6c90b526ee13#af823e1ca58eaceafe0dcfa2908d6c90b526ee13" +dependencies = [ + "serde", + "thiserror 2.0.19", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "usahp-daemon" +version = "0.1.0" +source = "git+https://github.com/usahp/usahp-core?rev=af823e1ca58eaceafe0dcfa2908d6c90b526ee13#af823e1ca58eaceafe0dcfa2908d6c90b526ee13" +dependencies = [ + "anyhow", + "clap", + "core-foundation 0.10.1", + "core-graphics 0.25.0", + "evdev", + "futures-util", + "rdev", + "serde_json", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "usahp-core", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.24.0" @@ -5018,6 +5516,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version-compare" version = "0.2.1" @@ -5870,6 +6374,15 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 04e1c227..0ff500b5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,7 +26,8 @@ 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" +usahp-daemon = { git = "https://github.com/usahp/usahp-core", rev = "af823e1ca58eaceafe0dcfa2908d6c90b526ee13" } +usahp-core = { git = "https://github.com/usahp/usahp-core", rev = "af823e1ca58eaceafe0dcfa2908d6c90b526ee13" } tauri-plugin-single-instance = "2.4.3" tauri-plugin-updater = "2.10.1" tiny-skia = "0.11.4" @@ -39,7 +40,7 @@ corebluetooth-rs = "=0.3.6" 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"] } 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..ab563264 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,9 @@ mod scanning; mod scanning_runtime; mod state; mod storage; +mod switch_gestures; +mod switch_runtime; +mod switches; mod telemetry; mod updater; #[cfg(target_os = "windows")] @@ -205,6 +208,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 +1223,60 @@ fn install_tray(app: &mut tauri::App) -> tauri::Result<()> { Ok(()) } +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 !window.is_focused().map_err(|e| e.to_string())? { + 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 +1344,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 +1364,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 +1434,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 +1455,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..0b389f9a 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -176,7 +176,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 } 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..bdd4b99a 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)] @@ -116,37 +125,6 @@ pub trait Technique { fn phase(&self) -> Self::Phase; } -/// Releases produce one semantic action; Select freezes the clock while held. -#[derive(Default)] -pub struct SwitchInput { - pressed: [bool; 5], -} -impl SwitchInput { - pub fn event( - &mut self, - current_generation: u64, - event_generation: u64, - index: usize, - pressed: bool, - ) -> Option { - if current_generation != event_generation { - return None; - } - let action = *ACTIONS.get(index)?; - let was_pressed = std::mem::replace(&mut self.pressed[index], pressed); - if action == Action::Cancel { - return (pressed && !was_pressed).then_some(action); - } - (!pressed && was_pressed).then_some(action) - } - pub fn selecting(&self) -> bool { - self.pressed[0] - } - pub fn reset(&mut self) { - self.pressed = [false; 5]; - } -} - pub struct Session { pub technique: T, automatic: bool, @@ -169,7 +147,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; } @@ -338,21 +316,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..ee9efcd9 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,7 @@ pub trait Adapter: Send + Sync + 'static { ) -> Result<(), String>; } +use crate::{switch_gestures::Gestures, switch_runtime, switches::Settings}; use std::{ cell::RefCell, sync::{ @@ -36,9 +37,9 @@ use std::{ time::Instant, }; use tauri::{AppHandle, Emitter, Manager}; -use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; +use usahp_daemon::embedded::Event; -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,16 @@ pub fn configure( publish::(app); Ok(c.view()) } -fn switch(app: &AppHandle, generation: u64, index: usize, pressed: bool) { +fn switch(app: &AppHandle, action: Action) { let c = app.state::>(); if !c.enabled.load(Ordering::SeqCst) { return; } - let action = { - let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); - d.pressed.event( - c.generation.load(Ordering::SeqCst), - generation, - index, - pressed, - ) - }; - let Some(action) = action else { - return; - }; if action == Action::Cancel { disable::(app, "Scanning cancelled. Switch keys released."); return; } + 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 { @@ -313,6 +276,42 @@ 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 { + continue; + } + if action == usahp_core::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); + } + } + _ => {} + } + } if !c.enabled.load(Ordering::SeqCst) { return; } @@ -328,11 +327,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 +364,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_runtime.rs b/src-tauri/src/switch_runtime.rs new file mode 100644 index 00000000..de8ebfb4 --- /dev/null +++ b/src-tauri/src/switch_runtime.rs @@ -0,0 +1,281 @@ +//! App-owned switch settings and the embedded USAHP capture lease. +use crate::switches::Settings; +use serde::Serialize; +use std::sync::Mutex; +use tauri::{AppHandle, Emitter, Manager}; +use usahp_daemon::embedded::{EmbeddedBroker, Event, Mode, StopReason}; +#[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)?; + 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) +} +impl Controller { + fn new(app: &AppHandle) -> Self { + let load = || -> Result { + let path = path(app)?; + 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(app, &settings)?; + Ok(settings) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let settings = Settings::default(); + persist(app, &settings)?; + Ok(settings) + } + Err(e) => Err(e.to_string()), + } + } + Err(e) => Err(e.to_string()), + } + }; + 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(EmbeddedBroker::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| !usahp_daemon::embedded::supported_key(&b.key)) + .map(|b| b.key.clone()) + .collect(), + } + } + 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| usahp_core::Mapping { + id: b.id.clone(), + switch_id: b.id.clone(), + input: usahp_core::InputKind::Keyboard, + code: b.key.clone(), + device: None, + }) + .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( + usahp_daemon::embedded::HEARTBEAT_INTERVAL_MS, + )) + .await; + app.state::() + .broker + .lock() + .unwrap_or_else(|p| p.into_inner()) + .heartbeat(); + } + }); +} diff --git a/src-tauri/src/switches.rs b/src-tauri/src/switches.rs new file mode 100644 index 00000000..ce344a76 --- /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 = usahp_daemon::embedded::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..f1fc5705 --- /dev/null +++ b/src/Switches.test.tsx @@ -0,0 +1,212 @@ +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__")); +it("commits a new switch only after name and physical capture are complete", async () => { + render(); + await screen.findByText("Key: Space"); + fireEvent.click(screen.getByRole("button", { name: "Add switch" })); + 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: "Learn switch key" })); + await screen.findByRole("button", { name: "Cancel capture" }); + event({ ...current, capture: { active: false, key: "Enter", error: null } }); + await screen.findByText("Key: Enter"); + fireEvent.click(screen.getByRole("button", { name: "Save new switch" })); + await waitFor(() => expect(current.settings.bindings).toHaveLength(2)); + expect(current.settings.bindings[1]).toMatchObject({ + name: "Foot switch", + key: "Enter", + pressAction: "select", + }); +}); +it("rejects a learned duplicate without changing the existing switch", async () => { + render(); + await screen.findByText("Key: Space"); + fireEvent.click(screen.getByRole("button", { name: "Add switch" })); + fireEvent.click(screen.getByRole("button", { name: "Learn switch key" })); + 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.findByText("Key: Space"); + 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(); +}); +it("preserves failed switch edits and prevents enable until retry succeeds", async () => { + render(); + await screen.findByText("Key: Space"); + 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.findByText("Key: Space"); + 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.findByText("Key: Space"); + 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); +}); 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..5cc8a4cb 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", @@ -109,7 +98,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, 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..a030ee10 --- /dev/null +++ b/src/settings/SwitchesSection.tsx @@ -0,0 +1,361 @@ +import { useEffect, useRef, useState } from "react"; +import { + actions, + type Binding, + type SwitchAction, + type SwitchController, +} from "../scanning/useSwitches"; +import { SettingGroup, OptionGroup, secondsOptions } from "./controls"; +function ActionPicker({ + label, + value, + disabled, + onChange, +}: { + label: string; + value: SwitchAction; + disabled: boolean; + onChange: (value: SwitchAction) => void; +}) { + return ( + + ); +} +export function SwitchesSection({ + controller, + locked, +}: { + controller: SwitchController; + locked: boolean; +}) { + const { settings, state, pending, error, unsaved } = controller; + const [draft, setDraft] = useState(null); + const [target, setTarget] = useState(null); + const [formError, setFormError] = useState(null); + 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)) { + setFormError("That key already belongs to another switch."); + setTarget(null); + return; + } + if (target === "new") setDraft((d) => (d ? { ...d, key } : d)); + 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 disabled = + locked || + !state?.supported || + !!state.error || + controller.capturing || + !!state.capture.active; + const edit = (binding: Binding) => + controller.update({ + ...settings, + bindings: settings.bindings.map((b) => + b.id === binding.id ? binding : b, + ), + }); + const learn = (id: string) => { + setFormError(null); + setTarget(id); + void controller.capture(); + }; + const add = () => { + if (!draft?.name.trim() || !draft.key) { + setFormError("Enter a name and learn the switch key first."); + return; + } + controller.update({ + ...settings, + bindings: [ + ...settings.bindings, + { ...draft, id: crypto.randomUUID(), name: draft.name.trim() }, + ], + }); + setDraft(null); + }; + return ( + <> + +

+ Press and release a switch to run its normal action. Holding a switch + pauses movement and offers its hold actions in order. Release to + choose the action shown on screen. +

+

+ Escape disables switch control immediately. Holding any assigned + switch for{" "} + {(settings.bindings.some((b) => b.holdActions.length) + ? Math.max( + 4000, + (Math.max( + ...settings.bindings.map((b) => b.holdActions.length), + ) + + 2) * + settings.holdIntervalMs, + ) + : 4000) / 1000}{" "} + seconds also disables it. +

+

+ {pending + ? "Saving switches..." + : unsaved + ? "Switch assignments have unsaved changes." + : "Switch assignments save automatically."} +

+ {(error || state?.error) &&

{error || state?.error}

} + {error && unsaved && ( + + )} + {formError &&

{formError}

} + {state?.capture.error &&

{state.capture.error}

} + {(controller.capturing || state?.capture.active) && ( +
+

+ Press and release the switch you want to learn. Escape cancels. +

+ +
+ )} + {!settings.bindings.length && ( +

+ No switches added yet. Add a Select action to use automatic + scanning; manual scanning also needs Next and Previous. +

+ )} + {settings.bindings.map((binding) => ( +
+ {binding.name || "Unnamed switch"} + +

Key: {binding.key}

+ {state?.unavailableKeys.includes(binding.key) && ( +

+ This key is unavailable on this computer. Learn another key. +

+ )} + + edit({ ...binding, pressAction })} + /> +

Hold actions

+ {binding.holdActions.length === 0 && ( +

No hold actions assigned.

+ )} +
    + {binding.holdActions.map((action, index) => ( +
  1. + + edit({ + ...binding, + holdActions: binding.holdActions.map((a, i) => + i === index ? value : a, + ), + }) + } + /> + + + +
  2. + ))} +
+ + +
+ ))} + {draft ? ( +
+ Add switch + +

{draft.key ? `Key: ${draft.key}` : "No key learned yet."}

+ + setDraft({ ...draft, pressAction })} + /> + + +
+ ) : ( + + )} +
+ + + legend="Hold action interval" + disabled={disabled} + value={settings.holdIntervalMs} + onChange={(holdIntervalMs) => + controller.update({ ...settings, holdIntervalMs }) + } + options={secondsOptions([ + 250, 500, 750, 1000, 1500, 2000, 3000, 4000, 5000, + ])} + /> + + + ); +} 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(); }); From 19f37b0b3c9c60c3ea7611300f681287cb52b1f2 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 12 Sep 2026 14:46:49 +0100 Subject: [PATCH 02/13] Reject drained switch actions after capture cancellation --- src-tauri/src/scanning_runtime.rs | 23 ++++- src-tauri/src/switch_runtime.rs | 134 +++++++++++++++++++++++------- 2 files changed, 125 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index ee9efcd9..c49f80ff 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -218,11 +218,18 @@ pub fn configure( publish::
(app); Ok(c.view()) } -fn switch(app: &AppHandle, action: Action) { +fn switch(app: &AppHandle, action: Action, input_generation: u64) { let c = app.state::>(); if !c.enabled.load(Ordering::SeqCst) { return; } + 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; @@ -247,7 +254,11 @@ fn switch(app: &AppHandle, action: Action) { h.hide(); } }); - if c.enabled.load(Ordering::SeqCst) { + if c.enabled.load(Ordering::SeqCst) + && app + .state::() + .active_generation(input_generation) + { A::activate(app, point)?; } } @@ -294,7 +305,11 @@ fn tick(app: &AppHandle) { } => { let selected = { let mut d = c.data.lock().unwrap_or_else(|p| p.into_inner()); - if generation != d.input_generation { + if generation != d.input_generation + || !app + .state::() + .active_generation(generation) + { continue; } if action == usahp_core::Action::Pressed { @@ -306,7 +321,7 @@ fn tick(app: &AppHandle) { } }; if let Some(action) = selected { - switch::(app, action); + switch::(app, action, generation); } } _ => {} diff --git a/src-tauri/src/switch_runtime.rs b/src-tauri/src/switch_runtime.rs index de8ebfb4..1fea1bf6 100644 --- a/src-tauri/src/switch_runtime.rs +++ b/src-tauri/src/switch_runtime.rs @@ -39,46 +39,49 @@ fn path(app: &AppHandle) -> Result { } 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) + crate::storage::AppStorage::write_switch_settings(path, settings) } -impl Controller { - fn new(app: &AppHandle) -> Self { - let load = || -> Result { - let path = path(app)?; - match std::fs::read(&path) { +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 s: Settings = serde_json::from_slice(&bytes) - .map_err(|e| format!("Switch settings could not be read: {e}"))?; - s.validate()?; - Ok(s) + 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 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(app, &settings)?; - Ok(settings) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - let settings = Settings::default(); - persist(app, &settings)?; - Ok(settings) - } - Err(e) => Err(e.to_string()), - } + 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)), @@ -110,6 +113,14 @@ impl Controller { .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() @@ -279,3 +290,70 @@ pub fn install(app: &AppHandle) { } }); } + +#[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); + } + } +} From 89a1cf4a932872e733e1cd98cd27f5adc8068151 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 12 Sep 2026 14:50:38 +0100 Subject: [PATCH 03/13] Match switch assignment controls to Settings styling --- src/settings/SwitchesSection.tsx | 26 +++++++++++++------------- src/styles.css | 9 +++++++++ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/settings/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx index a030ee10..5cec3767 100644 --- a/src/settings/SwitchesSection.tsx +++ b/src/settings/SwitchesSection.tsx @@ -145,7 +145,7 @@ export function SwitchesSection({

{(error || state?.error) &&

{error || state?.error}

} {error && unsaved && ( - )} @@ -156,7 +156,7 @@ export function SwitchesSection({

Press and release the switch you want to learn. Escape cancels.

- - - - - + setDraft({ ...draft, pressAction })} /> - - + ) : ( - + + {(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."} +

+ +
+
+ + +
+ + ); +} + export function SwitchesSection({ controller, locked, @@ -44,8 +257,11 @@ export function SwitchesSection({ }) { const { settings, state, pending, error, unsaved } = controller; const [draft, setDraft] = useState(null); + const [expanded, setExpanded] = useState(null); const [target, setTarget] = useState(null); const [formError, setFormError] = useState(null); + const nameRef = useRef(null); + const focusName = useRef(false); const cancel = useRef(controller.cancelCapture); cancel.current = controller.cancelCapture; useEffect( @@ -68,8 +284,10 @@ export function SwitchesSection({ setTarget(null); return; } - if (target === "new") setDraft((d) => (d ? { ...d, key } : d)); - else + if (target === newId) { + setDraft((d) => (d ? { ...d, key } : d)); + focusName.current = true; + } else controller.update({ ...settings, bindings: settings.bindings.map((b) => @@ -78,12 +296,16 @@ export function SwitchesSection({ }); setTarget(null); }, [state?.capture.key, state?.capture.active, target, settings, controller]); - const disabled = - locked || - !state?.supported || - !!state.error || - controller.capturing || - !!state.capture.active; + const capturing = controller.capturing || !!state?.capture.active; + 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, @@ -91,16 +313,32 @@ export function SwitchesSection({ b.id === binding.id ? binding : b, ), }); + const remove = (id: string) => + controller.update({ + ...settings, + bindings: settings.bindings.filter((b) => b.id !== id), + }); const learn = (id: string) => { setFormError(null); setTarget(id); void controller.capture(); }; - const add = () => { - if (!draft?.name.trim() || !draft.key) { - setFormError("Enter a name and learn the switch key first."); - return; + const startAdd = () => { + setDraft({ id: newId, name: "", key: "", pressAction: "select", holdActions: [] }); + setExpanded(newId); + learn(newId); + }; + const cancelAdd = () => { + setDraft(null); + setExpanded(null); + setFormError(null); + if (target === newId) { + setTarget(null); + void controller.cancelCapture(); } + }; + const add = () => { + if (!draft?.name.trim() || !draft.key) return; controller.update({ ...settings, bindings: [ @@ -109,252 +347,246 @@ export function SwitchesSection({ ], }); setDraft(null); + setExpanded(null); }; + 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 capturePanel = (id: string) => + capturing && + target === id && ( +
+
+ ); + const rowError = (id: string) => + expanded === id ? (formError ?? state?.capture.error ?? null) : null; return ( <> -

- Press and release a switch to run its normal action. Holding a switch - pauses movement and offers its hold actions in order. Release to - choose the action shown on screen. -

-

- Escape disables switch control immediately. Holding any assigned - switch for{" "} - {(settings.bindings.some((b) => b.holdActions.length) - ? Math.max( - 4000, - (Math.max( - ...settings.bindings.map((b) => b.holdActions.length), - ) + - 2) * - settings.holdIntervalMs, - ) - : 4000) / 1000}{" "} - seconds also disables it. -

-

+ +

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

- {(error || state?.error) &&

{error || state?.error}

} - {error && unsaved && ( - + {(error || state?.error) && ( +
+ {error || state?.error} + {error && unsaved && ( + + )} +
)} - {formError &&

{formError}

} - {state?.capture.error &&

{state.capture.error}

} - {(controller.capturing || state?.capture.active) && ( -
+ {!settings.bindings.length && !draft ? ( +
+
- )} - {!settings.bindings.length && ( -

- No switches added yet. Add a Select action to use automatic - scanning; manual scanning also needs Next and Previous. -

- )} - {settings.bindings.map((binding) => ( -
- {binding.name || "Unnamed switch"} - -

Key: {binding.key}

- {state?.unavailableKeys.includes(binding.key) && ( -

- This key is unavailable on this computer. Learn another key. -

+ ) : ( +
+ {settings.bindings.map((binding) => { + const open = expanded === binding.id; + const name = binding.name || "Unnamed switch"; + const unavailable = !!state?.unavailableKeys.includes(binding.key); + return ( +
+
+
+ {open && capturePanel(binding.id)} + {open && ( + learn(binding.id)} + onDone={() => setExpanded(null)} + onRemove={() => remove(binding.id)} + nameRef={nameRef} + /> + )} +
+ ); + })} + {draft && ( +
+
+
+ {capturePanel(newId)} + learn(newId)} + onDone={add} + onRemove={cancelAdd} + nameRef={nameRef} + /> +
)} - - edit({ ...binding, pressAction })} - /> -

Hold actions

- {binding.holdActions.length === 0 && ( -

No hold actions assigned.

+ {!draft && ( +
+ +
)} -
    - {binding.holdActions.map((action, index) => ( -
  1. - - edit({ - ...binding, - holdActions: binding.holdActions.map((a, i) => - i === index ? value : a, - ), - }) - } - /> - - - -
  2. - ))} -
- - -
- ))} - {draft ? ( -
- Add switch - -

{draft.key ? `Key: ${draft.key}` : "No key learned yet."}

- - setDraft({ ...draft, pressAction })} - /> - - -
- ) : ( - +
)}
- - legend="Hold action interval" - disabled={disabled} - value={settings.holdIntervalMs} - onChange={(holdIntervalMs) => - controller.update({ ...settings, holdIntervalMs }) - } - options={secondsOptions([ - 250, 500, 750, 1000, 1500, 2000, 3000, 4000, 5000, - ])} - /> +
+ + 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/styles.css b/src/styles.css index a95b6c2e..3a08f791 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); } @@ -318,11 +318,50 @@ 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; } -.switch-assignment { min-width: 0; margin: 0; padding: 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); } -.switch-assignment legend { padding: 0 6px; font-weight: 650; } -.switch-assignment > p { margin: 12px 0; } -.switch-assignment .exact-speed { margin: 12px 0; } -.switch-assignment input { min-width: 0; width: 55%; min-height: 36px; padding: 6px 8px; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 5px; } -.switch-assignment ol { padding-left: 24px; } -.switch-assignment li { padding: 4px 0 12px; } -.switch-assignment button { margin: 0 6px 6px 0; } +/* 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-panel { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; margin: 0 8px 8px; border: 1px dashed var(--brand); border-radius: 6px; padding: 12px 14px; color: var(--on-brand-container); background: var(--brand-container); animation: capture-pulse 1.4s ease-in-out infinite; } +.capture-panel > svg { color: var(--brand); } +.capture-panel strong { display: block; font-size: 13px; } +.capture-panel p { margin-top: 2px; font-size: 12px; } +@keyframes capture-pulse { 50% { border-color: var(--brand-border); } } +@media (prefers-reduced-motion: reduce) { .capture-panel { 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; } + .capture-panel { grid-template-columns: auto 1fr; } + .capture-panel > button { grid-column: 1 / -1; } +} From cca9478072bb4a2841f98ee33dcd00c226b4f030 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 12 Sep 2026 18:32:14 +0100 Subject: [PATCH 07/13] Show a refused capture only under the key field The hook records a capture refusal as both the general error and the capture error, so the Switches tab showed the same message twice when the window was not focused. The error band now carries save errors only. Co-Authored-By: Claude Fable 5.1 --- src/settings/SwitchesSection.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/settings/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx index da9554ca..2baf7dca 100644 --- a/src/settings/SwitchesSection.tsx +++ b/src/settings/SwitchesSection.tsx @@ -255,7 +255,13 @@ export function SwitchesSection({ controller: SwitchController; locked: boolean; }) { - const { settings, state, pending, error, unsaved } = controller; + 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); From 9f433888ec7de645c25a17e7d3e8795e21e6ad4a Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 12 Sep 2026 19:10:28 +0100 Subject: [PATCH 08/13] Learn switch keys inside a modal that swallows key events 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. While learning, a dialog now holds focus on itself rather than a control and swallows every key event at the document capture phase. Escape still reaches the backend through its hotkey; Cancel capture is mouse-only. Co-Authored-By: Claude Fable 5.1 --- src/Switches.test.tsx | 20 +++++++ src/settings/SwitchesSection.tsx | 89 +++++++++++++++++++++++--------- src/styles.css | 16 +++--- 3 files changed, 94 insertions(+), 31 deletions(-) diff --git a/src/Switches.test.tsx b/src/Switches.test.tsx index df33a304..3a8f7dce 100644 --- a/src/Switches.test.tsx +++ b/src/Switches.test.tsx @@ -254,3 +254,23 @@ it("cancels learning when the panel unmounts and retains pending edits", async ( ); 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")); +}); diff --git a/src/settings/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx index 2baf7dca..b8d99d36 100644 --- a/src/settings/SwitchesSection.tsx +++ b/src/settings/SwitchesSection.tsx @@ -248,6 +248,59 @@ function SwitchEditor({ ); } +// 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(); + 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, @@ -365,31 +418,23 @@ export function SwitchesSection({ }, [isPreset]); const exactId = useId(); const listId = useId(); - const capturePanel = (id: string) => - capturing && - target === id && ( -
-
- ); + const captureName = + target === newId + ? "the new switch" + : (settings.bindings.find((b) => b.id === target)?.name ?? "this switch"); const rowError = (id: string) => expanded === id ? (formError ?? state?.capture.error ?? null) : null; return ( <> + {capturing && target && ( + { + setTarget(null); + void controller.cancelCapture(); + }} + /> + )} - {open && capturePanel(binding.id)} {open && ( - {capturePanel(newId)} svg { color: var(--brand); } -.capture-panel strong { display: block; font-size: 13px; } -.capture-panel p { margin-top: 2px; font-size: 12px; } -@keyframes capture-pulse { 50% { border-color: var(--brand-border); } } -@media (prefers-reduced-motion: reduce) { .capture-panel { animation: none; } } +.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; } @@ -362,6 +364,4 @@ button:disabled { cursor: default; opacity: 0.55; } .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; } - .capture-panel { grid-template-columns: auto 1fr; } - .capture-panel > button { grid-column: 1 / -1; } } From 39de3a132fa73e4bea781984694c6ff99e19f0a0 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 12 Sep 2026 19:10:28 +0100 Subject: [PATCH 09/13] Check the foreground window before learning on Windows 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. After the first click inside the page is_focused stays false while the app is plainly in front, so begin_switch_capture refused every mouse user with "Focus Switchify PC before learning a switch." Compare the window against GetForegroundWindow instead; macOS keeps is_focused. Co-Authored-By: Claude Fable 5.1 --- docs/switches.md | 4 ++-- src-tauri/src/lib.rs | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/switches.md b/docs/switches.md index eda307e3..6a0bf616 100644 --- a/docs/switches.md +++ b/docs/switches.md @@ -1,6 +1,6 @@ # 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. Learning ends when Settings loses focus, the panel closes, or Escape is pressed. +In Settings → Switches, each switch is a row showing its name, key and actions; Edit expands it. Add switch starts learning immediately: press and release the physical key, then enter a name and choose actions, then save. Learning can also be restarted from Change key. Assign Select, Next, Previous, Reverse direction, Stop scanning or Pause / resume. Each key belongs to one switch; several switches can run the same action. Existing assignments save automatically. 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 previews 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. @@ -12,7 +12,7 @@ Escape disables capture immediately. The emergency hold timeout is at least 4 se 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. +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. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fd3b8965..6148a35a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1224,6 +1224,24 @@ 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()); @@ -1260,7 +1278,7 @@ fn begin_switch_capture( if app.state::().view().enabled { return Err("Disable scanning before learning a switch.".into()); } - if !window.is_focused().map_err(|e| e.to_string())? { + if !main_window_focused(&window)? { return Err("Focus Switchify PC before learning a switch.".into()); } point_scan_prepare(&app)?; From 2cabfb151ed18bfc123d230690d9c7fc9129a280 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 12 Sep 2026 19:42:17 +0100 Subject: [PATCH 10/13] Tie key errors to their row and keep focus after collapsing A capture that ended without a key left its target set, so a later view carrying the backend's remembered key could assign it to the refused row or raise a false duplicate. The target is now released whenever a capture ends keyless, and the error is recorded against the row that was learning rather than shown under whichever row is open next. Done, Save switch, Cancel and Remove unmounted the clicked button and dropped focus to the page; focus now returns to the row's Edit button or to Add switch. Edit gains aria-controls, the capture dialog links its body text and says Cancel is mouse-only, and an unavailable key is announced in the collapsed row instead of shown by colour alone. Co-Authored-By: Claude Fable 5.1 --- src/Switches.test.tsx | 61 ++++++++++++++++++++++++++++ src/settings/SwitchesSection.tsx | 70 +++++++++++++++++++++++++------- 2 files changed, 117 insertions(+), 14 deletions(-) diff --git a/src/Switches.test.tsx b/src/Switches.test.tsx index 3a8f7dce..58de7735 100644 --- a/src/Switches.test.tsx +++ b/src/Switches.test.tsx @@ -274,3 +274,64 @@ it("holds focus in a modal and swallows keys while learning", async () => { 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/); +}); diff --git a/src/settings/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx index b8d99d36..9fb7a89a 100644 --- a/src/settings/SwitchesSection.tsx +++ b/src/settings/SwitchesSection.tsx @@ -65,6 +65,7 @@ function KeyBadge({ value, unavailable }: { value: string; unavailable: boolean return value ? ( {value} + {unavailable && (unavailable on this computer)} ) : ( No key yet @@ -72,6 +73,7 @@ function KeyBadge({ value, unavailable }: { value: string; unavailable: boolean } function SwitchEditor({ + id, binding, isNew, disabled, @@ -84,6 +86,7 @@ function SwitchEditor({ onRemove, nameRef, }: { + id?: string; binding: Binding; isNew: boolean; disabled: boolean; @@ -108,7 +111,7 @@ function SwitchEditor({ onChange({ ...binding, holdActions }); }; return ( -
+