Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const { held, prompt } = useKeyboardSwitches({
- Bound keys are kept from the page, so Space and Enter don't also click whatever has focus.
- Keys pass through while focus is in a text field, unless you set `ignoreWhileTyping: false`.
- Escape stops scanning.
- `interceptPress(switchId)` runs when a switch goes down. Return true to consume the whole press, for example to let any press stop something the app is repeating.
- Automatic movement pauses while a switch is held.
- `validateSwitchSettings` reports settings that can't drive scanning, for example ones with no Select, or manual scanning without Next and Previous.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@switchify/scanning",
"version": "0.3.1",
"version": "0.4.0",
"description": "Switch scanning for React and React Native, ported from Switchify PC.",
"license": "AGPL-3.0-only",
"type": "module",
Expand Down
20 changes: 17 additions & 3 deletions src/dom/keyboardSwitches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export interface KeyboardSwitchOptions {
*/
ignoreWhileTyping?: boolean;
onAction?: (action: ScanAction, switchId: string) => void;
/**
* Called when a switch goes down. Returning true consumes the whole press, which
* then never becomes a scan action. Use it to let any press stop something the
* app is repeating.
*/
interceptPress?: (switchId: string) => boolean;
}

export interface KeyboardSwitchState {
Expand Down Expand Up @@ -45,16 +51,18 @@ export function useKeyboardSwitches({
enabled = true,
ignoreWhileTyping = true,
onAction,
interceptPress,
}: KeyboardSwitchOptions): KeyboardSwitchState {
const controller = useScanController();
const [state, setState] = useState<KeyboardSwitchState>({ held: false, prompt: undefined });
const latest = useRef({ settings, ignoreWhileTyping, onAction });
latest.current = { settings, ignoreWhileTyping, onAction };
const latest = useRef({ settings, ignoreWhileTyping, onAction, interceptPress });
latest.current = { settings, ignoreWhileTyping, onAction, interceptPress };

useEffect(() => {
if (!enabled || typeof window === "undefined") return;
const gestures = new SwitchGestures();
const captured = new Set<string>();
const intercepted = new Set<string>();
let timer: ReturnType<typeof setInterval> | undefined;
const now = () => (globalThis.performance ?? Date).now();
const show = () => {
Expand All @@ -75,6 +83,7 @@ export function useKeyboardSwitches({
const reset = () => {
gestures.cancel();
captured.clear();
intercepted.clear();
stopTimer();
controller.setSwitchHeld(false);
show();
Expand All @@ -95,7 +104,11 @@ export function useKeyboardSwitches({
event.preventDefault();
event.stopPropagation();
captured.add(event.code);
if (event.repeat) return;
if (event.repeat || intercepted.has(event.code)) return;
if (!gestures.isHeld() && latest.current.interceptPress?.(match.id)) {
intercepted.add(event.code);
return;
Comment on lines +108 to +110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Overlapping switches trigger scan actions

An intercepted switch never enters gestures, so gestures.isHeld() stays false while that switch is still down. When interceptPress stops repeating movement and returns false on later calls, pressing a second switch during that hold starts a new gesture. Releasing it can select an item or move the scan, instead of being ignored like other overlapping presses. The callback also runs while another switch is held.

Before merging, track intercepted switches as held without assigning them a scan action, and add a test for overlapping presses.

Artifacts

Evidence from the check

  • The preserved authored test exercised real DOM key events through the hook and dispatcher, confirming the overlapping-switch regression.

Command output from the check

  • The preserved base execution output includes its command, working directory, and exit code, showing that overlapping Enter release caused no action.

Command output from the check

  • The preserved HEAD execution output includes its command, working directory, and exit code, showing next dispatch while Space remained down.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/dom/keyboardSwitches.ts
Line: 108-110

Comment:
**Overlapping switches trigger scan actions**

An intercepted switch never enters `gestures`, so `gestures.isHeld()` stays false while that switch is still down. When `interceptPress` stops repeating movement and returns false on later calls, pressing a second switch during that hold starts a new gesture. Releasing it can select an item or move the scan, instead of being ignored like other overlapping presses. The callback also runs while another switch is held.

Before merging, track intercepted switches as held without assigning them a scan action, and add a test for overlapping presses.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}
gestures.pressed(match.id, now(), latest.current.settings);
controller.setSwitchHeld(true);
timer ??= setInterval(show, PROMPT_TICK_MS);
Expand All @@ -106,6 +119,7 @@ export function useKeyboardSwitches({
if (!captured.delete(event.code)) return;
event.preventDefault();
event.stopPropagation();
if (intercepted.delete(event.code)) return;
const match = binding(event.code);
const action = match ? gestures.released(match.id, now()) : undefined;
if (!gestures.isHeld()) {
Expand Down
20 changes: 19 additions & 1 deletion test/keyboardSwitches.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ function Item({ id, onActivate }: { id: string; onActivate?: () => void }) {

let switches: ReturnType<typeof useKeyboardSwitches>;
let scanner: ReturnType<typeof useScanner>;
let intercept: ((id: string) => boolean) | undefined;
function Switches({ enabled = true }: { enabled?: boolean }) {
switches = useKeyboardSwitches({ settings, enabled });
switches = useKeyboardSwitches({ settings, enabled, interceptPress: (id) => intercept?.(id) ?? false });
scanner = useScanner();
return null;
}
Expand Down Expand Up @@ -97,3 +98,20 @@ test("nothing is captured while disabled", async () => {
expect(event.defaultPrevented).toBe(false);
expect(scanner.snapshot.active).toBe(false);
});

test("an intercepted press is consumed whole and never becomes a scan action", async () => {
await setup();
tap("Space");
expect(highlighted("a")).toBe(true);
let repeating = true;
intercept = vi.fn(() => { const was = repeating; repeating = false; return was; });
const down = new KeyboardEvent("keydown", { code: "Enter", bubbles: true, cancelable: true });
act(() => { window.dispatchEvent(down); });
expect(down.defaultPrevented).toBe(true);
key("keyup", "Enter");
expect(highlighted("a")).toBe(true);
expect(intercept).toHaveBeenCalledWith("next");
tap("Enter");
expect(highlighted("b")).toBe(true);
intercept = undefined;
});
Loading