import { useCallback, useMemo, useState } from 'react' import { View, Text, Pressable, TextInput, Switch } from 'react-native' import { ChevronLeft } from 'lucide-react-native' import AsyncStorage from '@react-native-async-storage/async-storage' import { colors } from '../theme/mobile-theme' import { BottomDrawer } from './BottomDrawer' import { buildTerminalShortcutKey, normalizeShortcutKeyInput, TERMINAL_SHORTCUT_SPECIAL_KEYS, type TerminalShortcutModifier, type TerminalShortcutSpecialKey } from '../terminal/terminal-accessory-keys' import { customKeyModalStyles as styles } from './CustomKeyModal.styles' const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys' export type CustomKey = { id: string label: string bytes: string enter: boolean } type Step = 'choose-type' | 'shortcut-combo' | 'special-keys' | 'text-macro' // Why: Alt is rendered with the ⌥ glyph because on macOS hosts the Option key // is the only modifier that produces an ESC-prefixed byte sequence terminals // can read. Cmd is intentionally absent — macOS swallows it before keystrokes // reach the shell, so there's nothing to encode. const SHORTCUT_MODIFIERS: { id: TerminalShortcutModifier; label: string; glyph?: string }[] = [ { id: 'ctrl', label: 'Ctrl' }, { id: 'alt', label: 'Alt', glyph: '⌥' }, { id: 'shift', label: 'Shift' } ] // Why: special keys are grouped by purpose so the picker reads as three small // fixed grids rather than one ragged wrap row that clipped F7-F12. const SPECIAL_KEY_GROUPS: { title: string; ids: string[]; columns: number }[] = [ { title: 'Editing', ids: ['escape', 'tab', 'enter', 'backspace', 'delete', 'insert', 'space'], columns: 4 }, { title: 'Navigation', ids: ['arrowUp', 'arrowDown', 'arrowLeft', 'arrowRight', 'home', 'end', 'pageUp', 'pageDown'], columns: 4 }, { title: 'Function', ids: ['f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12'], columns: 6 } ] const SPECIAL_KEY_BY_ID: Record = Object.fromEntries( TERMINAL_SHORTCUT_SPECIAL_KEYS.map((key) => [key.id, key]) ) type Props = { visible: boolean onClose: () => void onKeysChanged: (keys: CustomKey[]) => void onManageShortcuts?: () => void } export async function loadCustomKeys(): Promise { try { const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY) return raw ? (JSON.parse(raw) as CustomKey[]) : [] } catch { return [] } } export async function saveCustomKeys(keys: CustomKey[]): Promise { await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys)) } export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) { const [step, setStep] = useState('choose-type') const [shortcutKey, setShortcutKey] = useState('c') const [shortcutModifiers, setShortcutModifiers] = useState(['ctrl']) const [macroLabel, setMacroLabel] = useState('') const [macroText, setMacroText] = useState('') const [macroEnter, setMacroEnter] = useState(true) const [previousVisible, setPreviousVisible] = useState(visible) // Why: reset before the opening commit so the drawer does not flash the last // custom-key draft; keep close state unchanged for the slide-out animation. if (visible !== previousVisible) { setPreviousVisible(visible) if (visible) { setStep('choose-type') setShortcutKey('c') setShortcutModifiers(['ctrl']) setMacroLabel('') setMacroText('') setMacroEnter(true) } } const addKey = useCallback( async (key: Omit) => { const existing = await loadCustomKeys() const newKey: CustomKey = { ...key, id: `custom-${Date.now()}` } const updated = [...existing, newKey] await saveCustomKeys(updated) onKeysChanged(updated) onClose() }, [onClose, onKeysChanged] ) const shortcutPreview = useMemo( () => buildTerminalShortcutKey({ key: shortcutKey, modifiers: shortcutModifiers }), [shortcutKey, shortcutModifiers] ) const previewKeyLabel = useMemo(() => { const special = SPECIAL_KEY_BY_ID[shortcutKey] if (special) { return special.label } return shortcutKey.length === 1 ? shortcutKey.toUpperCase() : shortcutKey }, [shortcutKey]) const orderedActiveModifiers = useMemo( () => SHORTCUT_MODIFIERS.filter((m) => shortcutModifiers.includes(m.id)), [shortcutModifiers] ) const toggleShortcutModifier = useCallback((modifier: TerminalShortcutModifier) => { setShortcutModifiers((current) => current.includes(modifier) ? current.filter((item) => item !== modifier) : [...current, modifier] ) }, []) const handleShortcutKeyInput = useCallback((value: string) => { if (value === '') { // Why: allow the field to go empty so backspace works; the Save button // stays disabled until a valid key is entered. setShortcutKey('') return } const next = normalizeShortcutKeyInput(value) if (next) { setShortcutKey(next) } }, []) const handleSpecialKeyPick = useCallback((id: string) => { setShortcutKey(id) setStep('shortcut-combo') }, []) const handleShortcutSave = useCallback(() => { const built = buildTerminalShortcutKey({ key: shortcutKey, modifiers: shortcutModifiers }) if (!built) { return } void addKey({ label: built.label, bytes: built.bytes, enter: false }) }, [addKey, shortcutKey, shortcutModifiers]) const handleMacroSave = useCallback(() => { const label = macroLabel.trim() || macroText.trim().slice(0, 12) const text = macroText if (!label || !text) { return } const bytes = macroEnter ? `${text}\r` : text void addKey({ label, bytes, enter: false }) }, [addKey, macroLabel, macroText, macroEnter]) const showBack = step !== 'choose-type' const onBack = useCallback(() => { if (step === 'special-keys') { setStep('shortcut-combo') } else { setStep('choose-type') } }, [step]) return ( {showBack ? ( [styles.backButton, pressed && styles.backButtonPressed]} onPress={onBack} accessibilityLabel="Back" > ) : ( )} {step === 'choose-type' && 'Add Shortcut'} {step === 'shortcut-combo' && 'Shortcut Combo'} {step === 'special-keys' && 'Pick a key'} {step === 'text-macro' && 'Text Macro'} {step === 'choose-type' && ( [styles.row, pressed && styles.rowPressed]} onPress={() => setStep('shortcut-combo')} > Shortcut Combo Build Ctrl, Alt, and Shift key chords [styles.row, pressed && styles.rowPressed]} onPress={() => setStep('text-macro')} > Text Macro Send custom text command {onManageShortcuts ? ( <> [styles.row, pressed && styles.rowPressed]} onPress={onManageShortcuts} > Manage Shortcuts Show, hide, or reorder shortcut keys ) : null} )} {step === 'shortcut-combo' && ( {orderedActiveModifiers.map((modifier, index) => ( {index > 0 ? + : null} {modifier.label} ))} {orderedActiveModifiers.length > 0 ? + : null} {previewKeyLabel} Modifiers {SHORTCUT_MODIFIERS.map((modifier) => { const selected = shortcutModifiers.includes(modifier.id) return ( [ styles.chip, selected && styles.chipSelected, pressed && !selected && styles.chipPressed ]} onPress={() => toggleShortcutModifier(modifier.id)} accessibilityRole="button" accessibilityState={{ selected }} > {modifier.label} {modifier.glyph ? ( {modifier.glyph} ) : null} ) })} Key [styles.moreLink, pressed && styles.moreLinkPressed]} onPress={() => setStep('special-keys')} > More keys — Tab, arrows, F1–F12… Add )} {step === 'special-keys' && ( {SPECIAL_KEY_GROUPS.map((group) => ( {group.title} {group.ids.map((id) => { const key = SPECIAL_KEY_BY_ID[id] if (!key) { return null } const selected = shortcutKey === id const flexBasis = `${100 / group.columns}%` as const return ( [ styles.keyCell, selected && styles.keyCellSelected, pressed && !selected && styles.keyCellPressed ]} onPress={() => handleSpecialKeyPick(id)} accessibilityLabel={key.accessibilityLabel} accessibilityState={{ selected }} > {key.label} ) })} ))} )} {step === 'text-macro' && ( Label Command Press Enter Add Shortcut )} ) }