From c3800279ed77097ff9d0efd93a8a5967d4df5acb Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 14 Sep 2026 17:28:13 +0100 Subject: [PATCH 1/4] Fold remote switches into the switch list and own their hold timing Switches forwarded from Switchify Remote were a separate panel of eight numbered slots with its own Save button and a reload-profiles chore after every change. They are now rows in the same list with the same editor: names, press action, ordered hold actions and the timing note. Add remote switch takes the next free number and the number can be changed in the editor. Edits save as they happen. The backend applies saved remote assignments, the shared hold interval and a mode change to a live session on the next press instead of ending it, and only ends the session when the mode's required actions are gone. Session start no longer rejects a stale profile revision, since the PC applies its current assignments and Remote's labels are only informational. Holding a remote switch past the emergency limit resets the scan and keeps the session, as the local emergency hold does. Slots gain an optional name; existing files load unchanged. Co-Authored-By: Claude Fable 5.1 --- docs/point-scan.md | 4 +- src-tauri/src/lib.rs | 9 +- src-tauri/src/point_scan_runtime.rs | 6 +- src-tauri/src/remote_scan.rs | 87 +++++++- src-tauri/src/scanning_runtime.rs | 4 +- src/Switches.test.tsx | 60 ++++++ src/scanning/useRemoteSwitches.ts | 94 +++++++++ src/settings/RemoteSwitches.test.tsx | 27 --- src/settings/RemoteSwitches.tsx | 43 ---- src/settings/SwitchesSection.tsx | 284 ++++++++++++++++++++++----- src/styles.css | 4 +- 11 files changed, 490 insertions(+), 132 deletions(-) create mode 100644 src/scanning/useRemoteSwitches.ts delete mode 100644 src/settings/RemoteSwitches.test.tsx delete mode 100644 src/settings/RemoteSwitches.tsx diff --git a/docs/point-scan.md b/docs/point-scan.md index af57dd36..a52631b2 100644 --- a/docs/point-scan.md +++ b/docs/point-scan.md @@ -52,8 +52,8 @@ The action menu uses a fixed grid of square icon tiles with labels beneath the a ## Remote scanning -Select **Switchify scanning** on Switchify Remote's Android Forwarding screen. Assign up to eight numbered remote slots under PC Settings → Switches → Remote switches. Defaults are Select, Next, Previous, Pause/resume, Reverse and Stop, with two unassigned slots. Remote uses the PC scan mode, colour, movement and hold timing; local keyboard assignments are separate. Manual mode requires connected slots covering Select, Next and Previous. +Select **Switchify scanning** on Switchify Remote's Android Forwarding screen. Remote switches live in the same list as keyboard switches under PC Settings → Switches: Add remote switch takes the next free number, and the number can be changed in the editor. Numbers match the switches shown on the Forwarding screen, up to eight. Defaults are Select, Next, Previous, Pause/resume, Reverse and Stop in switches one to six. Remote switches can be named and use the PC scan mode, colour, movement and hold timing; keyboard assignments are separate. Manual mode requires connected switches covering Select, Next and Previous. -Start forwarding, then press Select to begin. Local switch keys are inactive during the remote session; PC Escape remains an emergency stop. Forwarding's hold-to-stop and inactivity limits take precedence over hold actions. Saving remote assignments or shared scanner settings stops the session and changes the profile revision. Reload profiles before starting again. +Start forwarding, then press Select to begin. Local switch keys are inactive during the remote session; PC Escape remains an emergency stop. The PC owns hold timing: hold actions work exactly as for keyboard switches, and holding a remote switch past the emergency limit resets the scan without ending the session. Edits to remote switches, shared scanner settings or hold timing apply to a live session on the next press; a change that leaves the current mode without its required actions ends the session. The PC no longer rejects a stale profile revision, so Remote only needs to reload profiles to refresh its labels. Cancelled edges, missing edges and sync mismatches never select. Held-state mismatches cancel the gesture and require neutral input. Sessions expire after five seconds without an authenticated edge or sync. Disconnects and safety stops discard the point and release drag buttons; start explicitly again. Ordinary forwarding and remote scanning cannot own input simultaneously. Local scanning resumes after Remote disconnects. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 50faf926..3eac370a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1266,7 +1266,6 @@ fn save_remote_switches( config: remote_scan::Config, ) -> Result { require_main(&window)?; - point_scan_runtime::pause(&app); remote_scan::save(&app, config) } #[tauri::command] @@ -1286,9 +1285,11 @@ fn save_switches( require_main(&window)?; // Sync commands run on the main thread, which pausing the overlay needs. point_scan_runtime::pause(&app); - remote_scan::save(&app, remote_scan::config(&app))?; - app.state::() - .save(&app, settings) + let view = app + .state::() + .save(&app, settings)?; + remote_scan::apply(&app); + Ok(view) } #[tauri::command] fn begin_switch_capture( diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 07713c8e..6d02f0e4 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -20,8 +20,10 @@ pub fn configure(app: &AppHandle, config: Config) -> Result { if config.switches().keys() != previous.switches().keys() { return Err("Edit key assignments in Settings → Switches.".into()); } - crate::remote_scan::save(app, crate::remote_scan::config(app))?; - scanning_runtime::configure::(app, config) + let view = scanning_runtime::configure::(app, config)?; + // A mode change alters which remote assignments a live session needs. + crate::remote_scan::apply(app); + Ok(view) } pub fn pause(app: &AppHandle) { scanning_runtime::pause::(app); diff --git a/src-tauri/src/remote_scan.rs b/src-tauri/src/remote_scan.rs index f8d5a902..197293fd 100644 --- a/src-tauri/src/remote_scan.rs +++ b/src-tauri/src/remote_scan.rs @@ -19,6 +19,18 @@ const MAX_EVENTS: usize = 64; pub struct Slot { pub press_action: Option, pub hold_actions: Vec, + /// User-facing name; falls back to "Remote switch N". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +} +impl Slot { + pub fn display_name(&self, index: usize) -> String { + self.name + .as_deref() + .map(str::trim) + .filter(|n| !n.is_empty()) + .map_or_else(|| format!("Remote switch {}", index + 1), str::to_string) + } } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -46,6 +58,7 @@ impl Default for Config { .map(|press_action| Slot { press_action, hold_actions: vec![], + name: None, }) .collect(), } @@ -57,7 +70,8 @@ impl Config { || self.revision == 0 || self.slots.len() != 8 || self.slots.iter().any(|s| { - s.hold_actions.len() > 32 + s.name.as_ref().is_some_and(|n| n.chars().count() > 64) + || s.hold_actions.len() > 32 || s.press_action == Some(Action::Cancel) || s.hold_actions.contains(&Action::Cancel) || (s.press_action.is_none() && !s.hold_actions.is_empty()) @@ -79,7 +93,7 @@ impl Config { .filter_map(|(i, s)| { Some(Binding { id: (i + 1).to_string(), - name: format!("Remote switch {}", i + 1), + name: s.display_name(i), key: format!("F{}", i + 1), press_action: s.press_action?, hold_actions: s.hold_actions.clone(), @@ -92,6 +106,7 @@ impl Config { json!({"id":PROFILE_ID,"name":"Switchify scanning","version":self.revision,"kind":"scanning","bindings":self.slots.iter().enumerate().map(|(i,s)| { let mut label = s.press_action.map_or("Unassigned", Action::label).to_string(); if !s.hold_actions.is_empty() { label.push_str("; hold: "); label.push_str(&s.hold_actions.iter().map(|a| a.label()).collect::>().join(", ")); } + if s.press_action.is_some() && s.name.as_deref().is_some_and(|n| !n.trim().is_empty()) { label = format!("{}: {label}", s.display_name(i)); } json!({"switchId":i+1,"label":label,"behavior":if s.press_action.is_some(){"stateful"}else{"unassigned"}}) }).collect::>()}) } @@ -156,9 +171,6 @@ impl Mailbox { .as_u64() .filter(|n| (1..=8).contains(n)) .ok_or("Invalid remote switch count")? as u8; - if payload["profileVersion"].as_u64() != Some(self.config.revision.into()) { - return Err("The scanning profile changed. Reload profiles and start again.".into()); - } let id = payload["sessionId"] .as_str() .filter(|id| uuid::Uuid::parse_str(id).is_ok()) @@ -178,6 +190,20 @@ impl Mailbox { }); Ok(()) } + /// Applies the current config to a live session so edits take effect on the + /// next press instead of ending the session. A config the mode can no longer + /// use ends it, since scanning could not continue anyway. + fn apply(&mut self, interval: u64, automatic: bool) { + let Some(session) = self.session.as_mut() else { + return; + }; + let settings = self.config.settings(session.count as usize, interval); + if settings.validate_actions(automatic).is_ok() { + session.settings = settings; + } else { + self.stop(); + } + } fn accept( &mut self, device: &str, @@ -308,10 +334,31 @@ pub fn save(app: &AppHandle, mut config: Config) -> Result { serde_json::to_vec_pretty(&config).map_err(|e| e.to_string())?, ) .map_err(|e| e.to_string())?; - data.stop(); data.config = config.clone(); + drop(data); + apply(app); Ok(config) } +/// Re-derives a live session's settings from the saved remote config, the +/// shared hold interval and the current scan mode. +pub fn apply(app: &AppHandle) { + let Some(c) = app.try_state::() else { + return; + }; + let interval = app + .state::() + .settings() + .hold_interval_ms; + let automatic = app + .state::() + .view() + .config + .automatic; + c.data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .apply(interval, automatic); +} pub fn cancel(app: &AppHandle) { if let Some(c) = app.try_state::() { c.data.lock().unwrap_or_else(|p| p.into_inner()).stop(); @@ -488,7 +535,16 @@ mod tests { m.start("peer", &start(3), 1000, false, 0).unwrap(); let mut changed = start(3); changed["profileVersion"] = json!(2); - assert!(m.start("peer", &changed, 1000, true, 0).is_err()); + m.start("peer", &changed, 1000, true, 0).unwrap(); + m.config.slots[0].name = Some("Head switch".into()); + assert_eq!(m.config.settings(1, 1000).bindings[0].name, "Head switch"); + assert!(m.config.profile()["bindings"][0]["label"] + .as_str() + .unwrap() + .starts_with("Head switch: Select")); + m.config.slots[0].name = Some("x".repeat(65)); + assert!(m.config.validate().is_err()); + m.config.slots[0].name = None; serde_json::from_slice::(&serde_json::to_vec(&m.config).unwrap()) .unwrap() .validate() @@ -560,6 +616,23 @@ mod tests { assert!(m.queue.is_empty()); } #[test] + fn applying_config_updates_a_live_session_or_ends_an_unusable_one() { + let mut m = ready(); + m.config.slots[0].hold_actions = vec![Action::Stop]; + m.apply(1000, true); + assert_eq!( + m.session.as_ref().unwrap().settings.bindings[0].hold_actions, + vec![Action::Stop] + ); + m.config.slots[0].press_action = Some(Action::Next); + for slot in &mut m.config.slots[1..] { + slot.press_action = None; + slot.hold_actions.clear(); + } + m.apply(1000, true); + assert!(m.session.is_none(), "no Select left, so the session ends"); + } + #[test] fn remote_edges_use_shared_release_and_hold_gestures() { let mut m = ready(); m.config.slots[0].hold_actions = vec![Action::Next]; diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 7b553a8b..46d497b8 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -535,7 +535,9 @@ fn tick(app: &AppHandle) { .is_some_and(|start| now_ms.saturating_sub(start) >= settings.escape_ms()) }; if expired { - disable::(app, "Remote switch held. Start forwarding again."); + // Mirror the local emergency hold: reset the scan, keep the + // session. The next tick restarts remote scanning in place. + reset_scanner::(app, "Switch held too long. Scan reset."); return; } } diff --git a/src/Switches.test.tsx b/src/Switches.test.tsx index 556aff6c..287f7d86 100644 --- a/src/Switches.test.tsx +++ b/src/Switches.test.tsx @@ -35,6 +35,14 @@ const initial: SwitchState = { unavailableKeys: [], }; let current: SwitchState; +type RemoteSlotFixture = { pressAction: string | null; holdActions: string[]; name?: string }; +const emptySlot: RemoteSlotFixture = { pressAction: null, holdActions: [] }; +const initialRemote: { schemaVersion: number; revision: number; slots: RemoteSlotFixture[] } = { + schemaVersion: 1, + revision: 1, + slots: [{ pressAction: "select", holdActions: [] }, ...Array.from({ length: 7 }, () => emptySlot)], +}; +let remote: typeof initialRemote; const scan = { config: defaultPointScanConfig, enabled: false, @@ -62,6 +70,7 @@ function event(next: SwitchState) { } beforeEach(() => { current = structuredClone(initial); + remote = structuredClone(initialRemote); Object.defineProperty(window, "__TAURI_INTERNALS__", { configurable: true, value: {}, @@ -86,6 +95,11 @@ beforeEach(() => { capture: { active: false, key: null, error: null }, }; return current; + case "get_remote_switches": + return structuredClone(remote); + case "save_remote_switches": + remote = { ...args.config, revision: remote.revision + 1 }; + return remote; case "get_point_scan": return scan; case "configure_point_scan": @@ -331,3 +345,49 @@ it("keeps focus in the draft when another switch is removed while adding", async await waitFor(() => expect(current.settings.bindings).toHaveLength(0)); expect(document.activeElement).toBe(screen.getByLabelText("New switch name")); }); +it("lists remote switches with local ones and edits them in place", async () => { + render(); + await screen.findByRole("heading", { name: "Remote switch 1" }); + expect(screen.getByText("Remote 1")).toBeTruthy(); + open("Remote switch 1"); + expect(screen.queryByRole("button", { name: /Learn another key/ })).toBeNull(); + fireEvent.change(screen.getByLabelText("Normal action for Remote switch 1"), { target: { value: "next" } }); + await waitFor(() => expect(remote.slots[0].pressAction).toBe("next")); + fireEvent.change(screen.getByLabelText("Name for Remote 1"), { target: { value: "Chin" } }); + await waitFor(() => expect(remote.slots[0].name).toBe("Chin")); + await screen.findByRole("heading", { name: "Chin" }); + fireEvent.click(screen.getByRole("button", { name: "Add hold action for Chin" })); + await waitFor(() => expect(remote.slots[0].holdActions).toEqual(["next"])); + expect(mocks.invoke.mock.calls.some(([c]) => c === "save_switches")).toBe(false); +}); +it("adds a remote switch into the next free slot and can move it", async () => { + render(); + await screen.findByRole("heading", { name: "Remote switch 1" }); + fireEvent.click(screen.getByRole("button", { name: "Add remote switch" })); + expect(screen.queryByRole("dialog")).toBeNull(); + const slot = screen.getByLabelText("Remote switch number for Remote switch 2"); + expect(slot).toHaveValue("1"); + fireEvent.change(slot, { target: { value: "3" } }); + fireEvent.click(screen.getByRole("button", { name: "Save switch" })); + await waitFor(() => expect(remote.slots[3].pressAction).toBe("select")); + expect(remote.slots[1].pressAction).toBeNull(); + await screen.findByRole("heading", { name: "Remote switch 4" }); + open("Remote switch 4"); + fireEvent.change(screen.getByLabelText("Remote switch number for Remote switch 4"), { target: { value: "1" } }); + await waitFor(() => expect(remote.slots[1].pressAction).toBe("select")); + expect(remote.slots[3].pressAction).toBeNull(); + expect(screen.getByRole("button", { name: "Close Remote switch 2" })).toBeTruthy(); +}); +it("removes a remote switch and surfaces a failed remote save with retry", async () => { + render(); + await screen.findByRole("heading", { name: "Remote switch 1" }); + mocks.invoke.mockImplementationOnce(async () => { + throw "Remote save failed"; + }); + fireEvent.click(screen.getByRole("button", { name: "Remove Remote switch 1" })); + await screen.findByText("Remote save failed"); + expect(remote.slots[0].pressAction).toBe("select"); + fireEvent.click(screen.getByRole("button", { name: "Retry save" })); + await waitFor(() => expect(remote.slots[0].pressAction).toBeNull()); + expect(screen.queryByRole("heading", { name: "Remote switch 1" })).toBeNull(); +}); diff --git a/src/scanning/useRemoteSwitches.ts b/src/scanning/useRemoteSwitches.ts new file mode 100644 index 00000000..ac729f63 --- /dev/null +++ b/src/scanning/useRemoteSwitches.ts @@ -0,0 +1,94 @@ +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import type { SwitchAction } from "./useSwitches"; + +// Switches forwarded from Switchify Remote. Slots are positional: slot N is +// the Nth switch on the phone's Forwarding screen. The backend applies saved +// changes to a live remote session, so edits save as they happen, like keys. +export type RemoteSlot = { + pressAction: SwitchAction | null; + holdActions: SwitchAction[]; + name?: string; +}; +export type RemoteConfig = { + schemaVersion: number; + revision: number; + slots: RemoteSlot[]; +}; +export const remoteSlotCount = 8; + +export function useRemoteSwitches() { + const [config, setConfig] = useState(null); + const [pending, setPending] = useState(0); + const [error, setError] = useState(null); + const model = useRef({ + config: null as RemoteConfig | null, + revision: 0, + saved: 0, + pending: 0, + }); + const queue = useRef(Promise.resolve()); + useEffect(() => { + let alive = true; + if (!("__TAURI_INTERNALS__" in window)) return; + void invoke("get_remote_switches") + .then((value) => { + if (!alive) return; + // A save that raced the load wins; it already holds newer slots. + if (model.current.revision === 0) { + model.current.config = value; + setConfig(value); + } + }) + .catch((e) => { + if (alive) setError(String(e)); + }); + return () => { + alive = false; + }; + }, []); + const save = (revision: number, next: RemoteConfig) => { + const m = model.current; + m.pending++; + setPending(m.pending); + queue.current = queue.current + .then(async () => { + if (revision !== m.revision) return; + setError(null); + const result = await invoke("save_remote_switches", { + config: next, + }); + m.saved = revision; + if (m.config) { + m.config = { ...m.config, revision: result.revision }; + setConfig(m.config); + } + }) + .catch((e) => setError(String(e))) + .finally(() => { + m.pending--; + setPending(m.pending); + }); + }; + const update = (slots: RemoteSlot[]) => { + const m = model.current; + if (!m.config) return; + const next = { ...m.config, slots }; + m.config = next; + m.revision++; + setConfig(next); + save(m.revision, next); + }; + return { + config, + pending, + error, + update, + retry: () => { + if (model.current.config) + save(model.current.revision, model.current.config); + }, + unsaved: model.current.revision !== model.current.saved, + }; +} +export type RemoteSwitchController = ReturnType; diff --git a/src/settings/RemoteSwitches.test.tsx b/src/settings/RemoteSwitches.test.tsx deleted file mode 100644 index b0f796c4..00000000 --- a/src/settings/RemoteSwitches.test.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, expect, it, vi } from "vitest"; -import { RemoteSwitches } from "./RemoteSwitches"; -const invoke = vi.hoisted(() => vi.fn()); -vi.mock("@tauri-apps/api/core", () => ({ invoke })); -const config = { schemaVersion: 1, revision: 1, slots: Array.from({ length: 8 }, (_, i) => ({ pressAction: i === 0 ? "select" : null, holdActions: [] })) }; -beforeEach(() => { Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true }); invoke.mockReset(); invoke.mockImplementation(async (command, args) => command === "get_remote_switches" ? structuredClone(config) : { ...args.config, revision: 2 }); }); -it("edits remote slots independently and saves ordered holds", async () => { - render(); - const action = await screen.findByLabelText("Remote switch 1 action"); - fireEvent.change(action, { target: { value: "next" } }); - fireEvent.click(screen.getByText("Add hold action for remote switch 1")); - fireEvent.change(screen.getByLabelText("Remote switch 1 hold action 1"), { target: { value: "select" } }); - fireEvent.click(screen.getByText("Save remote switches")); - await screen.findByRole("status"); - expect(invoke).toHaveBeenCalledWith("save_remote_switches", { config: expect.objectContaining({ slots: expect.arrayContaining([{ pressAction: "next", holdActions: ["select"] }]) }) }); - expect(invoke.mock.calls.some(([command]) => command === "save_switches")).toBe(false); -}); -it("retains edits on save failure and permits retry", async () => { - render(); await screen.findByLabelText("Remote switch 1 action"); - invoke.mockRejectedValueOnce(new Error("Save failed")); - fireEvent.click(screen.getByText("Save remote switches")); - await screen.findByRole("alert"); - await waitFor(() => expect(screen.getByText("Save remote switches")).not.toBeDisabled()); - fireEvent.click(screen.getByText("Save remote switches")); - await screen.findByRole("status"); -}); diff --git a/src/settings/RemoteSwitches.tsx b/src/settings/RemoteSwitches.tsx deleted file mode 100644 index 6c9cd674..00000000 --- a/src/settings/RemoteSwitches.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useEffect, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; -import { actions, type SwitchAction } from "../scanning/useSwitches"; -import { SettingGroup } from "./controls"; -type Slot = { pressAction: SwitchAction | null; holdActions: SwitchAction[] }; -type Config = { schemaVersion: number; revision: number; slots: Slot[] }; -export function RemoteSwitches() { - const [config, setConfig] = useState(null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [saved, setSaved] = useState(false); - useEffect(() => { - let alive = true; - if (!("__TAURI_INTERNALS__" in window)) return; - void invoke("get_remote_switches").then((value) => { if (alive) setConfig(value); }).catch((e) => { if (alive) setError(String(e)); }); - return () => { alive = false; }; - }, []); - const update = (index: number, slot: Slot) => { if (config) setConfig({ ...config, slots: config.slots.map((s, i) => i === index ? slot : s) }); setSaved(false); }; - const save = async () => { - if (!config) return; - setBusy(true); setError(null); setSaved(false); - try { setConfig(await invoke("save_remote_switches", { config })); setSaved(true); } - catch (e) { setError(String(e)); } - finally { setBusy(false); } - }; - return -

