From 559968d89e56b3a286858a2339c4c96c725b22d9 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 9 Oct 2026 14:23:51 +0100 Subject: [PATCH 1/2] feat(scanning): add row scanning, gentler scrolling and switch repeat stop - Add a Rows option: in grouped scanning each grid row of several controls becomes its own group inside its section, ending with a "Leave row" stop - Only scroll when the highlighted control is not fully visible in its scroll area, then centre it smoothly (instantly with reduced motion) instead of nudging each step - While pointer movement or a key repeats, the next switch press stops the repeat and is consumed, using @switchify/scanning v0.4.0's interceptPress - Cover row preferences, the scroll decision and scan interrupts with tests, and document the behaviour Co-Authored-By: Claude Opus 5.5 --- docs/web.md | 4 +-- package-lock.json | 6 ++--- package.json | 2 +- src/app/(tabs)/remote.tsx | 9 ++++++- src/components/ResponsiveGrid.tsx | 18 ++++++++++---- src/scanning/ScanHighlight.tsx | 4 +-- src/scanning/ScanSection.tsx | 4 +-- src/scanning/ScanningContext.ts | 3 +++ src/scanning/ScanningProvider.tsx | 12 +++++---- src/scanning/ScanningSettings.tsx | 5 ++-- src/scanning/SwitchInput.web.tsx | 3 ++- src/scanning/preferences.test.ts | 7 ++++++ src/scanning/preferences.ts | 4 +++ src/scanning/reveal.test.ts | 20 +++++++++++++++ src/scanning/scanInterrupts.test.tsx | 24 ++++++++++++++++++ src/scanning/scanInterrupts.ts | 23 +++++++++++++++++ src/scanning/useScannable.ts | 37 ++++++++++++++++++++++++---- 17 files changed, 156 insertions(+), 29 deletions(-) create mode 100644 src/scanning/reveal.test.ts create mode 100644 src/scanning/scanInterrupts.test.tsx create mode 100644 src/scanning/scanInterrupts.ts diff --git a/docs/web.md b/docs/web.md index 600ceb5..ed6c43b 100644 --- a/docs/web.md +++ b/docs/web.md @@ -24,8 +24,8 @@ The web app is a progressive web app, so Chrome and Edge offer to install it to Switch users can operate Remote with keyboard-style switch interfaces, which send key presses such as Space or Enter. Scanning uses [@switchify/scanning](https://github.com/switchifyapp/switchify-scanning), the TypeScript port of Switchify PC's item scanner, so timing, passes, groups and hold actions behave as they do on the PC. -- **Settings:** turn it on in Settings → Switch scanning, which is off by default. Then choose automatic or manual movement, the scan speed, and "Sections first" (grouped) or "Every control" (linear). Assign each switch by choosing Set key and pressing the switch. Each switch has a press action and an optional hold action. Settings that cannot drive scanning are refused, for example ones with no Select, or manual scanning without Next and Previous. -- **Using it:** Select starts scanning, and Escape stops it. In grouped mode each surface section is one stop. Select enters it, its controls are scanned in turn, and a final "Leave section" stop returns to the page. Open dialogs (selectors, the PC switcher, the layout editor and the action picker) confine scanning to themselves. Controls on hidden tabs are never scanned, and the highlighted control scrolls into view. Automatic movement pauses while a switch is held. +- **Settings:** turn it on in Settings → Switch scanning, which is off by default. Then choose automatic or manual movement, the scan speed, and how to group controls: "Sections" scans each section then its controls, "Rows" scans each section, then each row of a grid, then its controls, and "Every control" visits every control in turn. Assign each switch by choosing Set key and pressing the switch. Each switch has a press action and an optional hold action. Settings that cannot drive scanning are refused, for example ones with no Select, or manual scanning without Next and Previous. +- **Using it:** Select starts scanning, and Escape stops it. In grouped mode each surface section is one stop. Select enters it, its controls are scanned in turn, and a final "Leave section" stop returns to the page. In Rows mode each grid row of several controls is a stop of its own, ending with "Leave row". Open dialogs (selectors, the PC switcher, the layout editor and the action picker) confine scanning to themselves. Controls on hidden tabs are never scanned. The page only scrolls when the highlighted control is not fully visible, and it then centres the control smoothly (instantly with reduced motion), so the next few stops are already on screen. Automatic movement pauses while a switch is held. While pointer movement or a key is repeating, the next switch press stops the repeat and does nothing else, as on Switchify Android. - **Implementation:** `src/scanning/` holds the provider, the `useScannable` hook used by `ControlButton`, `ActionButton`, `IconButton`, `ListRow`, `SelectorField`, the PC switcher and the tab bar, the highlight rings, and the settings card. Scanning settings live in `switchify.remote.preferences.v1` under `scanning`, and unknown or unusable values fall back on their own. - **Limits:** keys still type into a text field while one has focus, so live typing is not yet switch-accessible. Native builds have no global key events, so scanning is web-only for now. diff --git a/package-lock.json b/package-lock.json index 0768355..dce5bd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@noble/hashes": "^2.3.0", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/native": "^7.3.16", - "@switchify/scanning": "github:switchifyapp/switchify-scanning#v0.3.1", + "@switchify/scanning": "github:switchifyapp/switchify-scanning#v0.4.0", "base64-js": "^1.5.1", "expo": "~57.0.27", "expo-blur": "~57.0.3", @@ -3880,8 +3880,8 @@ } }, "node_modules/@switchify/scanning": { - "version": "0.3.1", - "resolved": "git+ssh://git@github.com/switchifyapp/switchify-scanning.git#ae1db07bab3e3df89b55944d3f518bca7a34919d", + "version": "0.4.0", + "resolved": "git+ssh://git@github.com/switchifyapp/switchify-scanning.git#2386313285d3b7385bae22eec44d9b7513119581", "license": "AGPL-3.0-only", "engines": { "node": ">=20" diff --git a/package.json b/package.json index 4b8848c..fa0a927 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@noble/hashes": "^2.3.0", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/native": "^7.3.16", - "@switchify/scanning": "github:switchifyapp/switchify-scanning#v0.3.1", + "@switchify/scanning": "github:switchifyapp/switchify-scanning#v0.4.0", "base64-js": "^1.5.1", "expo": "~57.0.27", "expo-blur": "~57.0.3", diff --git a/src/app/(tabs)/remote.tsx b/src/app/(tabs)/remote.tsx index 8a5b5d8..1a0d5a4 100644 --- a/src/app/(tabs)/remote.tsx +++ b/src/app/(tabs)/remote.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useSyncExternalStore } from 'react'; +import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useBridgeSnapshot, useSwitchifyBridge } from '@/bridge/BridgeContext'; import { EmptyState } from '@/components/EmptyState'; @@ -16,6 +16,7 @@ import { TypingSurface } from '@/remote/TypingSurface'; import { WindowSurface } from '@/remote/WindowSurface'; import { profilePresentation } from '@/remote/profilePresentation'; import { useProfileStatusAnnouncement } from '@/remote/useProfileStatusAnnouncement'; +import { useScanInterrupt } from '@/scanning/scanInterrupts'; import { usePreferences } from '@/storage/usePreferences'; import { preferencesStore } from '@/storage/PreferencesStore'; import { ForwardingRestoreState, ForwardingSurface, shouldClearForwardingRestore } from '@/forwarding/ForwardingSurface'; @@ -35,6 +36,12 @@ export default function RemoteScreen() { const session = useMemo(() => new RemoteSession(manager, profile, undefined, desktopId, bridge), [manager, desktopId, profile, bridge]); const sessionState = useSyncExternalStore(session.subscribe, session.snapshot, session.snapshot); useProfileStatusAnnouncement(profileStatus); + // While movement or a key repeats, any switch press stops it, as on Switchify Android. + useScanInterrupt(useCallback(() => { + if (!session.snapshot().repeat) return false; + void session.stopRepeat(); + return true; + }, [session])); usePreferredPcConnection(manager); useEffect(() => { if (params.surface === 'mouse' || params.surface === 'forwarding') void preferencesStore.update({ surface: params.surface }); }, [params.surface]); useEffect(() => manager.registerCleanup(() => session.cleanup()), [manager, session]); diff --git a/src/components/ResponsiveGrid.tsx b/src/components/ResponsiveGrid.tsx index 46e962c..026d1f2 100644 --- a/src/components/ResponsiveGrid.tsx +++ b/src/components/ResponsiveGrid.tsx @@ -1,6 +1,8 @@ -import { Children, type ReactNode, useMemo, useState } from 'react'; +import { Children, type ReactNode, useContext, useMemo, useState } from 'react'; import { View } from 'react-native'; +import { ScanRowsContext } from '@/scanning/ScanningContext'; +import { ScanSection } from '@/scanning/ScanSection'; import { useLayout, useTheme } from '@/theme/ThemeContext'; export function computeGridColumns(width: number, minItemWidth: number, gap: number, fontScale: number, maxColumns = Number.MAX_SAFE_INTEGER): number { @@ -22,7 +24,8 @@ export function ResponsiveGrid({ minItemWidth, gap: gapOverride, maxColumns = Nu testID?: string; }) { const { fontScale } = useLayout(); - const { spacing } = useTheme(); + const { radii, spacing } = useTheme(); + const scanRows = useContext(ScanRowsContext); const gap = gapOverride ?? spacing.sm; const [width, setWidth] = useState(0); const items = Children.toArray(children); @@ -38,8 +41,13 @@ export function ResponsiveGrid({ minItemWidth, gap: gapOverride, maxColumns = Nu const next = Math.round(event.nativeEvent.layout.width); setWidth((current) => current === next ? current : next); }} style={{ gap }}> - {rows.map((row, rowIndex) => - {row.map((item, columnIndex) => {item})} - )} + {rows.map((row, rowIndex) => { + const cells = row.map((item, columnIndex) => {item}); + const style = { alignItems: 'stretch' as const, flexDirection: 'row' as const, gap }; + // In row scanning, a row of several controls is one stop; a lone control stays direct. + return scanRows && rows.length > 1 && row.length > 1 + ? {cells} + : {cells}; + })} ; } diff --git a/src/scanning/ScanHighlight.tsx b/src/scanning/ScanHighlight.tsx index 6cf2471..fb6611d 100644 --- a/src/scanning/ScanHighlight.tsx +++ b/src/scanning/ScanHighlight.tsx @@ -27,7 +27,7 @@ export function ScanItemHighlight({ highlighted, groupHighlighted = false, radiu } /** The highlight around a scanned section, and its way out once entered. */ -export function ScanGroupHighlight({ highlighted, entered, escapeHighlighted, radius }: { highlighted: boolean; entered: boolean; escapeHighlighted: boolean; radius: number }) { +export function ScanGroupHighlight({ highlighted, entered, escapeHighlighted, radius, leaveLabel = 'Leave section' }: { highlighted: boolean; entered: boolean; escapeHighlighted: boolean; radius: number; leaveLabel?: string }) { const { colors, spacing, typography } = useTheme(); if (!highlighted && !escapeHighlighted && !entered) return null; return {escapeHighlighted ? - Leave section + {leaveLabel} : null} ; } diff --git a/src/scanning/ScanSection.tsx b/src/scanning/ScanSection.tsx index 69c79b1..fe1b0ec 100644 --- a/src/scanning/ScanSection.tsx +++ b/src/scanning/ScanSection.tsx @@ -10,10 +10,10 @@ import { useScannableGroup } from './useScannable'; * then scanned in turn, and a final "Leave section" stop returns to the page. * `exclusive` confines scanning to it while mounted, for dialogs. */ -export function ScanSection({ children, exclusive = false, radius = 16, style }: PropsWithChildren<{ exclusive?: boolean; radius?: number; style?: StyleProp }>) { +export function ScanSection({ children, exclusive = false, radius = 16, style, leaveLabel }: PropsWithChildren<{ exclusive?: boolean; radius?: number; style?: StyleProp; leaveLabel?: string }>) { const { id, attach, onLayout, highlighted, entered, escapeHighlighted } = useScannableGroup({ exclusive }); return {children} - + ; } diff --git a/src/scanning/ScanningContext.ts b/src/scanning/ScanningContext.ts index 5976953..067d110 100644 --- a/src/scanning/ScanningContext.ts +++ b/src/scanning/ScanningContext.ts @@ -8,3 +8,6 @@ export const ScanningEnabledContext = createContext(false); * their controls never join the scan. */ export const ScanVisibleContext = createContext(true); + +/** Grid rows scan as groups of their own. */ +export const ScanRowsContext = createContext(false); diff --git a/src/scanning/ScanningProvider.tsx b/src/scanning/ScanningProvider.tsx index 7ab5496..a06397f 100644 --- a/src/scanning/ScanningProvider.tsx +++ b/src/scanning/ScanningProvider.tsx @@ -4,7 +4,7 @@ import type { PropsWithChildren } from 'react'; import { View } from 'react-native'; import { usePreferences } from '@/storage/usePreferences'; -import { ScanningEnabledContext } from './ScanningContext'; +import { ScanningEnabledContext, ScanRowsContext } from './ScanningContext'; import { SwitchInput, switchInputSupported } from './SwitchInput'; /** @@ -17,10 +17,12 @@ export function ScanningProvider({ children }: PropsWithChildren) { const enabled = scanning.enabled && switchInputSupported; return - - {children} - {enabled ? : null} - + + + {children} + {enabled ? : null} + + ; } diff --git a/src/scanning/ScanningSettings.tsx b/src/scanning/ScanningSettings.tsx index 2bd9b10..922e681 100644 --- a/src/scanning/ScanningSettings.tsx +++ b/src/scanning/ScanningSettings.tsx @@ -77,8 +77,9 @@ export function ScanningSettings() { {scanning.automatic ? ({ key: ms, label: `${ms / 1000} seconds` }))} selectedKey={scanning.intervalMs} onSelect={(intervalMs) => save({ ...scanning, intervalMs }).then(() => undefined)} /> : null} - void save({ ...scanning, pattern: 'grouped' })} /> - void save({ ...scanning, pattern: 'linear' })} /> + void save({ ...scanning, pattern: 'grouped', rows: false })} /> + void save({ ...scanning, pattern: 'grouped', rows: true })} /> + void save({ ...scanning, pattern: 'linear', rows: false })} /> Switches {scanning.switches.bindings.map((binding) => diff --git a/src/scanning/SwitchInput.web.tsx b/src/scanning/SwitchInput.web.tsx index 60ebe2e..c06c73f 100644 --- a/src/scanning/SwitchInput.web.tsx +++ b/src/scanning/SwitchInput.web.tsx @@ -5,6 +5,7 @@ import { Text, View } from 'react-native'; import { useTheme } from '@/theme/ThemeContext'; import { useKeyCaptureActive } from './keyCapture'; +import { runScanInterrupts } from './scanInterrupts'; export const switchInputSupported = true; @@ -21,7 +22,7 @@ export function SwitchInput({ settings }: { settings: SwitchSettings }) { const { colors, radii, spacing, typography } = useTheme(); const snapshot = useScanSnapshot(); const capturing = useKeyCaptureActive(); - const { prompt } = useKeyboardSwitches({ settings, enabled: !capturing }); + const { prompt } = useKeyboardSwitches({ settings, enabled: !capturing, interceptPress: runScanInterrupts }); const message = statusMessage(snapshot, prompt ? ACTION_LABELS[prompt.action] : undefined); return {message ? diff --git a/src/scanning/preferences.test.ts b/src/scanning/preferences.test.ts index ffc9e30..2415ea8 100644 --- a/src/scanning/preferences.test.ts +++ b/src/scanning/preferences.test.ts @@ -13,6 +13,7 @@ describe('scanning preferences', () => { automatic: false, intervalMs: 1_500, pattern: 'linear', + rows: false, switches: { holdIntervalMs: 800, bindings: [ { id: 'a', name: 'Select', key: 'Digit1', pressAction: 'select', holdActions: ['reverse'] }, { id: 'b', name: 'Next', key: 'Digit2', pressAction: 'next', holdActions: [] }, @@ -41,4 +42,10 @@ describe('scanning preferences', () => { expect(keyLabel('ArrowUp')).toBe('Up arrow'); expect(keyLabel('F8')).toBe('F8'); }); + + it('keep row scanning only when it was chosen', () => { + expect(normalizeScanning({ pattern: 'grouped', rows: true }).rows).toBe(true); + expect(normalizeScanning({ pattern: 'grouped', rows: 'yes' }).rows).toBe(false); + expect(DEFAULT_SCANNING.rows).toBe(false); + }); }); diff --git a/src/scanning/preferences.ts b/src/scanning/preferences.ts index e6a9134..4b009ce 100644 --- a/src/scanning/preferences.ts +++ b/src/scanning/preferences.ts @@ -13,6 +13,8 @@ export type ScanningPreferences = { automatic: boolean; intervalMs: number; pattern: Pattern; + /** With the grouped pattern, scan each grid row as a group inside its section. */ + rows: boolean; switches: SwitchSettings; }; @@ -23,6 +25,7 @@ export const DEFAULT_SCANNING: ScanningPreferences = { automatic: true, intervalMs: 1_000, pattern: 'grouped', + rows: false, switches: { holdIntervalMs: DEFAULT_SWITCH_SETTINGS.holdIntervalMs, bindings: DEFAULT_SWITCH_SETTINGS.bindings.map((binding) => ({ ...binding, holdActions: [...binding.holdActions] })) }, }; @@ -56,6 +59,7 @@ export function normalizeScanning(raw: unknown): ScanningPreferences { automatic: options.automatic, intervalMs: options.intervalMs, pattern: options.pattern, + rows: value.rows === true, switches: validateSwitchSettings(switches, options.automatic) === null ? switches : DEFAULT_SCANNING.switches, }; } diff --git a/src/scanning/reveal.test.ts b/src/scanning/reveal.test.ts new file mode 100644 index 0000000..515ecd1 --- /dev/null +++ b/src/scanning/reveal.test.ts @@ -0,0 +1,20 @@ +import { revealPlan } from './useScannable'; + +const area = { top: 100, bottom: 700 }; + +describe('revealing the highlighted control', () => { + it('leaves the page still while the control is fully visible', () => { + expect(revealPlan({ top: 200, bottom: 260 }, area)).toBeNull(); + expect(revealPlan({ top: 112, bottom: 688 }, area)).toBeNull(); + }); + + it('centres a control that is hidden or touching an edge', () => { + expect(revealPlan({ top: 690, bottom: 750 }, area)).toBe('center'); + expect(revealPlan({ top: 40, bottom: 100 }, area)).toBe('center'); + expect(revealPlan({ top: 1200, bottom: 1260 }, area)).toBe('center'); + }); + + it('aligns a section too tall to centre with the top', () => { + expect(revealPlan({ top: 650, bottom: 1300 }, area)).toBe('start'); + }); +}); diff --git a/src/scanning/scanInterrupts.test.tsx b/src/scanning/scanInterrupts.test.tsx new file mode 100644 index 0000000..a2535cc --- /dev/null +++ b/src/scanning/scanInterrupts.test.tsx @@ -0,0 +1,24 @@ +import { render } from '@testing-library/react-native'; + +import { runScanInterrupts, useScanInterrupt, type ScanInterrupt } from './scanInterrupts'; + +function Claim({ interrupt }: { interrupt: ScanInterrupt }) { + useScanInterrupt(interrupt); + return null; +} + +describe('scan interrupts', () => { + it('let a mounted screen consume a press only while it needs to', async () => { + let repeating = true; + const stop = jest.fn(); + const interrupt = () => { if (!repeating) return false; repeating = false; stop(); return true; }; + const view = await render(); + expect(runScanInterrupts()).toBe(true); + expect(stop).toHaveBeenCalledTimes(1); + expect(runScanInterrupts()).toBe(false); + repeating = true; + await view.unmount(); + expect(runScanInterrupts()).toBe(false); + expect(stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/scanning/scanInterrupts.ts b/src/scanning/scanInterrupts.ts new file mode 100644 index 0000000..1bf508f --- /dev/null +++ b/src/scanning/scanInterrupts.ts @@ -0,0 +1,23 @@ +import { useEffect } from 'react'; + +/** Returns true when it handled the press, which then never becomes a scan action. */ +export type ScanInterrupt = () => boolean; + +const interrupts = new Set(); + +/** Gives every registered interrupt a chance to consume a switch press. */ +export function runScanInterrupts(): boolean { + for (const interrupt of interrupts) if (interrupt()) return true; + return false; +} + +/** + * Lets a screen claim the next switch press while something is in progress, such + * as a repeating pointer movement that any press should stop. + */ +export function useScanInterrupt(interrupt: ScanInterrupt): void { + useEffect(() => { + interrupts.add(interrupt); + return () => { interrupts.delete(interrupt); }; + }, [interrupt]); +} diff --git a/src/scanning/useScannable.ts b/src/scanning/useScannable.ts index 0f3d5cd..ce9c319 100644 --- a/src/scanning/useScannable.ts +++ b/src/scanning/useScannable.ts @@ -2,6 +2,7 @@ import { useScanGroup, useScanItem, type Measurable } from '@switchify/scanning/ import { type Ref, useCallback, useContext, useEffect, useId, useState } from 'react'; import { Platform, type View } from 'react-native'; +import { useTheme } from '@/theme/ThemeContext'; import { ScanningEnabledContext, ScanVisibleContext } from './ScanningContext'; export type ScanHighlightState = { highlighted: boolean; groupHighlighted?: boolean }; @@ -11,21 +12,47 @@ function assign(target: Ref | undefined, value: T | null): void { else if (target && typeof target === 'object') (target as { current: T | null }).current = value; } +type Span = { top: number; bottom: number }; + +const REVEAL_MARGIN = 12; + +/** + * How to bring a highlighted control into view: not at all while it is fully + * visible, otherwise centred, or aligned to the top when it is too tall to centre. + * Centring leaves the next few stops visible, so the page moves once rather than on + * every step. + */ +export function revealPlan(item: Span, area: Span, margin = REVEAL_MARGIN): ScrollLogicalPosition | null { + if (item.top >= area.top + margin && item.bottom <= area.bottom - margin) return null; + return item.bottom - item.top > (area.bottom - area.top) * 0.8 ? 'start' : 'center'; +} + +function scrollArea(element: HTMLElement): Span { + for (let parent = element.parentElement; parent; parent = parent.parentElement) { + const { overflowY } = getComputedStyle(parent); + if ((overflowY === 'auto' || overflowY === 'scroll') && parent.scrollHeight > parent.clientHeight) return parent.getBoundingClientRect(); + } + return { top: 0, bottom: window.innerHeight }; +} + /** On the web a React Native view is its DOM element, which can scroll itself into view. */ -function scrollIntoView(view: unknown): void { - if (Platform.OS !== 'web') return; - const element = view as { scrollIntoView?: (options: ScrollIntoViewOptions) => void } | null; - element?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); +function reveal(view: unknown, reducedMotion: boolean): void { + if (Platform.OS !== 'web' || typeof window === 'undefined') return; + const element = view as HTMLElement | null; + if (!element?.getBoundingClientRect || !element.scrollIntoView) return; + const block = revealPlan(element.getBoundingClientRect(), scrollArea(element)); + if (block) element.scrollIntoView({ block, inline: 'nearest', behavior: reducedMotion ? 'auto' : 'smooth' }); } function useScanAnchor(highlighted: boolean, measuredRef: (node: V | null) => void, forwarded?: Ref | undefined) { + const { reducedMotion } = useTheme(); const [view, setView] = useState(null); const attach = useCallback((node: V | null) => { setView(node); measuredRef(node); assign(forwarded, node); }, [measuredRef, forwarded]); - useEffect(() => { if (highlighted) scrollIntoView(view); }, [highlighted, view]); + useEffect(() => { if (highlighted) reveal(view, reducedMotion); }, [highlighted, view, reducedMotion]); return attach; } From 3dc841d2e5131cec9e3834145da57e9078770c4c Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 9 Oct 2026 15:11:04 +0100 Subject: [PATCH 2/2] feat(scanning): hold scanning during repeats and set up manual switches - Hold automatic scanning on the chosen control while pointer movement or a key repeats, using @switchify/scanning v0.5.0's holdMovement - Choosing Manual adds any missing Next or Previous switch on a free key (Enter and Backspace first), so manual scanning works with the default switches - Test the switch helper and document both Co-Authored-By: Claude Opus 5.5 --- docs/web.md | 4 ++-- package-lock.json | 6 +++--- package.json | 2 +- src/app/(tabs)/remote.tsx | 8 ++++++++ src/scanning/ScanningSettings.tsx | 4 ++-- src/scanning/preferences.test.ts | 10 +++++++++- src/scanning/preferences.ts | 22 ++++++++++++++++++++++ 7 files changed, 47 insertions(+), 9 deletions(-) diff --git a/docs/web.md b/docs/web.md index ed6c43b..c2ac091 100644 --- a/docs/web.md +++ b/docs/web.md @@ -24,8 +24,8 @@ The web app is a progressive web app, so Chrome and Edge offer to install it to Switch users can operate Remote with keyboard-style switch interfaces, which send key presses such as Space or Enter. Scanning uses [@switchify/scanning](https://github.com/switchifyapp/switchify-scanning), the TypeScript port of Switchify PC's item scanner, so timing, passes, groups and hold actions behave as they do on the PC. -- **Settings:** turn it on in Settings → Switch scanning, which is off by default. Then choose automatic or manual movement, the scan speed, and how to group controls: "Sections" scans each section then its controls, "Rows" scans each section, then each row of a grid, then its controls, and "Every control" visits every control in turn. Assign each switch by choosing Set key and pressing the switch. Each switch has a press action and an optional hold action. Settings that cannot drive scanning are refused, for example ones with no Select, or manual scanning without Next and Previous. -- **Using it:** Select starts scanning, and Escape stops it. In grouped mode each surface section is one stop. Select enters it, its controls are scanned in turn, and a final "Leave section" stop returns to the page. In Rows mode each grid row of several controls is a stop of its own, ending with "Leave row". Open dialogs (selectors, the PC switcher, the layout editor and the action picker) confine scanning to themselves. Controls on hidden tabs are never scanned. The page only scrolls when the highlighted control is not fully visible, and it then centres the control smoothly (instantly with reduced motion), so the next few stops are already on screen. Automatic movement pauses while a switch is held. While pointer movement or a key is repeating, the next switch press stops the repeat and does nothing else, as on Switchify Android. +- **Settings:** turn it on in Settings → Switch scanning, which is off by default. Then choose automatic or manual movement, the scan speed, and how to group controls: "Sections" scans each section then its controls, "Rows" scans each section, then each row of a grid, then its controls, and "Every control" visits every control in turn. Assign each switch by choosing Set key and pressing the switch. Each switch has a press action and an optional hold action. Choosing Manual adds any missing Next or Previous switch on a free key (Enter and Backspace first, as in Switchify PC). Settings that cannot drive scanning are refused, for example ones with no Select. +- **Using it:** Select starts scanning, and Escape stops it. In grouped mode each surface section is one stop. Select enters it, its controls are scanned in turn, and a final "Leave section" stop returns to the page. In Rows mode each grid row of several controls is a stop of its own, ending with "Leave row". Open dialogs (selectors, the PC switcher, the layout editor and the action picker) confine scanning to themselves. Controls on hidden tabs are never scanned. The page only scrolls when the highlighted control is not fully visible, and it then centres the control smoothly (instantly with reduced motion), so the next few stops are already on screen. Automatic movement pauses while a switch is held. While pointer movement or a key is repeating, the next switch press stops the repeat and does nothing else, as on Switchify Android. Automatic scanning holds on the chosen control until the repeat stops. - **Implementation:** `src/scanning/` holds the provider, the `useScannable` hook used by `ControlButton`, `ActionButton`, `IconButton`, `ListRow`, `SelectorField`, the PC switcher and the tab bar, the highlight rings, and the settings card. Scanning settings live in `switchify.remote.preferences.v1` under `scanning`, and unknown or unusable values fall back on their own. - **Limits:** keys still type into a text field while one has focus, so live typing is not yet switch-accessible. Native builds have no global key events, so scanning is web-only for now. diff --git a/package-lock.json b/package-lock.json index dce5bd5..fb3e290 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@noble/hashes": "^2.3.0", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/native": "^7.3.16", - "@switchify/scanning": "github:switchifyapp/switchify-scanning#v0.4.0", + "@switchify/scanning": "github:switchifyapp/switchify-scanning#v0.5.0", "base64-js": "^1.5.1", "expo": "~57.0.27", "expo-blur": "~57.0.3", @@ -3880,8 +3880,8 @@ } }, "node_modules/@switchify/scanning": { - "version": "0.4.0", - "resolved": "git+ssh://git@github.com/switchifyapp/switchify-scanning.git#2386313285d3b7385bae22eec44d9b7513119581", + "version": "0.5.0", + "resolved": "git+ssh://git@github.com/switchifyapp/switchify-scanning.git#e9ca2ec8460c70f58d9f002e0a9739c30f40bd14", "license": "AGPL-3.0-only", "engines": { "node": ">=20" diff --git a/package.json b/package.json index fa0a927..51dee06 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@noble/hashes": "^2.3.0", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/native": "^7.3.16", - "@switchify/scanning": "github:switchifyapp/switchify-scanning#v0.4.0", + "@switchify/scanning": "github:switchifyapp/switchify-scanning#v0.5.0", "base64-js": "^1.5.1", "expo": "~57.0.27", "expo-blur": "~57.0.3", diff --git a/src/app/(tabs)/remote.tsx b/src/app/(tabs)/remote.tsx index 1a0d5a4..f3a821e 100644 --- a/src/app/(tabs)/remote.tsx +++ b/src/app/(tabs)/remote.tsx @@ -16,6 +16,7 @@ import { TypingSurface } from '@/remote/TypingSurface'; import { WindowSurface } from '@/remote/WindowSurface'; import { profilePresentation } from '@/remote/profilePresentation'; import { useProfileStatusAnnouncement } from '@/remote/useProfileStatusAnnouncement'; +import { useScanController } from '@switchify/scanning/native'; import { useScanInterrupt } from '@/scanning/scanInterrupts'; import { usePreferences } from '@/storage/usePreferences'; import { preferencesStore } from '@/storage/PreferencesStore'; @@ -42,6 +43,13 @@ export default function RemoteScreen() { void session.stopRepeat(); return true; }, [session])); + // Automatic scanning stays on the chosen control until the repeat stops. + const scanController = useScanController(); + const repeating = sessionState.repeat !== null; + useEffect(() => { + scanController.holdMovement('repeat', repeating); + return () => scanController.holdMovement('repeat', false); + }, [scanController, repeating]); usePreferredPcConnection(manager); useEffect(() => { if (params.surface === 'mouse' || params.surface === 'forwarding') void preferencesStore.update({ surface: params.surface }); }, [params.surface]); useEffect(() => manager.registerCleanup(() => session.cleanup()), [manager, session]); diff --git a/src/scanning/ScanningSettings.tsx b/src/scanning/ScanningSettings.tsx index 922e681..c179a89 100644 --- a/src/scanning/ScanningSettings.tsx +++ b/src/scanning/ScanningSettings.tsx @@ -10,7 +10,7 @@ import { preferencesStore } from '@/storage/PreferencesStore'; import { usePreferences } from '@/storage/usePreferences'; import { useTheme } from '@/theme/ThemeContext'; import { captureNextKey } from './keyCapture'; -import { keyLabel, SCAN_SPEEDS_MS, type ScanningPreferences } from './preferences'; +import { keyLabel, SCAN_SPEEDS_MS, withManualSwitches, type ScanningPreferences } from './preferences'; const PRESS_ACTIONS: ScanAction[] = ['select', 'next', 'back', 'pause', 'reverse', 'stop']; const HOLD_CHOICES: { key: string; label: string; actions: ScanAction[] }[] = [ @@ -73,7 +73,7 @@ export function ScanningSettings() { {scanning.enabled ? <> void save({ ...scanning, automatic: true })} /> - void save({ ...scanning, automatic: false })} /> + void save({ ...scanning, automatic: false, switches: withManualSwitches(scanning.switches) })} /> {scanning.automatic ? ({ key: ms, label: `${ms / 1000} seconds` }))} selectedKey={scanning.intervalMs} onSelect={(intervalMs) => save({ ...scanning, intervalMs }).then(() => undefined)} /> : null} diff --git a/src/scanning/preferences.test.ts b/src/scanning/preferences.test.ts index 2415ea8..de535f7 100644 --- a/src/scanning/preferences.test.ts +++ b/src/scanning/preferences.test.ts @@ -1,4 +1,4 @@ -import { DEFAULT_SCANNING, keyLabel, normalizeScanning } from './preferences'; +import { DEFAULT_SCANNING, keyLabel, normalizeScanning, withManualSwitches } from './preferences'; describe('scanning preferences', () => { it('are off by default with a Select and a Next switch', () => { @@ -48,4 +48,12 @@ describe('scanning preferences', () => { expect(normalizeScanning({ pattern: 'grouped', rows: 'yes' }).rows).toBe(false); expect(DEFAULT_SCANNING.rows).toBe(false); }); + + it('add the Next and Previous switches manual scanning needs, on free keys', () => { + const manual = withManualSwitches(DEFAULT_SCANNING.switches); + expect(manual.bindings.map((binding) => [binding.key, binding.pressAction])).toEqual([['Space', 'select'], ['Enter', 'next'], ['Backspace', 'back']]); + expect(withManualSwitches(manual)).toEqual(manual); + const backspaceTaken = withManualSwitches({ holdIntervalMs: 1_000, bindings: [{ id: 'a', name: 'Select', key: 'Backspace', pressAction: 'select', holdActions: [] }] }); + expect(backspaceTaken.bindings.map((binding) => binding.key)).toEqual(['Backspace', 'Enter', 'ArrowLeft']); + }); }); diff --git a/src/scanning/preferences.ts b/src/scanning/preferences.ts index 4b009ce..918f354 100644 --- a/src/scanning/preferences.ts +++ b/src/scanning/preferences.ts @@ -64,6 +64,28 @@ export function normalizeScanning(raw: unknown): ScanningPreferences { }; } +const MANUAL_KEYS: Record<'next' | 'back', string[]> = { + next: ['Enter', 'ArrowRight', 'Digit2', 'F2'], + back: ['Backspace', 'ArrowLeft', 'Digit3', 'F3'], +}; + +/** + * Manual scanning needs switches for Next and Previous. Adds any that are missing on + * a free key (Enter and Backspace first, as in Switchify PC), so choosing Manual works + * straight away. The keys can be changed afterwards. + */ +export function withManualSwitches(switches: SwitchSettings): SwitchSettings { + const bindings = [...switches.bindings]; + for (const action of ['next', 'back'] as const) { + if (bindings.some((item) => item.pressAction === action || item.holdActions.includes(action))) continue; + const used = new Set(bindings.map((item) => item.key)); + const key = MANUAL_KEYS[action].find((candidate) => !used.has(candidate)); + if (!key) continue; + bindings.push({ id: `switch-${action}`, name: action === 'next' ? 'Next' : 'Previous', key, pressAction: action, holdActions: [] }); + } + return { ...switches, bindings }; +} + /** A readable name for a KeyboardEvent.code, for showing which key a switch sends. */ export function keyLabel(code: string): string { if (code === 'Space') return 'Space';