mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
The floor is the rule and the seam is the mechanism. A binding rule alone made the custom-key capture field an offender at 22, where nothing can zoom, and the only way to satisfy it was to lower a one-character field to 16 — the tail wagging the dog. The seam's web half now exports the floor it already computed `Math.max` against, and the census reads that number out of that file rather than carrying a second copy of 16. The rule becomes "the seam's binding, or a literal at or above the floor", with no per-site exemption: a literal under the floor is still reported, which is the case the seam exists for. A tree whose seam declares no floor is refused rather than judged against a number the census invented. So the capture field goes back to 22 on both platforms and its split, its override entry and its parity test go with it. The chat's two fields stay split, because 15 is under the floor however it is spelled. Red-first: with the rule removed, a planted literal 16 and a literal 22 are both reported and the refusal case does not throw; a literal 15 is reported either way. All three route closures that run this census — session, source-control, review — report 0 offenders and 0 unresolved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
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<string, TerminalShortcutSpecialKey> = 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<CustomKey[]> {
|
||
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<void> {
|
||
await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys))
|
||
}
|
||
|
||
export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) {
|
||
const [step, setStep] = useState<Step>('choose-type')
|
||
const [shortcutKey, setShortcutKey] = useState('c')
|
||
const [shortcutModifiers, setShortcutModifiers] = useState<TerminalShortcutModifier[]>(['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<CustomKey, 'id'>) => {
|
||
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 (
|
||
<BottomDrawer visible={visible} onClose={onClose}>
|
||
<View style={styles.header}>
|
||
{showBack ? (
|
||
<Pressable
|
||
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
|
||
onPress={onBack}
|
||
accessibilityRole="button"
|
||
accessibilityLabel="Back"
|
||
>
|
||
<ChevronLeft size={18} color={colors.textSecondary} />
|
||
</Pressable>
|
||
) : (
|
||
<View style={styles.backSpacer} />
|
||
)}
|
||
<Text style={styles.title}>
|
||
{step === 'choose-type' && 'Add Shortcut'}
|
||
{step === 'shortcut-combo' && 'Shortcut Combo'}
|
||
{step === 'special-keys' && 'Pick a key'}
|
||
{step === 'text-macro' && 'Text Macro'}
|
||
</Text>
|
||
<View style={styles.backSpacer} />
|
||
</View>
|
||
|
||
{step === 'choose-type' && (
|
||
<View style={styles.group}>
|
||
<Pressable
|
||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||
onPress={() => setStep('shortcut-combo')}
|
||
>
|
||
<Text style={styles.rowLabel}>Shortcut Combo</Text>
|
||
<Text style={styles.rowHint}>Build Ctrl, Alt, and Shift key chords</Text>
|
||
</Pressable>
|
||
<View style={styles.separator} />
|
||
<Pressable
|
||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||
onPress={() => setStep('text-macro')}
|
||
>
|
||
<Text style={styles.rowLabel}>Text Macro</Text>
|
||
<Text style={styles.rowHint}>Send custom text command</Text>
|
||
</Pressable>
|
||
{onManageShortcuts ? (
|
||
<>
|
||
<View style={styles.separator} />
|
||
<Pressable
|
||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||
onPress={onManageShortcuts}
|
||
>
|
||
<Text style={styles.rowLabel}>Manage Shortcuts</Text>
|
||
<Text style={styles.rowHint}>Show, hide, or reorder shortcut keys</Text>
|
||
</Pressable>
|
||
</>
|
||
) : null}
|
||
</View>
|
||
)}
|
||
|
||
{step === 'shortcut-combo' && (
|
||
<View style={styles.shortcutForm}>
|
||
<View style={styles.preview}>
|
||
{orderedActiveModifiers.map((modifier, index) => (
|
||
<View key={modifier.id} style={styles.previewKeycapRow}>
|
||
{index > 0 ? <Text style={styles.previewPlus}>+</Text> : null}
|
||
<View style={[styles.keycap, styles.keycapModifier]}>
|
||
<Text style={styles.keycapModifierText}>{modifier.label}</Text>
|
||
</View>
|
||
</View>
|
||
))}
|
||
{orderedActiveModifiers.length > 0 ? <Text style={styles.previewPlus}>+</Text> : null}
|
||
<View style={[styles.keycap, !shortcutPreview && styles.keycapWarn]}>
|
||
<Text style={[styles.keycapText, !shortcutPreview && styles.keycapTextWarn]}>
|
||
{previewKeyLabel}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.section}>
|
||
<Text style={styles.sectionLabel}>Modifiers</Text>
|
||
<View style={styles.mods}>
|
||
{SHORTCUT_MODIFIERS.map((modifier) => {
|
||
const selected = shortcutModifiers.includes(modifier.id)
|
||
return (
|
||
<Pressable
|
||
key={modifier.id}
|
||
style={({ pressed }) => [
|
||
styles.chip,
|
||
selected && styles.chipSelected,
|
||
pressed && !selected && styles.chipPressed
|
||
]}
|
||
onPress={() => toggleShortcutModifier(modifier.id)}
|
||
accessibilityRole="button"
|
||
accessibilityState={{ selected }}
|
||
>
|
||
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>
|
||
{modifier.label}
|
||
</Text>
|
||
{modifier.glyph ? (
|
||
<Text style={[styles.chipGlyph, selected && styles.chipGlyphSelected]}>
|
||
{modifier.glyph}
|
||
</Text>
|
||
) : null}
|
||
</Pressable>
|
||
)
|
||
})}
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.section}>
|
||
<Text style={styles.sectionLabel}>Key</Text>
|
||
<TextInput
|
||
style={styles.keyInput}
|
||
value={shortcutKey.length === 1 ? shortcutKey.toUpperCase() : ''}
|
||
onChangeText={handleShortcutKeyInput}
|
||
placeholder={SPECIAL_KEY_BY_ID[shortcutKey]?.label ?? 'C'}
|
||
placeholderTextColor={colors.textMuted}
|
||
autoCapitalize="characters"
|
||
autoCorrect={false}
|
||
maxLength={1}
|
||
/>
|
||
<Pressable
|
||
style={({ pressed }) => [styles.moreLink, pressed && styles.moreLinkPressed]}
|
||
onPress={() => setStep('special-keys')}
|
||
>
|
||
<Text style={styles.moreLinkText}>More keys — Tab, arrows, F1–F12…</Text>
|
||
</Pressable>
|
||
</View>
|
||
|
||
<Pressable
|
||
style={[styles.saveButton, !shortcutPreview && styles.saveButtonDisabled]}
|
||
disabled={!shortcutPreview}
|
||
onPress={handleShortcutSave}
|
||
>
|
||
<Text
|
||
style={[styles.saveButtonText, !shortcutPreview && styles.saveButtonTextDisabled]}
|
||
>
|
||
Add
|
||
</Text>
|
||
</Pressable>
|
||
</View>
|
||
)}
|
||
|
||
{step === 'special-keys' && (
|
||
<View style={styles.specialKeysForm}>
|
||
{SPECIAL_KEY_GROUPS.map((group) => (
|
||
<View key={group.title} style={styles.specialGroup}>
|
||
<Text style={styles.specialGroupTitle}>{group.title}</Text>
|
||
<View style={styles.keyGrid}>
|
||
{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 (
|
||
<View key={id} style={[styles.keyCellWrap, { flexBasis }]}>
|
||
<Pressable
|
||
style={({ pressed }) => [
|
||
styles.keyCell,
|
||
selected && styles.keyCellSelected,
|
||
pressed && !selected && styles.keyCellPressed
|
||
]}
|
||
onPress={() => handleSpecialKeyPick(id)}
|
||
accessibilityLabel={key.accessibilityLabel}
|
||
accessibilityState={{ selected }}
|
||
>
|
||
<Text style={[styles.keyCellText, selected && styles.keyCellTextSelected]}>
|
||
{key.label}
|
||
</Text>
|
||
</Pressable>
|
||
</View>
|
||
)
|
||
})}
|
||
</View>
|
||
</View>
|
||
))}
|
||
</View>
|
||
)}
|
||
|
||
{step === 'text-macro' && (
|
||
<View style={styles.group}>
|
||
<View style={styles.macroForm}>
|
||
<Text style={styles.fieldLabel}>Label</Text>
|
||
<TextInput
|
||
style={styles.fieldInput}
|
||
value={macroLabel}
|
||
onChangeText={setMacroLabel}
|
||
placeholder="e.g. Build"
|
||
placeholderTextColor={colors.textMuted}
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
/>
|
||
<Text style={styles.fieldLabel}>Command</Text>
|
||
<TextInput
|
||
style={styles.fieldInput}
|
||
value={macroText}
|
||
onChangeText={setMacroText}
|
||
placeholder="e.g. pnpm build"
|
||
placeholderTextColor={colors.textMuted}
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
/>
|
||
<View style={styles.switchRow}>
|
||
<Text style={styles.switchLabel}>Press Enter</Text>
|
||
<Switch
|
||
value={macroEnter}
|
||
onValueChange={setMacroEnter}
|
||
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
|
||
thumbColor={colors.textPrimary}
|
||
/>
|
||
</View>
|
||
<Pressable
|
||
style={[styles.saveButton, !macroText.trim() && styles.saveButtonDisabled]}
|
||
disabled={!macroText.trim()}
|
||
onPress={handleMacroSave}
|
||
>
|
||
<Text
|
||
style={[styles.saveButtonText, !macroText.trim() && styles.saveButtonTextDisabled]}
|
||
>
|
||
Add Shortcut
|
||
</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
)}
|
||
</BottomDrawer>
|
||
)
|
||
}
|