()
diff --git a/src-tauri/src/switches.rs b/src-tauri/src/switches.rs
index ce344a76..7c9d00f6 100644
--- a/src-tauri/src/switches.rs
+++ b/src-tauri/src/switches.rs
@@ -48,7 +48,7 @@ impl Settings {
"Each switch needs a name, a unique ID, and at most 32 hold actions.".into(),
);
}
- let key = usahp_daemon::embedded::normalize_key(&b.key)
+ let key = crate::switch_input::normalize_key(&b.key)
.ok_or("Choose a supported switch key.")?;
if key == "Escape" || !keys.insert(key) {
return Err(
diff --git a/src/settings/ScanningSection.tsx b/src/settings/ScanningSection.tsx
index 5cc8a4cb..15bcfa08 100644
--- a/src/settings/ScanningSection.tsx
+++ b/src/settings/ScanningSection.tsx
@@ -49,6 +49,10 @@ export function ScanningSection({
Switch keys are reserved while enabled. Escape stops scanning and
releases them. Disconnect Android before enabling local point scan.
+
+ On Windows, use switch keys without Shift, Ctrl, Alt or Windows held.
+ Key releases can still reach other applications.
+
{state?.enabled
From c5e3a5d1c1e81e03449f514b431913b7165533e3 Mon Sep 17 00:00:00 2001
From: Owen McGirr
Date: Sat, 12 Sep 2026 17:10:14 +0100
Subject: [PATCH 05/13] Preserve held switches when another keyboard
disconnects
---
src-tauri/src/switch_input/windows.rs | 23 ++++++++++++++++++++---
1 file changed, 20 insertions(+), 3 deletions(-)
diff --git a/src-tauri/src/switch_input/windows.rs b/src-tauri/src/switch_input/windows.rs
index 23e92366..3047d5ba 100644
--- a/src-tauri/src/switch_input/windows.rs
+++ b/src-tauri/src/switch_input/windows.rs
@@ -106,9 +106,8 @@ unsafe extern "system" fn window(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -
INPUT.with(|slot| {
if let Some(input) = slot.borrow().as_ref() {
input.driver.lost();
- let mut core = input.driver.core.lock().unwrap_or_else(|p| p.into_inner());
- core.physical.clear();
- core.down.clear();
+ // The removed device may not own a held switch. Off-mode cleanup
+ // reconciles actual key state before releasing its reservation.
}
});
}
@@ -458,4 +457,22 @@ mod tests {
assert!(!super::super::supported_key("F12"));
assert!(!known_keys().iter().any(|(_, name)| name == "F12"));
}
+ #[test]
+ fn device_loss_preserves_other_held_keys_until_their_release() {
+ let i = input();
+ i.hotkey(32);
+ i.driver.lost();
+ assert!(i.driver.core.lock().unwrap().physical.contains("Space"));
+ i.raw(32, true);
+ let c = i.driver.core.lock().unwrap();
+ assert!(c.physical.is_empty());
+ assert_eq!(c.events.len(), 1);
+ assert!(matches!(
+ c.events[0],
+ Event::Stopped {
+ reason: StopReason::CaptureLost,
+ ..
+ }
+ ));
+ }
}
From 374cabcac2dab99e3c64f41ffd65debffd738f12 Mon Sep 17 00:00:00 2001
From: Owen McGirr
Date: Sat, 12 Sep 2026 17:54:41 +0100
Subject: [PATCH 06/13] Redesign the Switches tab as summary rows with an
inline editor
Each switch is now a collapsed row showing name, key badge and an action
summary, with Edit and Remove as the only controls. Expanding a row opens a
stacked editor: name, key with Change key, press action, and hold actions
as numbered rows with icon reorder buttons and a live timing preview.
Adding a switch starts learning immediately and Save stays disabled until
both name and key exist. Learning shows a highlighted capture panel inside
the affected row, and key errors sit under the key field.
Guidance moves into the shared note and disclosure style, save state and
errors use the existing note and error band, and the empty list uses the
standard empty state. Hold timing gets five presets, an exact-interval
select, and the emergency hold duration alongside it. Fixes the unstyled
fieldset border around the interval group.
Co-Authored-By: Claude Fable 5.1
---
docs/switches.md | 4 +-
src/Switches.test.tsx | 70 ++-
src/settings/SwitchesSection.tsx | 738 ++++++++++++++++++++-----------
src/styles.css | 57 ++-
4 files changed, 592 insertions(+), 277 deletions(-)
diff --git a/docs/switches.md b/docs/switches.md
index d819bc29..eda307e3 100644
--- a/docs/switches.md
+++ b/docs/switches.md
@@ -1,8 +1,8 @@
# 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.
+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.
-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.
+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.
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.
diff --git a/src/Switches.test.tsx b/src/Switches.test.tsx
index f1fc5705..df33a304 100644
--- a/src/Switches.test.tsx
+++ b/src/Switches.test.tsx
@@ -99,33 +99,61 @@ beforeEach(() => {
});
});
afterEach(() => Reflect.deleteProperty(window, "__TAURI_INTERNALS__"));
-it("commits a new switch only after name and physical capture are complete", async () => {
+const open = (name: string) =>
+ fireEvent.click(screen.getByRole("button", { name: `Edit ${name}` }));
+it("shows each switch as a summary row and expands one to edit", async () => {
render();
- await screen.findByText("Key: Space");
+ await screen.findByRole("heading", { name: "Head switch" });
+ expect(screen.getByText("Select · Hold: Next, Stop scanning")).toBeTruthy();
+ expect(screen.queryByLabelText("Name for Space")).toBeNull();
+ open("Head switch");
+ expect(screen.getByLabelText("Name for Space")).toBeTruthy();
+ expect(
+ screen.getByRole("button", { name: "Close Head switch" }).getAttribute("aria-expanded"),
+ ).toBe("true");
+ fireEvent.click(screen.getByRole("button", { name: "Done" }));
+ expect(screen.queryByLabelText("Name for Space")).toBeNull();
+});
+it("starts learning when a switch is added and commits only after name and key", async () => {
+ render();
+ await screen.findByRole("heading", { name: "Head switch" });
fireEvent.click(screen.getByRole("button", { name: "Add switch" }));
+ await screen.findByRole("button", { name: "Cancel capture" });
+ expect(mocks.invoke).toHaveBeenCalledWith("begin_switch_capture");
+ expect(screen.getByRole("button", { name: "Save switch" })).toBeDisabled();
+ event({ ...current, capture: { active: false, key: "Enter", error: null } });
+ await screen.findByText("Enter");
+ expect(screen.getByRole("button", { name: "Save switch" })).toBeDisabled();
fireEvent.change(screen.getByLabelText("New switch name"), {
target: { value: "Foot switch" },
});
expect(mocks.invoke.mock.calls.some(([c]) => c === "save_switches")).toBe(
false,
);
- fireEvent.click(screen.getByRole("button", { name: "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" }));
+ fireEvent.click(screen.getByRole("button", { name: "Save switch" }));
await waitFor(() => expect(current.settings.bindings).toHaveLength(2));
expect(current.settings.bindings[1]).toMatchObject({
name: "Foot switch",
key: "Enter",
pressAction: "select",
});
+ expect(screen.queryByLabelText("New switch name")).toBeNull();
+});
+it("cancelling a new switch also cancels its capture", async () => {
+ render();
+ await screen.findByRole("heading", { name: "Head switch" });
+ fireEvent.click(screen.getByRole("button", { name: "Add switch" }));
+ await screen.findByRole("button", { name: "Cancel capture" });
+ event({ ...current, capture: { active: false, key: "Enter", error: null } });
+ await screen.findByText("Enter");
+ fireEvent.click(screen.getByRole("button", { name: "Cancel new switch" }));
+ expect(screen.queryByLabelText("New switch name")).toBeNull();
+ expect(current.settings.bindings).toHaveLength(1);
});
it("rejects a learned duplicate without changing the existing switch", async () => {
render();
- await screen.findByText("Key: Space");
+ await screen.findByRole("heading", { name: "Head switch" });
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.");
@@ -133,7 +161,8 @@ it("rejects a learned duplicate without changing the existing switch", async ()
});
it("reorders hold actions and waits for pending switch saves before enabling", async () => {
render();
- await screen.findByText("Key: Space");
+ await screen.findByRole("heading", { name: "Head switch" });
+ open("Head switch");
let finish!: (v: SwitchState) => void;
mocks.invoke.mockImplementationOnce(
() =>
@@ -164,10 +193,23 @@ it("reorders hold actions and waits for pending switch saves before enabling", a
);
await screen.findByRole("button", { name: "Disable point scan" });
expect(screen.getByLabelText("Normal action for Head switch")).toBeDisabled();
+ expect(screen.getByText("Select · Hold: Stop scanning, Next")).toBeTruthy();
+});
+it("previews hold timing from the interval", async () => {
+ render();
+ await screen.findByRole("heading", { name: "Head switch" });
+ open("Head switch");
+ expect(
+ screen.getByText("Hold 1s for Next, 2s for Stop scanning. Release to run the action shown."),
+ ).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "2s" }));
+ await screen.findByText("Hold 2s for Next, 4s for Stop scanning. Release to run the action shown.");
+ await screen.findByText(/Holding any switch for 8s disables switch control/);
});
it("preserves failed switch edits and prevents enable until retry succeeds", async () => {
render();
- await screen.findByText("Key: Space");
+ await screen.findByRole("heading", { name: "Head switch" });
+ open("Head switch");
mocks.invoke.mockRejectedValueOnce("Disk full");
fireEvent.change(screen.getByLabelText("Name for Space"), {
target: { value: "New name" },
@@ -188,7 +230,8 @@ it("preserves failed switch edits and prevents enable until retry succeeds", asy
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");
+ await screen.findByRole("heading", { name: "Head switch" });
+ open("Head switch");
fireEvent.click(
screen.getByRole("button", { name: "Learn another key for Head switch" }),
);
@@ -199,7 +242,8 @@ it("does not reuse an old learned key when starting another capture", async () =
});
it("cancels learning when the panel unmounts and retains pending edits", async () => {
const view = render();
- await screen.findByText("Key: Space");
+ await screen.findByRole("heading", { name: "Head switch" });
+ open("Head switch");
fireEvent.click(
screen.getByRole("button", { name: "Learn another key for Head switch" }),
);
diff --git a/src/settings/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx
index 5cec3767..da9554ca 100644
--- a/src/settings/SwitchesSection.tsx
+++ b/src/settings/SwitchesSection.tsx
@@ -1,40 +1,253 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useId, useRef, useState } from "react";
+import { ChevronDown, ChevronUp, Keyboard, Trash2, X } from "lucide-react";
import {
actions,
type Binding,
type SwitchAction,
type SwitchController,
} from "../scanning/useSwitches";
-import { SettingGroup, OptionGroup, secondsOptions } from "./controls";
-function ActionPicker({
+import {
+ Disclosure,
+ OptionGroup,
+ SettingGroup,
+ SettingNote,
+ secondsOptions,
+} from "./controls";
+
+const holdIntervalPresets = [500, 1000, 1500, 2000, 3000] as const;
+const holdIntervalValues = [250, 500, 750, 1000, 1500, 2000, 3000, 4000, 5000];
+const newId = "new";
+
+function seconds(ms: number) {
+ const value = ms / 1000;
+ return `${Number.isInteger(value) ? value : value.toFixed(2).replace(/0$/, "")}s`;
+}
+
+// The emergency exit must outlast the longest hold list, so it grows with the
+// interval. Mirrors the backend rule so the shown number matches behaviour.
+export function escapeHoldMs(bindings: Binding[], holdIntervalMs: number) {
+ const longest = Math.max(0, ...bindings.map((b) => b.holdActions.length));
+ return longest ? Math.max(4000, (longest + 2) * holdIntervalMs) : 4000;
+}
+
+function summary(binding: Binding) {
+ const hold = binding.holdActions.length
+ ? `Hold: ${binding.holdActions.map((a) => actions[a]).join(", ")}`
+ : "No hold actions";
+ return `${actions[binding.pressAction]} · ${hold}`;
+}
+
+function ActionSelect({
label,
value,
- disabled,
onChange,
}: {
label: string;
value: SwitchAction;
- disabled: boolean;
onChange: (value: SwitchAction) => void;
}) {
return (
-
+
);
}
+
+function KeyBadge({ value, unavailable }: { value: string; unavailable: boolean }) {
+ return value ? (
+
+ {value}
+
+ ) : (
+ No key yet
+ );
+}
+
+function SwitchEditor({
+ binding,
+ isNew,
+ disabled,
+ holdIntervalMs,
+ unavailable,
+ keyError,
+ onChange,
+ onLearn,
+ onDone,
+ onRemove,
+ nameRef,
+}: {
+ binding: Binding;
+ isNew: boolean;
+ disabled: boolean;
+ holdIntervalMs: number;
+ unavailable: boolean;
+ keyError: string | null;
+ onChange: (next: Binding) => void;
+ onLearn: () => void;
+ onDone: () => void;
+ onRemove: () => void;
+ nameRef: React.RefObject;
+}) {
+ const name = binding.name || "this switch";
+ const keyErrorId = useId();
+ const canSave = !isNew || (!!binding.name.trim() && !!binding.key);
+ const move = (index: number, delta: number) => {
+ const holdActions = [...binding.holdActions];
+ [holdActions[index], holdActions[index + delta]] = [
+ holdActions[index + delta],
+ holdActions[index],
+ ];
+ onChange({ ...binding, holdActions });
+ };
+ return (
+
+ );
+}
+
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 && (
+
+
+
+
Press and release your switch
+
Switchify learns the key it sends. Escape cancels.
+
+
+
+ );
+ 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 ? (
+
+
+
No switches yet
- Press and release the switch you want to learn. Escape cancels.
+ Automatic scanning needs a Select switch. Manual scanning also
+ needs Next and Previous.
-
- )}
- {!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) => (
-
- {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 (
-
+
From 3cf2cbf7e73a80bd4ad0d79ebc95a01c4b412d7b Mon Sep 17 00:00:00 2001
From: Owen McGirr
Date: Sat, 12 Sep 2026 21:09:10 +0100
Subject: [PATCH 12/13] Pin generated Tauri files to LF and drop a retired
product word
Tauri rewrites the autogenerated permission files on every dev build,
and with core.autocrlf on Windows git then reported them as modified
despite identical content. A .gitattributes rule fixes their EOL. The
docs and a test name also used a word the identity check rejects.
Co-Authored-By: Claude Fable 5.1
---
.gitattributes | 4 ++++
docs/switches.md | 2 +-
src/Switches.test.tsx | 2 +-
3 files changed, 6 insertions(+), 2 deletions(-)
create mode 100644 .gitattributes
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..3014e3b1
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,4 @@
+# Tauri regenerates these on every dev build; pin them to LF so a Windows
+# checkout with core.autocrlf does not report them as modified.
+src-tauri/permissions/autogenerated/*.toml text eol=lf
+src-tauri/gen/** text eol=lf
diff --git a/docs/switches.md b/docs/switches.md
index 6a0bf616..3d0d27d8 100644
--- a/docs/switches.md
+++ b/docs/switches.md
@@ -2,7 +2,7 @@
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.
+The normal action runs on release. Hold actions are offered in their configured order. The interval defaults to 1 second and accepts 250–5000 milliseconds; five presets are shown and the rest sit behind an exact-interval select. The editor shows the time at which each hold action is offered. Each interval offers the next action in a nonactivating desktop prompt; the last remains selected. Release executes only the offered action. Movement freezes while any switch is held. The first pressed switch owns the gesture; overlapping presses do not activate additional actions.
Stop resets the scan but leaves switches enabled. Select starts again. Pause preserves position. Reverse changes direction without stepping. Automatic scanning requires Select; manual scanning also requires Next and Previous. Hold assignments count toward those requirements.
diff --git a/src/Switches.test.tsx b/src/Switches.test.tsx
index 58de7735..f633872e 100644
--- a/src/Switches.test.tsx
+++ b/src/Switches.test.tsx
@@ -195,7 +195,7 @@ it("reorders hold actions and waits for pending switch saves before enabling", a
expect(screen.getByLabelText("Normal action for Head switch")).toBeDisabled();
expect(screen.getByText("Select · Hold: Stop scanning, Next")).toBeTruthy();
});
-it("previews hold timing from the interval", async () => {
+it("shows hold timing computed from the interval", async () => {
render();
await screen.findByRole("heading", { name: "Head switch" });
open("Head switch");
From dfaebad36d8d57ee0fe8b51611562e2f6bfb3ed3 Mon Sep 17 00:00:00 2001
From: Owen McGirr
Date: Sat, 12 Sep 2026 21:30:53 +0100
Subject: [PATCH 13/13] Count exact-length passes and keep focus in an open
draft
A step of exactly the region length wrapped back to the same edge, so
the before/after comparison missed the pass and small grid cells at
the top speed could scan past the three-pass limit. The wrap flag now
comes from the overflow itself, and cyclic indexes wrap on leaving
their last slot. Removing a switch while a new one is being drafted
sent focus to the hidden Add button; it now lands on the draft's name.
Co-Authored-By: Claude Fable 5.1
---
src-tauri/src/point_scan.rs | 67 +++++++++++++++++++++++---------
src/Switches.test.tsx | 12 ++++++
src/settings/SwitchesSection.tsx | 5 ++-
3 files changed, 64 insertions(+), 20 deletions(-)
diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs
index 36de6683..29ddbf2c 100644
--- a/src-tauri/src/point_scan.rs
+++ b/src-tauri/src/point_scan.rs
@@ -210,43 +210,40 @@ impl Engine {
/ 1000.0)
.max(1.0);
let forward = self.direction > 0.0;
- let wrapped = |before: f64, after: f64| {
- if forward {
- after < before
- } else {
- after > before
- }
- };
+ // A cyclic index wraps when it leaves its last (or first) slot; comparing
+ // positions would miss a pass whose step lands exactly where it began.
+ let last = self.config.grid_size.saturating_sub(1);
+ let cycle_wraps = |before: usize| if forward { before == last } else { before == 0 };
match self.phase {
Phase::Row => {
let before = self.row.index();
self.row.step(self.config.grid_size, forward);
- wrapped(before as f64, self.row.index() as f64)
+ cycle_wraps(before)
}
Phase::Cell => {
let before = self.cell.index();
self.cell.step(self.config.grid_size, forward);
- wrapped(before as f64, self.cell.index() as f64)
+ cycle_wraps(before)
}
Phase::X => {
- let before = self.x;
- self.x = advance(
+ let (x, wrapped) = advance(
self.x,
self.region.x,
self.region.width,
self.direction * amount,
);
- wrapped(before, self.x)
+ self.x = x;
+ wrapped
}
Phase::Y => {
- let before = self.y;
- self.y = advance(
+ let (y, wrapped) = advance(
self.y,
self.region.y,
self.region.height,
self.direction * amount,
);
- wrapped(before, self.y)
+ self.y = y;
+ wrapped
}
Phase::Idle => false,
}
@@ -339,14 +336,17 @@ fn outline(r: Rect, t: f64) -> [Rect; 4] {
},
]
}
-fn advance(value: f64, start: f64, length: f64, delta: f64) -> f64 {
+/// Moves along one axis and reports whether the move wrapped past an edge. The
+/// flag comes from the overflow itself, so a step of exactly the region's
+/// length still counts as a completed pass even though it lands where it began.
+fn advance(value: f64, start: f64, length: f64, delta: f64) -> (f64, bool) {
let next = value + delta;
if next > start + length - 1.0 {
- start
+ (start, true)
} else if next < start {
- start + length - 1.0
+ (start + length - 1.0, true)
} else {
- next
+ (next, false)
}
}
@@ -494,6 +494,35 @@ mod tests {
assert!(!g.active());
}
#[test]
+ fn a_step_of_exactly_the_region_length_counts_as_a_pass() {
+ // At speed 4 a 250 ms tick moves 67.5 units, so a 67.5-wide grid cell
+ // lands back on its own left edge every tick and must still be counted.
+ let mut e = Session::new(
+ Engine::new(
+ Config {
+ speed: 4,
+ ..Config::default()
+ }
+ .point(),
+ Rect {
+ x: 0.0,
+ y: 0.0,
+ width: 67.5,
+ height: 67.5,
+ },
+ 1.0,
+ )
+ .unwrap(),
+ true,
+ );
+ e.action(Action::Select);
+ e.tick(250, false);
+ e.tick(250, false);
+ assert!(e.active());
+ e.tick(250, false);
+ assert!(!e.active(), "three exact-length passes exhaust the scan");
+ }
+ #[test]
fn manual_steps_never_exhaust_the_scan() {
let mut e = engine(Config {
automatic: false,
diff --git a/src/Switches.test.tsx b/src/Switches.test.tsx
index f633872e..e7cb0211 100644
--- a/src/Switches.test.tsx
+++ b/src/Switches.test.tsx
@@ -335,3 +335,15 @@ it("announces an unavailable key in the collapsed row", async () => {
event({ ...current, unavailableKeys: ["Space"] });
await screen.findByText(/unavailable on this computer/);
});
+it("keeps focus in the draft when another switch is removed while adding", async () => {
+ render();
+ await screen.findByRole("heading", { name: "Head switch" });
+ fireEvent.click(screen.getByRole("button", { name: "Add switch" }));
+ await screen.findByRole("dialog");
+ current = { ...current, capture: { active: false, key: "Enter", error: null } };
+ event(current);
+ await screen.findByText("Enter");
+ fireEvent.click(screen.getByRole("button", { name: "Remove Head switch" }));
+ await waitFor(() => expect(current.settings.bindings).toHaveLength(0));
+ expect(document.activeElement).toBe(screen.getByLabelText("New switch name"));
+});
diff --git a/src/settings/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx
index 9fb7a89a..2551455b 100644
--- a/src/settings/SwitchesSection.tsx
+++ b/src/settings/SwitchesSection.tsx
@@ -339,7 +339,10 @@ export function SwitchesSection({
if (!focusAfter.current) return;
const id = focusAfter.current;
focusAfter.current = null;
- (id === newId ? addRef.current : (editRefs.current.get(id) ?? addRef.current))?.focus();
+ // While a new switch is being drafted the Add switch button is not rendered,
+ // so a removal elsewhere hands focus to the draft's name field instead.
+ const target = id === newId ? null : editRefs.current.get(id);
+ (target ?? addRef.current ?? nameRef.current)?.focus();
});
const cancel = useRef(controller.cancelCapture);
cancel.current = controller.cancelCapture;