From 3088406b12de8239ae34771fa38225eb5889f52a Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 21 Sep 2026 15:25:56 +0100 Subject: [PATCH 1/2] fix: allow explicit keyboard entry for switch names (#823) --- src-tauri/build.rs | 1 + src-tauri/capabilities/main.json | 1 + .../set_switch_keyboard_entry.toml | 11 +++ src-tauri/src/lib.rs | 31 ++++++++ src-tauri/src/switch_input/mod.rs | 18 +++++ src-tauri/src/switch_runtime.rs | 58 +++++++++++++++ src/App.test.tsx | 2 +- src/Switches.test.tsx | 71 +++++++++++++++++++ src/scanning/useSwitches.ts | 23 ++++++ src/settings/SwitchesSection.tsx | 63 ++++++++++++++++ 10 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 src-tauri/permissions/autogenerated/set_switch_keyboard_entry.toml diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 039659d7..72f2c25a 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -11,6 +11,7 @@ fn main() { "save_switches", "begin_switch_capture", "cancel_switch_capture", + "set_switch_keyboard_entry", "get_point_scan", "configure_point_scan", "check_accessibility", diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index 97c7e611..474820ed 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -12,6 +12,7 @@ "allow-save-switches", "allow-begin-switch-capture", "allow-cancel-switch-capture", + "allow-set-switch-keyboard-entry", "allow-get-point-scan", "allow-configure-point-scan", "allow-check-accessibility", diff --git a/src-tauri/permissions/autogenerated/set_switch_keyboard_entry.toml b/src-tauri/permissions/autogenerated/set_switch_keyboard_entry.toml new file mode 100644 index 00000000..51425b73 --- /dev/null +++ b/src-tauri/permissions/autogenerated/set_switch_keyboard_entry.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-set-switch-keyboard-entry" +description = "Enables the set_switch_keyboard_entry command without any pre-configured scope." +commands.allow = ["set_switch_keyboard_entry"] + +[[permission]] +identifier = "deny-set-switch-keyboard-entry" +description = "Denies the set_switch_keyboard_entry command without any pre-configured scope." +commands.deny = ["set_switch_keyboard_entry"] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1c0cd825..132f3ed9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1322,6 +1322,31 @@ fn cancel_switch_capture( Ok(controller.view()) } +#[tauri::command] +fn set_switch_keyboard_entry( + window: tauri::WebviewWindow, + app: AppHandle, + active: bool, +) -> Result { + require_main(&window)?; + let switches = app.state::(); + if active { + if !main_window_focused(&window)? { + return Err("Focus Switchify PC before typing with the keyboard.".into()); + } + if remote_scan::active(&app) { + return Err("Stop forwarding on Remote before typing with the keyboard.".into()); + } + } + switches.set_keyboard_entry(active)?; + if active { + point_scan_runtime::interrupt(&app); + } + let view = switches.view(); + let _ = app.emit("switches-changed", &view); + Ok(view) +} + #[tauri::command] fn get_point_scan( controller: State<'_, point_scan_runtime::Controller>, @@ -1346,6 +1371,11 @@ async fn configure_point_scan( /// Pure environment check, safe to call every tick while scanning is off. fn point_scan_ready(app: &AppHandle) -> Result<(), String> { + if app.state::().keyboard_entry() { + return Err( + "Typing with the keyboard. Resume switch control in Switches when finished.".into(), + ); + } let state = app.state::().snapshot(); if state.bluetooth == state::BluetoothState::Connected && !remote_scan::active(app) { return Err("Local scanning pauses while Android is connected.".into()); @@ -1507,6 +1537,7 @@ pub fn run() { save_switches, begin_switch_capture, cancel_switch_capture, + set_switch_keyboard_entry, get_point_scan, configure_point_scan, get_app_state, diff --git a/src-tauri/src/switch_input/mod.rs b/src-tauri/src/switch_input/mod.rs index 96fc51ac..ccc54a9e 100644 --- a/src-tauri/src/switch_input/mod.rs +++ b/src-tauri/src/switch_input/mod.rs @@ -569,6 +569,24 @@ mod tests { c } #[test] + fn explicit_typing_pause_drains_held_switch_and_passes_mapped_keys() { + let mut c = core(); + c.mappings.insert("Enter".into(), "enter".into()); + assert!(c.key("Space", true, 1)); + c.stop(StopReason::Disabled); + assert!(c.key("Space", false, 2)); + c.events.clear(); + for key in ["Space", "Enter"] { + assert!(!c.key(key, true, 3)); + assert!(!c.key(key, false, 4)); + } + assert!(c.events.is_empty()); + c.begin(Mode::Active, 5); + assert!(c.key("Space", true, 6)); + assert!(c.key("Space", false, 7)); + assert_eq!(c.events.len(), 2); + } + #[test] fn repeat_and_unmatched_release_never_activate() { let mut c = core(); assert!(!c.key("Space", false, 0)); diff --git a/src-tauri/src/switch_runtime.rs b/src-tauri/src/switch_runtime.rs index 86198805..5595a7f6 100644 --- a/src-tauri/src/switch_runtime.rs +++ b/src-tauri/src/switch_runtime.rs @@ -17,6 +17,7 @@ pub struct View { pub settings: Settings, pub capture: CaptureState, pub supported: bool, + pub keyboard_entry: bool, pub error: Option, pub escape_hold_ms: u64, pub unavailable_keys: Vec, @@ -25,6 +26,7 @@ struct Data { settings: Settings, capture: CaptureState, capture_generation: u64, + keyboard_entry: bool, error: Option, } pub struct Controller { @@ -91,6 +93,7 @@ impl Controller { settings, capture: CaptureState::default(), capture_generation: 0, + keyboard_entry: false, error, }), broker: Mutex::new(Capture::new()), @@ -101,6 +104,7 @@ impl Controller { View { settings: d.settings.clone(), capture: d.capture.clone(), + keyboard_entry: d.keyboard_entry, supported: cfg!(any(target_os = "windows", target_os = "macos")), error: d.error.clone(), escape_hold_ms: d.settings.escape_ms(), @@ -113,6 +117,31 @@ impl Controller { .collect(), } } + pub fn keyboard_entry(&self) -> bool { + self.data + .lock() + .unwrap_or_else(|p| p.into_inner()) + .keyboard_entry + } + pub fn set_keyboard_entry(&self, active: bool) -> Result<(), String> { + let mut d = self.data.lock().unwrap_or_else(|p| p.into_inner()); + if active && d.capture.active { + return Err("Finish learning the switch before typing with the keyboard.".into()); + } + d.keyboard_entry = active; + drop(d); + if active { + self.stop(); + } + Ok(()) + } + fn require_switch_control(&self) -> Result<(), String> { + if self.keyboard_entry() { + Err("Typing with the keyboard. Resume switch control in Switches when finished.".into()) + } else { + Ok(()) + } + } pub fn active_generation(&self, generation: u64) -> bool { let status = self .broker @@ -155,6 +184,7 @@ impl Controller { Ok(self.view()) } pub fn enable_escape(&self) -> Result<(), String> { + self.require_switch_control()?; let mut broker = self.broker.lock().unwrap_or_else(|p| p.into_inner()); broker.stop(); broker.configure(&[], 4000).map_err(|e| e.to_string())?; @@ -162,6 +192,7 @@ impl Controller { Ok(()) } pub fn enable(&self, automatic: bool) -> Result<(), String> { + self.require_switch_control()?; let view = self.view(); if let Some(e) = view.error { return Err(e); @@ -184,6 +215,7 @@ impl Controller { Ok(()) } pub fn begin_capture(&self, app: &AppHandle) -> Result { + self.require_switch_control()?; if let Some(e) = self.view().error { return Err(e); } @@ -312,6 +344,32 @@ pub fn install(app: &AppHandle) { #[cfg(test)] mod tests { use super::*; + #[test] + fn keyboard_entry_excludes_capture_without_changing_bindings() { + // An off broker never installs a native adapter in this test. + let controller = Controller { + data: Mutex::new(Data { + settings: Settings::default(), + capture: CaptureState::default(), + capture_generation: 0, + keyboard_entry: false, + error: None, + }), + broker: Mutex::new(Capture::new()), + }; + controller.set_keyboard_entry(true).unwrap(); + assert!(controller.view().keyboard_entry); + assert!(controller + .enable(true) + .unwrap_err() + .contains("Resume switch control")); + assert!(controller.enable_escape().is_err()); + controller.set_keyboard_entry(false).unwrap(); + assert!(controller.require_switch_control().is_ok()); + controller.data.lock().unwrap().capture.active = true; + assert!(controller.set_keyboard_entry(true).is_err()); + assert!(!controller.keyboard_entry()); + } struct Files(std::path::PathBuf); impl Files { fn new() -> Self { diff --git a/src/App.test.tsx b/src/App.test.tsx index b0d5ccf6..4ae8faa0 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -31,7 +31,7 @@ describe("Switchify PC shell", () => { function localSwitches(): switchHooks.SwitchController { const settings: switchHooks.SwitchSettings = { schemaVersion: 1, holdIntervalMs: 1000, bindings: [{ id: "space", name: "Space switch", key: "Space", pressAction: "select", holdActions: [] }] }; - return { settings, state: { settings, capture: { active: false, key: null, error: null }, supported: true, error: null, escapeHoldMs: 4000, unavailableKeys: [] }, pending: 0, error: null, capturing: false, unsaved: false, update: vi.fn(), capture: vi.fn(), cancelCapture: vi.fn().mockResolvedValue(undefined), retry: vi.fn() }; + return { settings, state: { settings, capture: { active: false, key: null, error: null }, supported: true, error: null, escapeHoldMs: 4000, unavailableKeys: [] }, pending: 0, error: null, capturing: false, unsaved: false, update: vi.fn(), capture: vi.fn(), cancelCapture: vi.fn().mockResolvedValue(undefined), setKeyboardEntry: vi.fn().mockResolvedValue(true), retry: vi.fn() }; } it("shows local readiness without Bluetooth and explains Android suspension", async () => { diff --git a/src/Switches.test.tsx b/src/Switches.test.tsx index 11c0d502..85e6cf70 100644 --- a/src/Switches.test.tsx +++ b/src/Switches.test.tsx @@ -78,6 +78,9 @@ beforeEach(() => { mocks.listen.mockReset().mockResolvedValue(vi.fn()); mocks.invoke.mockReset().mockImplementation(async (command, args) => { switch (command) { + case "set_switch_keyboard_entry": + current = { ...current, keyboardEntry: args.active }; + return current; case "get_switches": return current; case "save_switches": @@ -400,3 +403,71 @@ it("removes a remote switch and surfaces a failed remote save with retry", async await waitFor(() => expect(remote.slots[0].pressAction).toBeNull()); expect(screen.queryByRole("heading", { name: "Remote switch 1" })).toBeNull(); }); + + +it("pauses capture only by explicit choice, saves mapped characters and resumes after Done", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "Edit Head switch" })); + const name = screen.getByRole("textbox", { name: "Name for Space" }); + name.focus(); + expect(mocks.invoke).not.toHaveBeenCalledWith("set_switch_keyboard_entry", expect.anything()); + fireEvent.click(screen.getByRole("button", { name: "Type with keyboard" })); + await screen.findByText("Keyboard typing is on. Assigned keys type normally; switch scanning is paused."); + expect(name).toHaveFocus(); + expect(screen.getByRole("button", { name: "Learn another key for Head switch" })).toBeDisabled(); + fireEvent.change(name, { target: { value: "Big blue switch" } }); + await waitFor(() => expect(current.settings.bindings[0].name).toBe("Big blue switch")); + fireEvent.click(screen.getByRole("button", { name: "Done" })); + await waitFor(() => expect(current.keyboardEntry).toBe(false)); +}); + +it("releases keyboard entry when navigating away, even while entry is pending", async () => { + let finish: (() => void) | undefined; + const invoke = mocks.invoke.getMockImplementation()!; + mocks.invoke.mockImplementation((command, args) => command === "set_switch_keyboard_entry" && args.active + ? new Promise((resolve) => { finish = () => { current = { ...current, keyboardEntry: true }; resolve(current); }; }) + : invoke(command, args)); + const view = render(); + fireEvent.click(await screen.findByRole("button", { name: "Edit Head switch" })); + fireEvent.click(screen.getByRole("button", { name: "Type with keyboard" })); + await waitFor(() => expect(finish).toBeDefined()); + view.rerender(); + await act(async () => finish!()); + await waitFor(() => expect(current.keyboardEntry).toBe(false)); + expect(mocks.invoke).toHaveBeenCalledWith("set_switch_keyboard_entry", { active: false }); +}); + +it("keeps the name draft and normal switch control if keyboard entry is refused", async () => { + const invoke = mocks.invoke.getMockImplementation()!; + mocks.invoke.mockImplementation((command, args) => command === "set_switch_keyboard_entry" && args.active + ? Promise.reject("Stop forwarding on Remote before typing with the keyboard.") : invoke(command, args)); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Edit Head switch" })); + fireEvent.click(screen.getByRole("button", { name: "Type with keyboard" })); + await screen.findByText("Stop forwarding on Remote before typing with the keyboard."); + expect(screen.getByRole("textbox", { name: "Name for Space" })).toHaveValue("Head switch"); + expect(screen.getByRole("button", { name: "Learn another key for Head switch" })).toBeEnabled(); +}); + + +it("preserves a newly learned key while typing and releases capture ownership on Cancel", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "Add switch" })); + await screen.findByRole("button", { name: "Cancel capture" }); + current = { ...current, capture: { active: false, key: "Enter", error: null } }; + event(current); + fireEvent.click(await screen.findByRole("button", { name: "Type with keyboard" })); + await screen.findByRole("button", { name: "Resume switch control" }); + fireEvent.change(screen.getByRole("textbox", { name: "New switch name" }), { target: { value: "Second switch" } }); + expect(screen.getByText("Enter", { selector: "kbd" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Cancel new switch" })); + await waitFor(() => expect(current.keyboardEntry).toBe(false)); + expect(current.settings.bindings).toHaveLength(1); +}); + +it("offers recovery when returning to Switches with keyboard typing still active", async () => { + current.keyboardEntry = true; + render(); + fireEvent.click(await screen.findByRole("button", { name: "Resume switch control" })); + await waitFor(() => expect(current.keyboardEntry).toBe(false)); +}); diff --git a/src/scanning/useSwitches.ts b/src/scanning/useSwitches.ts index 22751644..e6af74a6 100644 --- a/src/scanning/useSwitches.ts +++ b/src/scanning/useSwitches.ts @@ -26,6 +26,7 @@ export type SwitchState = { settings: SwitchSettings; capture: { active: boolean; key: string | null; error: string | null }; supported: boolean; + keyboardEntry?: boolean; error: string | null; escapeHoldMs: number; unavailableKeys: string[]; @@ -166,6 +167,27 @@ export function useSwitches() { setError(String(e)); } }; + const setKeyboardEntry = (active: boolean): Promise => { + if (!("__TAURI_INTERNALS__" in window)) return Promise.resolve(false); + const m = model.current; + m.pending++; + setPending(m.pending); + const operation = queue.current.then(async () => { + setError(null); + const stamp = runtime.current; + const result = await invoke("set_switch_keyboard_entry", { active }); + if (stamp === runtime.current) receive(result); + return true; + }).catch((reason) => { + setError(String(reason)); + return false; + }).finally(() => { + m.pending--; + setPending(m.pending); + }); + queue.current = operation.then(() => {}); + return operation; + }; return { state, settings, @@ -175,6 +197,7 @@ export function useSwitches() { update, capture, cancelCapture, + setKeyboardEntry, retry: () => save(model.current.settings, model.current.revision), unsaved: model.current.revision !== model.current.saved, }; diff --git a/src/settings/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx index 98270f6c..e54f8a5a 100644 --- a/src/settings/SwitchesSection.tsx +++ b/src/settings/SwitchesSection.tsx @@ -87,7 +87,13 @@ function SwitchEditor({ onRemove, nameRef, remote, + keyboardEntry, + entryPending, + onKeyboardEntry, }: { + keyboardEntry: boolean; + entryPending: boolean; + onKeyboardEntry: (active: boolean) => void; id?: string; binding: Binding; isNew: boolean; @@ -129,6 +135,20 @@ function SwitchEditor({ onChange={(e) => onChange({ ...binding, name: e.target.value })} /> +
+

+ Keyboard switches share their assigned keys with every keyboard. To type + a name with those keys, choose Type with keyboard. Switch control pauses + until you resume or close this editor. You can use Tab and Enter to + reach and activate Resume switch control. +

+

If you rely on switches, keep switch control on and use the scanning keyboard from the point action menu to enter the name.

+ + {keyboardEntry &&

Keyboard typing is on. Assigned keys type normally; switch scanning is paused.

} +
{remote ? (