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
4 changes: 2 additions & 2 deletions docs/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.

Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.5.0",
"base64-js": "^1.5.1",
"expo": "~57.0.27",
"expo-blur": "~57.0.3",
Expand Down
17 changes: 16 additions & 1 deletion src/app/(tabs)/remote.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,6 +16,8 @@ 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';
import { ForwardingRestoreState, ForwardingSurface, shouldClearForwardingRestore } from '@/forwarding/ForwardingSurface';
Expand All @@ -35,6 +37,19 @@ 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]));
// 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]);
Expand Down
18 changes: 13 additions & 5 deletions src/components/ResponsiveGrid.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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);
Expand All @@ -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) => <View key={rowIndex} style={{ alignItems: 'stretch', flexDirection: 'row', gap }}>
{row.map((item, columnIndex) => <View key={`${rowIndex}-${columnIndex}`} testID={testID ? `${testID}-cell-${rowIndex * columns + columnIndex}` : undefined} style={cellWidth === null ? { flex: 1, minWidth: 0 } : { minWidth: 0, width: cellWidth }}>{item}</View>)}
</View>)}
{rows.map((row, rowIndex) => {
const cells = row.map((item, columnIndex) => <View key={`${rowIndex}-${columnIndex}`} testID={testID ? `${testID}-cell-${rowIndex * columns + columnIndex}` : undefined} style={cellWidth === null ? { flex: 1, minWidth: 0 } : { minWidth: 0, width: cellWidth }}>{item}</View>);
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
? <ScanSection key={rowIndex} leaveLabel="Leave row" radius={radii.md} style={style}>{cells}</ScanSection>
: <View key={rowIndex} style={style}>{cells}</View>;
})}
</View>;
}
4 changes: 2 additions & 2 deletions src/scanning/ScanHighlight.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <View
Expand All @@ -48,7 +48,7 @@ export function ScanGroupHighlight({ highlighted, entered, escapeHighlighted, ra
>
{escapeHighlighted ? <View style={{ alignItems: 'center', alignSelf: 'flex-end', backgroundColor: colors.text, borderBottomLeftRadius: radius, flexDirection: 'row', gap: spacing.xs, paddingHorizontal: spacing.sm, paddingVertical: spacing.xs }}>
<MaterialIcons color={colors.background} name="undo" size={16} />
<Text style={[typography.label, { color: colors.background, fontSize: 14 }]}>Leave section</Text>
<Text style={[typography.label, { color: colors.background, fontSize: 14 }]}>{leaveLabel}</Text>
</View> : null}
</View>;
}
4 changes: 2 additions & 2 deletions src/scanning/ScanSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ViewStyle> }>) {
export function ScanSection({ children, exclusive = false, radius = 16, style, leaveLabel }: PropsWithChildren<{ exclusive?: boolean; radius?: number; style?: StyleProp<ViewStyle>; leaveLabel?: string }>) {
const { id, attach, onLayout, highlighted, entered, escapeHighlighted } = useScannableGroup({ exclusive });
return <View ref={attach} onLayout={onLayout} style={[{ position: 'relative' }, style]}>
<ScanGroupScope id={id}>{children}</ScanGroupScope>
<ScanGroupHighlight highlighted={highlighted} entered={entered} escapeHighlighted={escapeHighlighted} radius={radius} />
<ScanGroupHighlight highlighted={highlighted} entered={entered} escapeHighlighted={escapeHighlighted} radius={radius} {...(leaveLabel ? { leaveLabel } : {})} />
</View>;
}
3 changes: 3 additions & 0 deletions src/scanning/ScanningContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
12 changes: 7 additions & 5 deletions src/scanning/ScanningProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -17,10 +17,12 @@ export function ScanningProvider({ children }: PropsWithChildren) {
const enabled = scanning.enabled && switchInputSupported;
return <ScanProvider policy={Policy.MENU} options={{ automatic: scanning.automatic, intervalMs: scanning.intervalMs, pattern: scanning.pattern, passLimit: 3 }}>
<ScanningEnabledContext.Provider value={enabled}>
<View style={{ flex: 1 }}>
{children}
{enabled ? <SwitchInput settings={scanning.switches} /> : null}
</View>
<ScanRowsContext.Provider value={enabled && scanning.pattern === 'grouped' && scanning.rows}>
<View style={{ flex: 1 }}>
{children}
{enabled ? <SwitchInput settings={scanning.switches} /> : null}
</View>
</ScanRowsContext.Provider>
</ScanningEnabledContext.Provider>
</ScanProvider>;
}
9 changes: 5 additions & 4 deletions src/scanning/ScanningSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }[] = [
Expand Down Expand Up @@ -73,12 +73,13 @@ export function ScanningSettings() {
{scanning.enabled ? <>
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
<ControlButton label="Automatic" selected={scanning.automatic} onPress={() => void save({ ...scanning, automatic: true })} />
<ControlButton label="Manual" selected={!scanning.automatic} onPress={() => void save({ ...scanning, automatic: false })} />
<ControlButton label="Manual" selected={!scanning.automatic} onPress={() => void save({ ...scanning, automatic: false, switches: withManualSwitches(scanning.switches) })} />
</View>
{scanning.automatic ? <SelectorField label="Scan speed" options={SCAN_SPEEDS_MS.map((ms) => ({ key: ms, label: `${ms / 1000} seconds` }))} selectedKey={scanning.intervalMs} onSelect={(intervalMs) => save({ ...scanning, intervalMs }).then(() => undefined)} /> : null}
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
<ControlButton label="Sections first" selected={scanning.pattern === 'grouped'} onPress={() => void save({ ...scanning, pattern: 'grouped' })} />
<ControlButton label="Every control" selected={scanning.pattern === 'linear'} onPress={() => void save({ ...scanning, pattern: 'linear' })} />
<ControlButton label="Sections" selected={scanning.pattern === 'grouped' && !scanning.rows} onPress={() => void save({ ...scanning, pattern: 'grouped', rows: false })} />
<ControlButton label="Rows" selected={scanning.pattern === 'grouped' && scanning.rows} onPress={() => void save({ ...scanning, pattern: 'grouped', rows: true })} />
<ControlButton label="Every control" selected={scanning.pattern === 'linear'} onPress={() => void save({ ...scanning, pattern: 'linear', rows: false })} />
</View>
<AppText variant="label">Switches</AppText>
{scanning.switches.bindings.map((binding) => <View key={binding.id} style={{ borderColor: colors.border, borderRadius: 12, borderWidth: 1, gap: spacing.sm, padding: spacing.md }}>
Expand Down
3 changes: 2 additions & 1 deletion src/scanning/SwitchInput.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 <View accessibilityLiveRegion="polite" pointerEvents="none" style={{ alignItems: 'center', bottom: spacing.xxxl * 3, left: 0, position: 'absolute', right: 0 }}>
{message ? <View testID="scan-status" style={{ backgroundColor: colors.text, borderRadius: radii.pill, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm }}>
Expand Down
17 changes: 16 additions & 1 deletion src/scanning/preferences.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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: [] },
Expand Down Expand Up @@ -41,4 +42,18 @@ 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);
});

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']);
});
});
Loading
Loading