diff --git a/docs/accessibility.md b/docs/accessibility.md index 94548fd..d1afd87 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -50,6 +50,15 @@ Save persists only the selected section and preserves neighboring sections. An u - Scroll content clears the bottom tab bar, gesture area, and home indicator at maximum text size. - No essential interaction depends on animation, gestures, precise timing, or simultaneous touch. +## Switch scanning (web) + +- Every interactive control can be reached and activated by scanning. Activation does exactly what a press does. +- The highlight is a 4px ring in the text colour, visible on surfaces, selected (red) buttons and dialogs in light and dark themes. It is never conveyed by colour alone. +- Sections show an outline while entered, and a labelled "Leave section" stop. +- Hold prompts and pauses are shown as text in a polite live region. +- Dialogs confine scanning, and hidden tabs are never scanned. +- Assigning a key never lets the switch press or release activate the focused control. + ## Manual smoke test On both platforms, clear the app data before starting. Enable the screen reader and then the platform switch-access feature. Traverse both setup steps and confirm the headings, step positions, explanations, desktop download, Continue, Back, Not now, and Allow Bluetooth are announced in logical order. Confirm no Bluetooth prompt appears before Allow Bluetooth, and that the layout remains usable in both orientations and at 200% text. Then navigate PCs, pairing, every available remote screen, Settings, and Diagnostics in logical order. Confirm every action can be selected, read-only text does not become a switch-scan stop, pointer repeat exposes Stop movement, held modifiers and drag expose state, disabled controls are announced, and backgrounding returns the desktop to a neutral input state. In live typing, send several lines with software Return and the visible Enter control. Confirm each sends one Enter, clears only after success, restores the text field and software keyboard, and accepts the next line without backspaces. Simulate a failed Enter and confirm the text remains, focus returns, and Retry Enter sends no duplicate text. On each connected Remote control screen, scroll past the title and confirm the Surface selector stays visible, gains its backdrop only while pinned, remains one scan stop, and causes no focus move or announcement. Confirm Scroll to top then appears as one 48-point scan stop above the PC switcher, does not cover the final control, returns to the title, announces completion once, and disappears at the top. Interrupt the return gesture and confirm it remains usable without a stale announcement. Repeat with Reduce Motion and confirm the return is immediate. Confirm disconnected, recovering, and unavailable states do not expose Scroll to top. On Android, confirm Forwarding keeps its profile choices available before Start but adds mapped external switches to scanning only while forwarding is active. The mapped switches and overflow notice must disappear after manual or safety stops. In Settings, edit the Remote name and confirm validation and save status are announced without adding read-only scan stops. Confirm Use device model restores the model-based name. Confirm Privacy policy is one scan stop, announces that it opens in a browser, opens the Remote-specific policy, and returns safely if the browser cannot open. During a delayed pointer-profile response, confirm "Restoring controls" is announced once, followed by either restored controls or "Controls unavailable" after all retries. Open the Surface selector while it is pinned and confirm the underlying screen and bottom tabs are not scannable, options scan linearly, Close works, Android back and VoiceOver escape dismiss the modal, and focus returns to the selector after selection or dismissal. Repeat those checks with the Remote PC switcher, confirm it stays above the tabs while content scrolls, and switch between two saved PCs. Press Unpair for a saved computer and confirm the native alert reads the full computer name. Verify Cancel, Android Back, and outside dismissal preserve the pairing, then confirm Unpair removes it. Repeat in light and dark appearance, at 200% text scaling, with reduced motion enabled, and with iOS Reduce Transparency enabled. diff --git a/docs/web.md b/docs/web.md index dcbb671..600ceb5 100644 --- a/docs/web.md +++ b/docs/web.md @@ -20,6 +20,15 @@ The web app is a progressive web app, so Chrome and Edge offer to install it to - The service worker fetches pages from the network first and falls back to the cache when offline, so a new deployment is never stuck behind an old copy. Content-hashed bundles and assets under `/_expo/static/` and `/assets/` are cached once and reused. Bluetooth still needs the PC nearby; offline only means the app opens. - `vercel.json` serves `sw.js` and the manifest with `no-cache`, and the hashed bundles as immutable. To change caching behaviour, change the `CACHE` name in `sw.js` so old caches are cleared on activation. +## Switch scanning + +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. +- **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. + ## Supported browsers Web Bluetooth is available in Chrome and Edge on Android, Windows, macOS and ChromeOS. Safari, Firefox and every iOS browser lack it. In those browsers the PCs screen shows that Bluetooth is unavailable and names the supported browsers. diff --git a/package-lock.json b/package-lock.json index a92e060..0768355 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +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", "base64-js": "^1.5.1", "expo": "~57.0.27", "expo-blur": "~57.0.3", @@ -3878,6 +3879,22 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@switchify/scanning": { + "version": "0.3.1", + "resolved": "git+ssh://git@github.com/switchifyapp/switchify-scanning.git#ae1db07bab3e3df89b55944d3f518bca7a34919d", + "license": "AGPL-3.0-only", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/@testing-library/react-native": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-14.0.1.tgz", diff --git a/package.json b/package.json index 339baaf..4b8848c 100644 --- a/package.json +++ b/package.json @@ -8,6 +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", "base64-js": "^1.5.1", "expo": "~57.0.27", "expo-blur": "~57.0.3", diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index 720623e..9ce818a 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { computeBottomTabBarHeight } from '@/navigation/bottomTabBarLayout'; import { tabDefinitions } from '@/navigation/tabDefinitions'; import { tabSceneLayout } from '@/navigation/tabSceneLayout'; +import { ScannableTabButton } from '@/navigation/ScannableTabButton'; import { useTheme } from '@/theme/ThemeContext'; const icons = { index: 'computer', remote: 'settings-remote', settings: 'settings' } as const; @@ -22,6 +23,7 @@ export default function TabsLayout() { tabBarActiveTintColor: scheme === 'dark' ? colors.brandText : colors.brand, tabBarInactiveTintColor: colors.textMuted, tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.border, height: tabBarHeight }, + tabBarButton: (props) => , tabBarIcon: ({ color, size }) => , })}> {tabDefinitions.map((tab) => )} diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx index eb0f11f..c670133 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(tabs)/settings.tsx @@ -11,6 +11,9 @@ import { PrivacyPolicyLink } from '@/components/PrivacyPolicyLink'; import { RemoteNameEditor, type RemoteNameSaveResult } from '@/components/RemoteNameEditor'; import { Screen } from '@/components/Screen'; import { SelectorField } from '@/components/SelectorField'; +import { ScanningSettings } from '@/scanning/ScanningSettings'; +import { ScanSection } from '@/scanning/ScanSection'; +import { switchInputSupported } from '@/scanning/SwitchInput'; import { useConnectionManager, useConnectionState } from '@/connection/ConnectionContext'; import type { SavedPc } from '@/storage/PairingStore'; import { preferencesStore } from '@/storage/PreferencesStore'; @@ -37,6 +40,7 @@ export default function SettingsScreen() { return ({ key: surface, label: surface[0]!.toUpperCase() + surface.slice(1) }))} selectedKey={preferences.surface} onSelect={(surface) => preferencesStore.update({ surface })} /> + {switchInputSupported ? : null} void preferencesStore.update({ typingMode: 'live' })} /> void preferencesStore.update({ typingMode: 'draft' })} /> {saved.length === 0 ? : void setDefault(null)} />{saved.map((pc) => void setDefault(pc.desktopId)} />)}} @@ -48,6 +52,6 @@ export default function SettingsScreen() { } function SettingCard({ title, description, children }: { title: string; description: string; children: ReactNode }) { - const { spacing } = useTheme(); - return {title}{description}{children}; + const { radii, spacing } = useTheme(); + return {title}{description}{children}; } diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 3bcc34d..5968e8c 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -8,6 +8,7 @@ import { diagnosticsScreenOptions } from '@/navigation/diagnosticsScreenOptions' import { FirstRunSetupGate } from '@/onboarding/FirstRunSetup'; import { StoreCaptureRoot, storeCaptureEnabled } from '@/store-capture/StoreCaptureRoot'; import { installPlatform } from '@/platform/installPlatform'; +import { ScanningProvider } from '@/scanning/ScanningProvider'; import { ThemeProvider, useTheme } from '@/theme/ThemeContext'; installPlatform(); @@ -15,7 +16,7 @@ installPlatform(); export default function RootLayout() { return ( - {storeCaptureEnabled() ? : } + {storeCaptureEnabled() ? : } ); } diff --git a/src/components/ActionButton.tsx b/src/components/ActionButton.tsx index 87cba71..db94578 100644 --- a/src/components/ActionButton.tsx +++ b/src/components/ActionButton.tsx @@ -4,6 +4,8 @@ import { ActivityIndicator, Animated, Platform, Pressable, Text, View, type View import { webControlAccessibility } from './webControlAccessibility'; import { useTheme } from '@/theme/ThemeContext'; import { usePressScale } from './usePressScale'; +import { ScanItemHighlight } from '@/scanning/ScanHighlight'; +import { useScannable } from '@/scanning/useScannable'; type Tone = 'primary' | 'secondary' | 'tertiary' | 'danger'; type IconName = ComponentProps['name']; @@ -11,15 +13,17 @@ type IconName = ComponentProps['name']; export function ActionButton({ label, onPress, disabled = false, busy = false, secondary = false, tone, icon, controlRef }: { label: string; onPress: () => void; disabled?: boolean; busy?: boolean; secondary?: boolean; tone?: Tone; icon?: IconName; controlRef?: Ref }) { const { colors, radii, spacing, typography } = useTheme(); const press = usePressScale(); + const { attach: scanAttach, onLayout: scanLayout, highlighted: scanHighlighted, groupHighlighted: scanGroupHighlighted } = useScannable({ onActivate: onPress, disabled: disabled || busy, controlRef }); const resolvedTone = tone ?? (secondary ? 'secondary' : 'primary'); const background = resolvedTone === 'primary' ? colors.brand : resolvedTone === 'danger' ? colors.dangerTint : resolvedTone === 'secondary' ? colors.surfaceRaised : 'transparent'; const foreground = resolvedTone === 'primary' ? colors.onBrand : resolvedTone === 'danger' ? colors.danger : resolvedTone === 'tertiary' ? colors.brandText : colors.text; return ( - ({ alignItems: 'center', backgroundColor: pressed && !disabled ? (resolvedTone === 'primary' ? colors.brandPressed : colors.surfacePressed) : background, borderColor: resolvedTone === 'tertiary' ? 'transparent' : resolvedTone === 'danger' ? colors.danger : colors.border, borderRadius: radii.md, borderWidth: resolvedTone === 'primary' ? 0 : 1, justifyContent: 'center', minHeight: 52, opacity: disabled ? 0.4 : 1, paddingHorizontal: spacing.lg })}> + ({ alignItems: 'center', backgroundColor: pressed && !disabled ? (resolvedTone === 'primary' ? colors.brandPressed : colors.surfacePressed) : background, borderColor: resolvedTone === 'tertiary' ? 'transparent' : resolvedTone === 'danger' ? colors.danger : colors.border, borderRadius: radii.md, borderWidth: resolvedTone === 'primary' ? 0 : 1, justifyContent: 'center', minHeight: 52, opacity: disabled ? 0.4 : 1, paddingHorizontal: spacing.lg })}> {busy ? : icon ? : null} {label} + ); } diff --git a/src/components/ControlButton.tsx b/src/components/ControlButton.tsx index bc7a225..424da21 100644 --- a/src/components/ControlButton.tsx +++ b/src/components/ControlButton.tsx @@ -10,6 +10,8 @@ import { } from "react-native"; import { useTheme } from "@/theme/ThemeContext"; import { usePressScale } from "./usePressScale"; +import { ScanItemHighlight } from '@/scanning/ScanHighlight'; +import { useScannable } from '@/scanning/useScannable'; import { webControlAccessibility } from './webControlAccessibility'; type IconName = ComponentProps["name"]; @@ -47,6 +49,7 @@ export function ControlButton({ }) { const { colors, radii, spacing, typography } = useTheme(); const press = usePressScale(selected); + const { attach: scanAttach, onLayout: scanLayout, highlighted: scanHighlighted, groupHighlighted: scanGroupHighlighted } = useScannable({ onActivate: onPress, disabled, controlRef }); return ( ) : null} + ); diff --git a/src/components/IconButton.tsx b/src/components/IconButton.tsx index 146bdc2..6522d5d 100644 --- a/src/components/IconButton.tsx +++ b/src/components/IconButton.tsx @@ -3,6 +3,8 @@ import type { ComponentProps } from "react"; import { Animated, Platform, Pressable } from "react-native"; import { useTheme } from "@/theme/ThemeContext"; import { usePressScale } from "./usePressScale"; +import { ScanItemHighlight } from '@/scanning/ScanHighlight'; +import { useScannable } from '@/scanning/useScannable'; import { webControlAccessibility } from './webControlAccessibility'; export function IconButton({ @@ -22,6 +24,7 @@ export function IconButton({ }) { const { colors, radii } = useTheme(); const press = usePressScale(selected); + const { attach: scanAttach, onLayout: scanLayout, highlighted: scanHighlighted, groupHighlighted: scanGroupHighlighted } = useScannable({ onActivate: onPress, disabled }); return ( + ); diff --git a/src/components/ListRow.tsx b/src/components/ListRow.tsx index aeb634c..addd930 100644 --- a/src/components/ListRow.tsx +++ b/src/components/ListRow.tsx @@ -4,11 +4,14 @@ import { Pressable, View } from 'react-native'; import { AppText } from './AppText'; import { useTheme } from '@/theme/ThemeContext'; +import { ScanItemHighlight } from '@/scanning/ScanHighlight'; +import { useScannable } from '@/scanning/useScannable'; type IconName = ComponentProps['name']; export function ListRow({ title, description, icon, hint, onPress }: { title: string; description?: string; icon?: IconName; hint?: string; onPress?: () => void }) { const { colors, radii, spacing } = useTheme(); + const { attach: scanAttach, onLayout: scanLayout, highlighted: scanHighlighted, groupHighlighted: scanGroupHighlighted } = useScannable({ onActivate: onPress }); const content = <> {icon ? : null} {title}{description ? {description} : null} @@ -16,5 +19,5 @@ export function ListRow({ title, description, icon, hint, onPress }: { title: st ; const style = { alignItems: 'center' as const, borderRadius: radii.md, flexDirection: 'row' as const, gap: spacing.md, minHeight: 56, padding: spacing.md }; if (!onPress) return {content}; - return [style, { backgroundColor: pressed ? colors.surfacePressed : 'transparent' }]}>{content}; + return [style, { backgroundColor: pressed ? colors.surfacePressed : 'transparent' }]}>{content}; } diff --git a/src/components/SelectorField.tsx b/src/components/SelectorField.tsx index 56e73df..5f8b869 100644 --- a/src/components/SelectorField.tsx +++ b/src/components/SelectorField.tsx @@ -8,6 +8,9 @@ import { ControlButton } from './ControlButton'; import { focusAccessibilityTarget } from './accessibilityFocus'; import { useTheme } from '@/theme/ThemeContext'; import { webControlAccessibility } from './webControlAccessibility'; +import { ScanItemHighlight } from '@/scanning/ScanHighlight'; +import { ScanSection } from '@/scanning/ScanSection'; +import { useScannable } from '@/scanning/useScannable'; export type SelectorOption = { key: T; label: string; disabled?: boolean }; @@ -91,21 +94,26 @@ export function SelectorField({ label, options, selec } }; + const openSelector = () => { setSelectionError(null); setVisible(true); }; + const { attach: scanAttach, onLayout: scanLayout, highlighted: scanHighlighted, groupHighlighted: scanGroupHighlighted } = useScannable({ onActivate: openSelector, controlRef: fieldRef }); return <> - { setSelectionError(null); setVisible(true); }} style={({ pressed }) => ({ alignItems: 'center', backgroundColor: pressed ? colors.surfacePressed : colors.surfaceRaised, borderColor: colors.border, borderRadius: radii.lg, borderWidth: 1, flexDirection: 'row', gap: spacing.sm, minHeight: 48, padding: spacing.md })}> + ({ alignItems: 'center', backgroundColor: pressed ? colors.surfacePressed : colors.surfaceRaised, borderColor: colors.border, borderRadius: radii.lg, borderWidth: 1, flexDirection: 'row', gap: spacing.sm, minHeight: 48, padding: spacing.md })}> {label}: {selected?.label ?? ''} + scheduleFocus(selectedOptionRef)} supportedOrientations={['portrait', 'landscape']} transparent visible={visible}> + {modalTitle} {options.map((option) => void choose(option)} />)} {selectionError ? {selectionError} : null} + diff --git a/src/layouts/ActionPicker.tsx b/src/layouts/ActionPicker.tsx index 0a238c9..74eee21 100644 --- a/src/layouts/ActionPicker.tsx +++ b/src/layouts/ActionPicker.tsx @@ -1,4 +1,5 @@ import { useMemo, useRef, useState } from "react"; +import { ScanSection } from "@/scanning/ScanSection"; import { KeyboardAvoidingView, Platform, @@ -31,7 +32,9 @@ export function ActionPicker(props: ActionPickerProps) { + + ); } diff --git a/src/layouts/LayoutEditor.tsx b/src/layouts/LayoutEditor.tsx index 5c3a588..54a383e 100644 --- a/src/layouts/LayoutEditor.tsx +++ b/src/layouts/LayoutEditor.tsx @@ -1,4 +1,5 @@ import { ResponsiveGrid } from "@/components/ResponsiveGrid"; +import { ScanSection } from "@/scanning/ScanSection"; import { sectionGridMetrics } from "./gridMetrics"; import { useEffect, useMemo, useRef, useState } from "react"; import { @@ -448,6 +449,7 @@ export function LayoutEditor({ > + ) : null} + diff --git a/src/layouts/SurfaceLayout.tsx b/src/layouts/SurfaceLayout.tsx index 5f0e1f6..d20d56c 100644 --- a/src/layouts/SurfaceLayout.tsx +++ b/src/layouts/SurfaceLayout.tsx @@ -9,6 +9,7 @@ import { Platform, ScrollView, View } from "react-native"; import { ControlButton } from "@/components/ControlButton"; import { AppText } from "@/components/AppText"; import { ResponsiveGrid } from "@/components/ResponsiveGrid"; +import { ScanSection } from "@/scanning/ScanSection"; import { focusAccessibilityTarget } from "@/components/accessibilityFocus"; import { useLayout, useTheme } from "@/theme/ThemeContext"; import { useLayoutEditMode } from "./LayoutEditMode"; @@ -60,7 +61,7 @@ export function SurfaceLayout({ useEffect(() => { editableRef.current = editing && !blocked; }, [blocked, editing]); - const { spacing } = useTheme(); + const { radii, spacing } = useTheme(); const { fontScale } = useLayout(); useEffect(() => { mounted.current = true; @@ -89,6 +90,7 @@ export function SurfaceLayout({ spacing.sm, ); return ( + setWidth(event.nativeEvent.layout.width)} @@ -215,5 +217,6 @@ export function SurfaceLayout({ /> ) : null} + ); } diff --git a/src/navigation/ScannableTabButton.tsx b/src/navigation/ScannableTabButton.tsx new file mode 100644 index 0000000..f0aa615 --- /dev/null +++ b/src/navigation/ScannableTabButton.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from 'react'; +import { Pressable, type GestureResponderEvent, type PressableProps } from 'react-native'; + +import { ScanItemHighlight } from '@/scanning/ScanHighlight'; +import { useScannable } from '@/scanning/useScannable'; +import { useTheme } from '@/theme/ThemeContext'; + +type TabButtonProps = Omit & { + children?: ReactNode; + href?: string | undefined; + onPress?: ((event: GestureResponderEvent) => void) | undefined; +}; + +/** + * A tab bar button that scanning can reach. The navigator's press handler ignores + * its event, so a scan selection calls it without one. + */ +export function ScannableTabButton({ children, onPress, style, ...rest }: TabButtonProps) { + const { radii } = useTheme(); + const { attach: scanAttach, onLayout: scanLayout, highlighted: scanHighlighted, groupHighlighted: scanGroupHighlighted } = useScannable({ onActivate: onPress ? () => onPress(undefined as unknown as GestureResponderEvent) : undefined }); + return + {(state) => <> + {typeof children === 'function' ? (children as (pressed: typeof state) => ReactNode)(state) : children} + + } + ; +} diff --git a/src/navigation/tabSceneLayout.web.tsx b/src/navigation/tabSceneLayout.web.tsx index 58f7667..ec34289 100644 --- a/src/navigation/tabSceneLayout.web.tsx +++ b/src/navigation/tabSceneLayout.web.tsx @@ -1,6 +1,7 @@ import { type ReactElement, useCallback, useSyncExternalStore } from 'react'; import { View } from 'react-native'; +import { ScanVisibleContext } from '@/scanning/ScanningContext'; import type { TabSceneNavigation } from './tabSceneLayout'; export type { TabSceneNavigation } from './tabSceneLayout'; @@ -18,7 +19,7 @@ export function WebTabScene({ children, navigation }: { children: ReactElement; }, [navigation]); const isFocused = useCallback(() => navigation.isFocused(), [navigation]); const focused = useSyncExternalStore(subscribe, isFocused, isFocused); - return {children}; + return {children}; } export const tabSceneLayout = ({ children, navigation }: { children: ReactElement; navigation: TabSceneNavigation }): ReactElement => diff --git a/src/remote/RemoteDeviceSwitcher.tsx b/src/remote/RemoteDeviceSwitcher.tsx index 7f0e69e..361f6f8 100644 --- a/src/remote/RemoteDeviceSwitcher.tsx +++ b/src/remote/RemoteDeviceSwitcher.tsx @@ -8,6 +8,9 @@ import { ControlButton } from '@/components/ControlButton'; import { EmptyState } from '@/components/EmptyState'; import { focusAccessibilityTarget } from '@/components/accessibilityFocus'; import { webControlAccessibility } from '@/components/webControlAccessibility'; +import { ScanItemHighlight } from '@/scanning/ScanHighlight'; +import { ScanSection } from '@/scanning/ScanSection'; +import { useScannable } from '@/scanning/useScannable'; import type { ConnectionManager, ConnectionState } from '@/connection/ConnectionManager'; import type { SavedPc } from '@/storage/PairingStore'; import { useTheme } from '@/theme/ThemeContext'; @@ -89,9 +92,11 @@ export function RemoteDeviceSwitcher({ connection, manager, managePcs }: { conne managePcs(); }; + const { attach: scanAttach, onLayout: scanLayout, highlighted: scanHighlighted, groupHighlighted: scanGroupHighlighted } = useScannable({ onActivate: open, controlRef: buttonRef }); return <> {presentation.status} + { modalShown.current = true; @@ -118,12 +124,14 @@ export function RemoteDeviceSwitcher({ connection, manager, managePcs }: { conne + Switch PC {saved === null ? undefined} /> : saved.length > 0 ? {saved.map((pc, index) => selectPc(pc)} />)} : } + diff --git a/src/scanning/ScanHighlight.tsx b/src/scanning/ScanHighlight.tsx new file mode 100644 index 0000000..6cf2471 --- /dev/null +++ b/src/scanning/ScanHighlight.tsx @@ -0,0 +1,54 @@ +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import { Text, View } from 'react-native'; + +import { useTheme } from '@/theme/ThemeContext'; + +const RING = 4; + +/** The scan highlight around a control: a thick ring in the text colour, clear on any background. */ +export function ScanItemHighlight({ highlighted, groupHighlighted = false, radius }: { highlighted: boolean; groupHighlighted?: boolean; radius: number }) { + const { colors } = useTheme(); + if (!highlighted && !groupHighlighted) return null; + return ; +} + +/** 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 }) { + const { colors, spacing, typography } = useTheme(); + if (!highlighted && !escapeHighlighted && !entered) return null; + return + {escapeHighlighted ? + + Leave section + : null} + ; +} diff --git a/src/scanning/ScanSection.tsx b/src/scanning/ScanSection.tsx new file mode 100644 index 0000000..69c79b1 --- /dev/null +++ b/src/scanning/ScanSection.tsx @@ -0,0 +1,19 @@ +import { ScanGroupScope } from '@switchify/scanning/native'; +import type { PropsWithChildren } from 'react'; +import { View, type StyleProp, type ViewStyle } from 'react-native'; + +import { ScanGroupHighlight } from './ScanHighlight'; +import { useScannableGroup } from './useScannable'; + +/** + * Scans its controls as one group. Select enters the section, the controls are + * 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 }>) { + const { id, attach, onLayout, highlighted, entered, escapeHighlighted } = useScannableGroup({ exclusive }); + return + {children} + + ; +} diff --git a/src/scanning/ScanningContext.ts b/src/scanning/ScanningContext.ts new file mode 100644 index 0000000..5976953 --- /dev/null +++ b/src/scanning/ScanningContext.ts @@ -0,0 +1,10 @@ +import { createContext } from 'react'; + +/** Scanning is switched on and this platform can read switches. */ +export const ScanningEnabledContext = createContext(false); + +/** + * False inside screens that are mounted but not shown, such as inactive tabs, so + * their controls never join the scan. + */ +export const ScanVisibleContext = createContext(true); diff --git a/src/scanning/ScanningProvider.tsx b/src/scanning/ScanningProvider.tsx new file mode 100644 index 0000000..7ab5496 --- /dev/null +++ b/src/scanning/ScanningProvider.tsx @@ -0,0 +1,26 @@ +import { Policy } from '@switchify/scanning'; +import { ScanProvider } from '@switchify/scanning/native'; +import type { PropsWithChildren } from 'react'; +import { View } from 'react-native'; + +import { usePreferences } from '@/storage/usePreferences'; +import { ScanningEnabledContext } from './ScanningContext'; +import { SwitchInput, switchInputSupported } from './SwitchInput'; + +/** + * Scans the whole app once switch scanning is on in Settings. The menu policy keeps + * the highlight on a control after it is used, so repeated presses such as moving + * the pointer stay quick. + */ +export function ScanningProvider({ children }: PropsWithChildren) { + const { scanning } = usePreferences(); + const enabled = scanning.enabled && switchInputSupported; + return + + + {children} + {enabled ? : null} + + + ; +} diff --git a/src/scanning/ScanningSettings.tsx b/src/scanning/ScanningSettings.tsx new file mode 100644 index 0000000..2bd9b10 --- /dev/null +++ b/src/scanning/ScanningSettings.tsx @@ -0,0 +1,96 @@ +import { ACTION_LABELS, validateSwitchSettings, type ScanAction, type SwitchBinding } from '@switchify/scanning'; +import { useEffect, useRef, useState } from 'react'; +import { View } from 'react-native'; + +import { ActionButton } from '@/components/ActionButton'; +import { AppText } from '@/components/AppText'; +import { ControlButton } from '@/components/ControlButton'; +import { SelectorField } from '@/components/SelectorField'; +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'; + +const PRESS_ACTIONS: ScanAction[] = ['select', 'next', 'back', 'pause', 'reverse', 'stop']; +const HOLD_CHOICES: { key: string; label: string; actions: ScanAction[] }[] = [ + { key: 'none', label: 'Nothing', actions: [] }, + { key: 'reverse', label: 'Reverse direction', actions: ['reverse'] }, + { key: 'pause', label: 'Pause / resume', actions: ['pause'] }, + { key: 'stop', label: 'Stop scanning', actions: ['stop'] }, + { key: 'reverse-stop', label: 'Reverse, then stop', actions: ['reverse', 'stop'] }, +]; + +function holdKey(actions: ScanAction[]): string { + return HOLD_CHOICES.find((choice) => choice.actions.join() === actions.join())?.key ?? 'none'; +} + +/** Switch scanning settings: off by default, then movement, speed, pattern and switches. */ +export function ScanningSettings() { + const { scanning } = usePreferences(); + const { colors, spacing } = useTheme(); + const [error, setError] = useState(null); + const [capturingId, setCapturingId] = useState(null); + const stopCapture = useRef<(() => void) | null>(null); + useEffect(() => () => stopCapture.current?.(), []); + + const save = async (next: ScanningPreferences): Promise => { + const problem = validateSwitchSettings(next.switches, next.automatic); + if (problem) { setError(problem); return false; } + setError(null); + await preferencesStore.update({ scanning: next }); + return true; + }; + const updateBinding = (id: string, patch: Partial) => save({ ...scanning, switches: { ...scanning.switches, bindings: scanning.switches.bindings.map((binding) => binding.id === id ? { ...binding, ...patch } : binding) } }); + const assignKey = (binding: SwitchBinding) => { + stopCapture.current?.(); + setCapturingId(binding.id); + stopCapture.current = captureNextKey((code) => { + stopCapture.current = null; + setCapturingId(null); + if (code) void updateBinding(binding.id, { key: code }); + }); + }; + const addSwitch = () => { + stopCapture.current?.(); + setCapturingId("new"); + stopCapture.current = captureNextKey((code) => { + stopCapture.current = null; + setCapturingId(null); + if (!code) return; + const used = new Set(scanning.switches.bindings.map((binding) => binding.pressAction)); + const action = PRESS_ACTIONS.find((candidate) => !used.has(candidate)) ?? "next"; + const binding: SwitchBinding = { id: `switch-${Date.now()}`, name: ACTION_LABELS[action], key: code, pressAction: action, holdActions: [] }; + void save({ ...scanning, switches: { ...scanning.switches, bindings: [...scanning.switches.bindings, binding] } }); + }); + }; + + return + + void save({ ...scanning, enabled: false })} /> + void save({ ...scanning, enabled: true })} /> + + {scanning.enabled ? <> + + void save({ ...scanning, automatic: true })} /> + void save({ ...scanning, automatic: false })} /> + + {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' })} /> + + Switches + {scanning.switches.bindings.map((binding) => + {binding.key ? `Key: ${keyLabel(binding.key)}` : 'No key set'} + assignKey(binding)} /> + ({ key: action, label: ACTION_LABELS[action] }))} selectedKey={binding.pressAction} onSelect={(pressAction) => updateBinding(binding.id, { pressAction, name: ACTION_LABELS[pressAction] }).then(() => undefined)} /> + ({ key, label }))} selectedKey={holdKey(binding.holdActions)} onSelect={(key) => updateBinding(binding.id, { holdActions: HOLD_CHOICES.find((choice) => choice.key === key)?.actions ?? [] }).then(() => undefined)} /> + {scanning.switches.bindings.length > 1 ? void save({ ...scanning, switches: { ...scanning.switches, bindings: scanning.switches.bindings.filter((item) => item.id !== binding.id) } })} /> : null} + )} + {scanning.switches.bindings.length < 6 ? : null} + Press Select to start scanning. Escape stops it. Keys still type in text fields while one is in use. + : null} + {error ? {error} : null} + ; +} diff --git a/src/scanning/SwitchInput.tsx b/src/scanning/SwitchInput.tsx new file mode 100644 index 0000000..b17ea89 --- /dev/null +++ b/src/scanning/SwitchInput.tsx @@ -0,0 +1,8 @@ +import type { SwitchSettings } from '@switchify/scanning'; + +/** Native React Native has no global key events, so switches are read on the web only. */ +export const switchInputSupported = false; + +export function SwitchInput(_props: { settings: SwitchSettings }) { + return null; +} diff --git a/src/scanning/SwitchInput.web.tsx b/src/scanning/SwitchInput.web.tsx new file mode 100644 index 0000000..60ebe2e --- /dev/null +++ b/src/scanning/SwitchInput.web.tsx @@ -0,0 +1,31 @@ +import { ACTION_LABELS, type SwitchSettings } from '@switchify/scanning'; +import { useKeyboardSwitches } from '@switchify/scanning/dom'; +import { useScanSnapshot } from '@switchify/scanning/native'; +import { Text, View } from 'react-native'; + +import { useTheme } from '@/theme/ThemeContext'; +import { useKeyCaptureActive } from './keyCapture'; + +export const switchInputSupported = true; + +function statusMessage(snapshot: ReturnType, holdAction: string | undefined): string | null { + if (holdAction) return `Release for ${holdAction.toLowerCase()}`; + if (!snapshot.active) return null; + if (snapshot.paused) return 'Scanning paused. Use Pause to resume.'; + if (snapshot.waiting || snapshot.suspended) return 'Press Select to continue scanning.'; + return null; +} + +/** Reads keyboard-style switches and shows hold prompts and pauses at the bottom of the screen. */ +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 message = statusMessage(snapshot, prompt ? ACTION_LABELS[prompt.action] : undefined); + return + {message ? + {message} + : null} + ; +} diff --git a/src/scanning/keyCapture.ts b/src/scanning/keyCapture.ts new file mode 100644 index 0000000..e92b2bb --- /dev/null +++ b/src/scanning/keyCapture.ts @@ -0,0 +1,55 @@ +import { useSyncExternalStore } from 'react'; + +let capturing = false; +const listeners = new Set<() => void>(); + +function set(value: boolean): void { + if (capturing === value) return; + capturing = value; + listeners.forEach((listener) => listener()); +} + +const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; }; +const snapshot = () => capturing; + +/** True while Settings waits for a switch press to assign a key; switches then pause. */ +export function useKeyCaptureActive(): boolean { + return useSyncExternalStore(subscribe, snapshot, snapshot); +} + +/** + * Waits for the next key press and reports its code, so a switch can be assigned by + * pressing it. Escape cancels. Keys are kept from the page until the pressed key is + * released, so that release cannot click whatever has focus. Returns a function that + * stops waiting. + */ +export function captureNextKey(onKey: (code: string | null) => void): () => void { + if (typeof window === 'undefined') { onKey(null); return () => undefined; } + set(true); + let pressed: string | null = null; + let closed = false; + const swallow = (event: KeyboardEvent) => { event.preventDefault(); event.stopImmediatePropagation(); }; + const close = () => { + if (closed) return; + closed = true; + window.removeEventListener('keydown', onKeyDown, true); + window.removeEventListener('keyup', onKeyUp, true); + set(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + swallow(event); + if (pressed !== null || event.repeat) return; + pressed = event.code; + onKey(event.code === 'Escape' ? null : event.code); + }; + const onKeyUp = (event: KeyboardEvent) => { + swallow(event); + if (pressed !== null && event.code === pressed) close(); + }; + window.addEventListener('keydown', onKeyDown, true); + window.addEventListener('keyup', onKeyUp, true); + return () => { + if (pressed === null) onKey(null); + close(); + }; +} diff --git a/src/scanning/keyCapture.web.test.ts b/src/scanning/keyCapture.web.test.ts new file mode 100644 index 0000000..19ad268 --- /dev/null +++ b/src/scanning/keyCapture.web.test.ts @@ -0,0 +1,41 @@ +/** @jest-environment jsdom */ +import { captureNextKey } from './keyCapture'; + +const key = (type: 'keydown' | 'keyup', code: string, repeat = false) => { + const event = new KeyboardEvent(type, { code, repeat, bubbles: true, cancelable: true }); + window.dispatchEvent(event); + return event; +}; + +describe('assigning a switch key', () => { + it('reports the pressed key and keeps its release from the page', () => { + const onKey = jest.fn(); + const pageKeyUp = jest.fn(); + window.addEventListener('keyup', pageKeyUp); + captureNextKey(onKey); + expect(key('keydown', 'Digit1').defaultPrevented).toBe(true); + expect(onKey).toHaveBeenCalledWith('Digit1'); + key('keydown', 'Digit1', true); + expect(onKey).toHaveBeenCalledTimes(1); + expect(key('keyup', 'Digit1').defaultPrevented).toBe(true); + expect(pageKeyUp).not.toHaveBeenCalled(); + expect(key('keydown', 'Space').defaultPrevented).toBe(false); + window.removeEventListener('keyup', pageKeyUp); + }); + + it('treats Escape as cancel', () => { + const onKey = jest.fn(); + captureNextKey(onKey); + key('keydown', 'Escape'); + key('keyup', 'Escape'); + expect(onKey).toHaveBeenCalledWith(null); + }); + + it('can be stopped before any key is pressed', () => { + const onKey = jest.fn(); + const stop = captureNextKey(onKey); + stop(); + expect(onKey).toHaveBeenCalledWith(null); + expect(key('keydown', 'Space').defaultPrevented).toBe(false); + }); +}); diff --git a/src/scanning/preferences.test.ts b/src/scanning/preferences.test.ts new file mode 100644 index 0000000..ffc9e30 --- /dev/null +++ b/src/scanning/preferences.test.ts @@ -0,0 +1,44 @@ +import { DEFAULT_SCANNING, keyLabel, normalizeScanning } from './preferences'; + +describe('scanning preferences', () => { + it('are off by default with a Select and a Next switch', () => { + expect(normalizeScanning(undefined)).toEqual(DEFAULT_SCANNING); + expect(DEFAULT_SCANNING.enabled).toBe(false); + expect(DEFAULT_SCANNING.switches.bindings.map((binding) => [binding.key, binding.pressAction])).toEqual([['Space', 'select'], ['Enter', 'next']]); + }); + + it('keep stored choices that are still valid', () => { + const stored = { + enabled: true, + automatic: false, + intervalMs: 1_500, + pattern: 'linear', + switches: { holdIntervalMs: 800, bindings: [ + { id: 'a', name: 'Select', key: 'Digit1', pressAction: 'select', holdActions: ['reverse'] }, + { id: 'b', name: 'Next', key: 'Digit2', pressAction: 'next', holdActions: [] }, + { id: 'c', name: 'Previous', key: 'Digit3', pressAction: 'back', holdActions: [] }, + ] }, + }; + expect(normalizeScanning(stored)).toEqual(stored); + }); + + it('let an unknown or unusable choice fall back on its own', () => { + const result = normalizeScanning({ enabled: 'yes', automatic: true, intervalMs: 5, pattern: 'zigzag', switches: { holdIntervalMs: 1_000, bindings: [{ id: 'a', name: 'A', key: 'Space', pressAction: 'fly', holdActions: [] }] } }); + expect(result).toEqual({ ...DEFAULT_SCANNING, enabled: false }); + }); + + it('replace switches that could not drive the chosen movement', () => { + const manualWithoutPrevious = normalizeScanning({ automatic: false, switches: DEFAULT_SCANNING.switches }); + expect(manualWithoutPrevious.automatic).toBe(false); + expect(manualWithoutPrevious.switches).toEqual(DEFAULT_SCANNING.switches); + }); + + it('names keys the way people know them', () => { + expect(keyLabel('Space')).toBe('Space'); + expect(keyLabel('KeyA')).toBe('A'); + expect(keyLabel('Digit1')).toBe('1'); + expect(keyLabel('Numpad5')).toBe('Keypad 5'); + expect(keyLabel('ArrowUp')).toBe('Up arrow'); + expect(keyLabel('F8')).toBe('F8'); + }); +}); diff --git a/src/scanning/preferences.ts b/src/scanning/preferences.ts new file mode 100644 index 0000000..e6a9134 --- /dev/null +++ b/src/scanning/preferences.ts @@ -0,0 +1,71 @@ +import { + DEFAULT_SWITCH_SETTINGS, + resolveOptions, + validateSwitchSettings, + type Pattern, + type ScanAction, + type SwitchBinding, + type SwitchSettings, +} from '@switchify/scanning'; + +export type ScanningPreferences = { + enabled: boolean; + automatic: boolean; + intervalMs: number; + pattern: Pattern; + switches: SwitchSettings; +}; + +export const SCAN_SPEEDS_MS = [500, 750, 1_000, 1_500, 2_000, 3_000] as const; + +export const DEFAULT_SCANNING: ScanningPreferences = { + enabled: false, + automatic: true, + intervalMs: 1_000, + pattern: 'grouped', + switches: { holdIntervalMs: DEFAULT_SWITCH_SETTINGS.holdIntervalMs, bindings: DEFAULT_SWITCH_SETTINGS.bindings.map((binding) => ({ ...binding, holdActions: [...binding.holdActions] })) }, +}; + +const ACTIONS: readonly ScanAction[] = ['select', 'next', 'back', 'pause', 'reverse', 'stop']; + +function binding(value: unknown): SwitchBinding | null { + if (!value || typeof value !== 'object') return null; + const item = value as Partial; + if (typeof item.id !== 'string' || typeof item.name !== 'string' || typeof item.key !== 'string') return null; + if (!ACTIONS.includes(item.pressAction as ScanAction) || !Array.isArray(item.holdActions)) return null; + const holdActions = item.holdActions.filter((action): action is ScanAction => ACTIONS.includes(action as ScanAction)); + return { id: item.id, name: item.name, key: item.key, pressAction: item.pressAction as ScanAction, holdActions }; +} + +/** Keeps every stored choice that is still valid; anything else falls back on its own. */ +export function normalizeScanning(raw: unknown): ScanningPreferences { + if (!raw || typeof raw !== 'object') return DEFAULT_SCANNING; + const value = raw as Partial>; + const options = resolveOptions({ + automatic: typeof value.automatic === 'boolean' ? value.automatic : DEFAULT_SCANNING.automatic, + intervalMs: typeof value.intervalMs === 'number' ? value.intervalMs : DEFAULT_SCANNING.intervalMs, + pattern: value.pattern as Pattern, + }); + const storedSwitches = value.switches as Partial | undefined; + const bindings = Array.isArray(storedSwitches?.bindings) ? storedSwitches.bindings.map(binding) : null; + const switches: SwitchSettings = bindings && bindings.every((item) => item !== null) && typeof storedSwitches?.holdIntervalMs === 'number' + ? { holdIntervalMs: storedSwitches.holdIntervalMs, bindings: bindings as SwitchBinding[] } + : DEFAULT_SCANNING.switches; + return { + enabled: value.enabled === true, + automatic: options.automatic, + intervalMs: options.intervalMs, + pattern: options.pattern, + switches: validateSwitchSettings(switches, options.automatic) === null ? switches : DEFAULT_SCANNING.switches, + }; +} + +/** 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'; + if (code.startsWith('Key')) return code.slice(3); + if (code.startsWith('Digit')) return code.slice(5); + if (code.startsWith('Numpad')) return `Keypad ${code.slice(6)}`; + if (code.startsWith('Arrow')) return `${code.slice(5)} arrow`; + return code; +} diff --git a/src/scanning/useScannable.test.tsx b/src/scanning/useScannable.test.tsx new file mode 100644 index 0000000..7362c88 --- /dev/null +++ b/src/scanning/useScannable.test.tsx @@ -0,0 +1,46 @@ +import { ScanController } from '@switchify/scanning'; +import { ScanProvider } from '@switchify/scanning/native'; +import { act, render, screen } from '@testing-library/react-native'; +import type { ReactNode } from 'react'; + +import { ControlButton } from '@/components/ControlButton'; +import { ScanningEnabledContext, ScanVisibleContext } from './ScanningContext'; + +function Scanned({ controller, enabled = true, visible = true, children }: { controller: ScanController; enabled?: boolean; visible?: boolean; children: ReactNode }) { + return {children}; +} + +const controllers: ScanController[] = []; +const controllerFor = () => { const controller = new ScanController({ options: { automatic: false } }); controllers.push(controller); return controller; }; + +afterEach(() => { controllers.splice(0).forEach((controller) => controller.destroy()); }); + +describe('scannable controls', () => { + it('are highlighted and pressed by a switch, like a tap', async () => { + const controller = controllerFor(); + const pressA = jest.fn(); + const pressB = jest.fn(); + await render(); + await act(async () => { controller.flush(); controller.start(); }); + expect(screen.getAllByTestId('scan-highlight', { includeHiddenElements: true })).toHaveLength(1); + await act(async () => { controller.dispatch('next'); controller.dispatch('select'); }); + expect(pressA.mock.calls.length + pressB.mock.calls.length).toBe(1); + }); + + it('take no part while scanning is off, hidden, or disabled', async () => { + for (const props of [{ enabled: false }, { visible: false }]) { + const controller = controllerFor(); + const press = jest.fn(); + const view = await render(); + await act(async () => { controller.flush(); controller.start(); controller.dispatch('select'); }); + expect(screen.queryByTestId('scan-highlight', { includeHiddenElements: true })).toBeNull(); + expect(press).not.toHaveBeenCalled(); + await view.unmount(); + } + const controller = controllerFor(); + const press = jest.fn(); + await render(); + await act(async () => { controller.flush(); controller.start(); controller.dispatch('select'); }); + expect(press).not.toHaveBeenCalled(); + }); +}); diff --git a/src/scanning/useScannable.ts b/src/scanning/useScannable.ts new file mode 100644 index 0000000..0f3d5cd --- /dev/null +++ b/src/scanning/useScannable.ts @@ -0,0 +1,52 @@ +import { useScanGroup, useScanItem, type Measurable } from '@switchify/scanning/native'; +import { type Ref, useCallback, useContext, useEffect, useId, useState } from 'react'; +import { Platform, type View } from 'react-native'; + +import { ScanningEnabledContext, ScanVisibleContext } from './ScanningContext'; + +export type ScanHighlightState = { highlighted: boolean; groupHighlighted?: boolean }; + +function assign(target: Ref | undefined, value: T | null): void { + if (typeof target === 'function') target(value); + else if (target && typeof target === 'object') (target as { current: T | null }).current = value; +} + +/** 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 useScanAnchor(highlighted: boolean, measuredRef: (node: V | null) => void, forwarded?: Ref | undefined) { + 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]); + return attach; +} + +/** + * Makes a control scannable. Selecting it with a switch calls `onActivate`, the same + * handler a press would. Attach `ref` and `onLayout` to the control's view so the + * scan follows its position on screen. + */ +export function useScannable({ onActivate, disabled = false, controlRef }: { onActivate?: (() => void) | undefined; disabled?: boolean | undefined; controlRef?: Ref | undefined }) { + const enabled = useContext(ScanningEnabledContext); + const visible = useContext(ScanVisibleContext); + const id = useId(); + const item = useScanItem(id, { ...(onActivate ? { onActivate } : {}), disabled: disabled || !enabled || !visible || !onActivate }); + const attach = useScanAnchor(item.highlighted, item.ref as (node: View | null) => void, controlRef); + return { attach, onLayout: item.onLayout, highlighted: item.highlighted, groupHighlighted: item.groupHighlighted }; +} + +/** Makes a section scan as one group: Select enters it, and the last stop leaves it. */ +export function useScannableGroup({ exclusive = false }: { exclusive?: boolean } = {}) { + const id = useId(); + const group = useScanGroup(id, { exclusive }); + const attach = useScanAnchor(group.highlighted || group.escapeHighlighted, group.ref as (node: View | null) => void); + return { id, attach, onLayout: group.onLayout, highlighted: group.highlighted || group.parentHighlighted, entered: group.entered, escapeHighlighted: group.escapeHighlighted }; +} diff --git a/src/storage/PreferencesStore.ts b/src/storage/PreferencesStore.ts index 236e157..af147ca 100644 --- a/src/storage/PreferencesStore.ts +++ b/src/storage/PreferencesStore.ts @@ -1,13 +1,14 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { validateRemoteName } from '@/device/remoteName'; +import { DEFAULT_SCANNING, normalizeScanning, type ScanningPreferences } from '@/scanning/preferences'; export type RemoteSurface = 'mouse' | 'typing' | 'window' | 'forwarding'; export type TypingMode = 'live' | 'draft'; -export type Preferences = { surface: RemoteSurface; typingMode: TypingMode; draft: string; forwardingHoldToStopMs: number; forwardingProfiles: Record; remoteName: string | null }; +export type Preferences = { surface: RemoteSurface; typingMode: TypingMode; draft: string; forwardingHoldToStopMs: number; forwardingProfiles: Record; remoteName: string | null; scanning: ScanningPreferences }; const KEY = 'switchify.remote.preferences.v1'; -const defaults: Preferences = { surface: 'mouse', typingMode: 'live', draft: '', forwardingHoldToStopMs: 5_000, forwardingProfiles: {}, remoteName: null }; +const defaults: Preferences = { surface: 'mouse', typingMode: 'live', draft: '', forwardingHoldToStopMs: 5_000, forwardingProfiles: {}, remoteName: null, scanning: DEFAULT_SCANNING }; export class PreferencesStore { #value = defaults; @@ -33,6 +34,7 @@ export class PreferencesStore { ? Object.fromEntries(Object.entries(raw.forwardingProfiles).filter((entry): entry is [string, string] => typeof entry[1] === 'string')) : {}, remoteName: remoteName?.valid ? remoteName.value : null, + scanning: normalizeScanning(raw.scanning), }; } catch { this.#value = defaults; } this.#loaded = true;