Remote owns scanning while forwarding. PC Escape stops it. Remote’s hold-to-stop and inactivity limits take priority over hold actions. Saving stops an active remote scan.

- {error &&

{error}

} - {config &&
- {config.slots.map((slot, index) =>
Remote switch {index + 1}: {slot.pressAction ? actions[slot.pressAction] : "Unassigned"} - - {slot.holdActions.map((action, hold) =>
- -
)} - -
)} - -
} - {saved &&

Remote switches saved. Reload profiles in Remote before starting again.

} -
; -} diff --git a/src/settings/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx index 7bb9dab3..0a4f03f3 100644 --- a/src/settings/SwitchesSection.tsx +++ b/src/settings/SwitchesSection.tsx @@ -1,6 +1,6 @@ -import { RemoteSwitches } from "./RemoteSwitches"; import { useEffect, useId, useRef, useState } from "react"; -import { ChevronDown, ChevronUp, Keyboard, Trash2, X } from "lucide-react"; +import { ChevronDown, ChevronUp, Keyboard, Smartphone, Trash2, X } from "lucide-react"; +import { useRemoteSwitches, type RemoteSlot } from "../scanning/useRemoteSwitches"; import { actions, type Binding, @@ -86,6 +86,7 @@ function SwitchEditor({ onDone, onRemove, nameRef, + remote, }: { id?: string; binding: Binding; @@ -99,10 +100,14 @@ function SwitchEditor({ onDone: () => void; onRemove: () => void; nameRef: React.RefObject; + /** Present for a switch forwarded from Switchify Remote: its slot and the + * slots it could move to. Remote switches have a default name, so a new + * one can be saved unnamed. */ + remote?: { slot: number; free: number[]; onSlot: (slot: number) => void }; }) { - const name = binding.name || "this switch"; + const name = binding.name || (remote ? `Remote switch ${remote.slot + 1}` : "this switch"); const keyErrorId = useId(); - const canSave = !isNew || (!!binding.name.trim() && !!binding.key); + const canSave = !isNew || (!!binding.key && (!!binding.name.trim() || !!remote)); const move = (index: number, delta: number) => { const holdActions = [...binding.holdActions]; [holdActions[index], holdActions[index + delta]] = [ @@ -124,27 +129,46 @@ function SwitchEditor({ onChange={(e) => onChange({ ...binding, name: e.target.value })} /> -
- Key -
- - +
+ {(keyError || unavailable) && ( + + {keyError ?? + "This key is unavailable on this computer. Learn another key."} + + )}
- {(keyError || unavailable) && ( - - {keyError ?? - "This key is unavailable on this computer. Learn another key."} - - )} - + )}
(app); } -/// Pauses scanning so switches can be saved or learned. Must run on the main -/// thread, as the commands that call it do; the tick loop re-arms afterwards. +/// Pauses scanning so settings can be saved. Must run on the main thread, as +/// the commands that call it do. A live remote session survives: only the +/// scanner and the local key broker reset, and the next tick restarts the +/// session with the freshly applied settings. pub fn pause(app: &AppHandle) { - disable::(app, "Scanning paused while switches change."); + reset_scanner::(app, "Scanning paused while switches change."); +} +/// Stops scanning outright, ending any remote session, so the local key +/// broker is free for learning a key. +pub fn interrupt(app: &AppHandle) { + disable::(app, "Scanning paused while a switch is learned."); } /// Saves new settings and re-arms scanning with them. A failure to arm is not /// an error here: the settings are saved and the view's message says why @@ -221,7 +228,7 @@ pub fn configure( ) -> Result::Phase>, String> { A::validate(&config)?; let path = config_path::(app)?; - disable::(app, "Applying scanning settings..."); + reset_scanner::(app, "Applying scanning settings..."); let save = || -> Result<(), String> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; diff --git a/src/Switches.test.tsx b/src/Switches.test.tsx index aef9f0e5..11c0d502 100644 --- a/src/Switches.test.tsx +++ b/src/Switches.test.tsx @@ -377,6 +377,15 @@ it("adds a remote switch into the next free slot and can move it", async () => { await waitFor(() => expect(remote.slots[1].pressAction).toBe("select")); expect(remote.slots[3].pressAction).toBeNull(); expect(screen.getByRole("button", { name: "Close Remote switch 2" })).toBeTruthy(); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "Close Remote switch 2" })); +}); +it("moves focus into the remote draft and back to a usable Add button", async () => { + render(); + await screen.findByRole("heading", { name: "Remote switch 1" }); + fireEvent.click(screen.getByRole("button", { name: "Add remote switch" })); + expect(document.activeElement).toBe(screen.getByLabelText("New switch name")); + fireEvent.click(screen.getByRole("button", { name: "Cancel new switch" })); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "Add switch" })); }); it("removes a remote switch and surfaces a failed remote save with retry", async () => { render(); diff --git a/src/scanning/useRemoteSwitches.ts b/src/scanning/useRemoteSwitches.ts index ac729f63..3fad7687 100644 --- a/src/scanning/useRemoteSwitches.ts +++ b/src/scanning/useRemoteSwitches.ts @@ -34,11 +34,8 @@ export function useRemoteSwitches() { void invoke("get_remote_switches") .then((value) => { if (!alive) return; - // A save that raced the load wins; it already holds newer slots. - if (model.current.revision === 0) { - model.current.config = value; - setConfig(value); - } + model.current.config = value; + setConfig(value); }) .catch((e) => { if (alive) setError(String(e)); diff --git a/src/settings/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx index 25f7c6dd..ed91ddc3 100644 --- a/src/settings/SwitchesSection.tsx +++ b/src/settings/SwitchesSection.tsx @@ -378,6 +378,8 @@ export function SwitchesSection({ controller, onDraftChange, suspended = false } i === to ? slots[from] : i === from ? { pressAction: null, holdActions: [] } : s, ), ); + // The row is keyed by its number, so it remounts; keep focus on it. + focusAfter.current = remoteId(to); setExpanded(remoteId(to)); }; const [expanded, setExpanded] = useState(null); @@ -392,15 +394,18 @@ export function SwitchesSection({ controller, onDraftChange, suspended = false } // the page. Remember where focus should land and move it after the render. const editRefs = useRef(new Map()); const addRef = useRef(null); + const addRemoteRef = useRef(null); const focusAfter = useRef(null); useEffect(() => { if (!focusAfter.current) return; const id = focusAfter.current; focusAfter.current = null; - // While a new switch is being drafted the Add switch button is not rendered, - // so a removal elsewhere hands focus to the draft's name field instead. + // Fall through past controls that are unmounted or disabled: while a new + // switch is being drafted the Add buttons are not rendered, so focus lands + // on the draft's name field instead. const target = id === newId ? null : editRefs.current.get(id); - (target ?? addRef.current ?? nameRef.current)?.focus(); + const enabled = (el: HTMLElement | null) => (el && !(el as HTMLButtonElement).disabled ? el : null); + (target ?? enabled(addRef.current) ?? enabled(addRemoteRef.current) ?? nameRef.current)?.focus(); }); const cancel = useRef(controller.cancelCapture); cancel.current = controller.cancelCapture; @@ -485,6 +490,8 @@ export function SwitchesSection({ controller, onDraftChange, suspended = false } setDraftSlot(slot); setDraft({ id: newId, name: "", key: `Remote ${slot + 1}`, pressAction: "select", holdActions: [] }); setExpanded(newId); + // The Add buttons unmount while drafting; the name field takes focus. + focusAfter.current = newId; }; const cancelAdd = () => { setDraft(null); @@ -519,7 +526,10 @@ export function SwitchesSection({ controller, onDraftChange, suspended = false } setExpanded(null); focusAfter.current = newId; }; - const escapeMs = escapeHoldMs(settings.bindings, settings.holdIntervalMs); + const escapeMs = escapeHoldMs( + [...settings.bindings, ...remoteRows.map(({ slot, index }) => remoteBinding(slot, index))], + settings.holdIntervalMs, + ); const isPreset = (holdIntervalPresets as readonly number[]).includes( settings.holdIntervalMs, ); @@ -610,6 +620,7 @@ export function SwitchesSection({ controller, onDraftChange, suspended = false }