mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Add mobile custom shortcut combos (#2387)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -65,6 +65,7 @@ import { ConfirmModal } from '../../../../src/components/ConfirmModal'
|
||||
import {
|
||||
CustomKeyModal,
|
||||
loadCustomKeys,
|
||||
saveCustomKeys,
|
||||
type CustomKey
|
||||
} from '../../../../src/components/CustomKeyModal'
|
||||
import {
|
||||
@@ -1684,7 +1685,7 @@ export default function SessionScreen() {
|
||||
async (key: CustomKey) => {
|
||||
const updated = customKeys.filter((k) => k.id !== key.id)
|
||||
setCustomKeys(updated)
|
||||
await AsyncStorage.setItem('orca:custom-accessory-keys', JSON.stringify(updated))
|
||||
await saveCustomKeys(updated)
|
||||
},
|
||||
[customKeys]
|
||||
)
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { View, Text, Pressable, TextInput, StyleSheet, ScrollView, Switch } from 'react-native'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import {
|
||||
buildTerminalShortcutKey,
|
||||
normalizeShortcutKeyInput,
|
||||
TERMINAL_SHORTCUT_SPECIAL_KEYS,
|
||||
type TerminalShortcutModifier,
|
||||
type TerminalShortcutSpecialKey
|
||||
} from '../terminal/terminal-accessory-keys'
|
||||
|
||||
const STORAGE_KEY = 'orca:custom-accessory-keys'
|
||||
export const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
|
||||
|
||||
export type CustomKey = {
|
||||
id: string
|
||||
@@ -14,17 +21,41 @@ export type CustomKey = {
|
||||
enter: boolean
|
||||
}
|
||||
|
||||
type Step = 'choose-type' | 'pick-ctrl' | 'pick-alt' | 'text-macro'
|
||||
type Step = 'choose-type' | 'shortcut-combo' | 'special-keys' | 'text-macro'
|
||||
|
||||
const ALPHA_KEYS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||
// 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' }
|
||||
]
|
||||
|
||||
function ctrlBytes(letter: string): string {
|
||||
return String.fromCharCode(letter.toUpperCase().charCodeAt(0) - 64)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
]
|
||||
|
||||
function altBytes(letter: string): string {
|
||||
return `\x1b${letter.toLowerCase()}`
|
||||
}
|
||||
const SPECIAL_KEY_BY_ID: Record<string, TerminalShortcutSpecialKey> = Object.fromEntries(
|
||||
TERMINAL_SHORTCUT_SPECIAL_KEYS.map((key) => [key.id, key])
|
||||
)
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
@@ -34,19 +65,21 @@ type Props = {
|
||||
|
||||
export async function loadCustomKeys(): Promise<CustomKey[]> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY)
|
||||
const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as CustomKey[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCustomKeys(keys: CustomKey[]): Promise<void> {
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keys))
|
||||
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 }: 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)
|
||||
@@ -54,6 +87,8 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setStep('choose-type')
|
||||
setShortcutKey('c')
|
||||
setShortcutModifiers(['ctrl'])
|
||||
setMacroLabel('')
|
||||
setMacroText('')
|
||||
setMacroEnter(true)
|
||||
@@ -72,20 +107,54 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
|
||||
[onClose, onKeysChanged]
|
||||
)
|
||||
|
||||
const handleCtrlKey = useCallback(
|
||||
(letter: string) => {
|
||||
void addKey({ label: `Ctrl+${letter}`, bytes: ctrlBytes(letter), enter: false })
|
||||
},
|
||||
[addKey]
|
||||
const shortcutPreview = useMemo(
|
||||
() => buildTerminalShortcutKey({ key: shortcutKey, modifiers: shortcutModifiers }),
|
||||
[shortcutKey, shortcutModifiers]
|
||||
)
|
||||
|
||||
const handleAltKey = useCallback(
|
||||
(letter: string) => {
|
||||
void addKey({ label: `Alt+${letter}`, bytes: altBytes(letter), enter: false })
|
||||
},
|
||||
[addKey]
|
||||
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
|
||||
@@ -95,6 +164,13 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
|
||||
}, [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}>
|
||||
@@ -102,7 +178,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
|
||||
{showBack ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
|
||||
onPress={() => setStep('choose-type')}
|
||||
onPress={onBack}
|
||||
accessibilityLabel="Back"
|
||||
>
|
||||
<ChevronLeft size={18} color={colors.textSecondary} />
|
||||
@@ -112,8 +188,8 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
|
||||
)}
|
||||
<Text style={styles.title}>
|
||||
{step === 'choose-type' && 'Add Shortcut'}
|
||||
{step === 'pick-ctrl' && 'Ctrl + Key'}
|
||||
{step === 'pick-alt' && 'Alt + Key'}
|
||||
{step === 'shortcut-combo' && 'Shortcut Combo'}
|
||||
{step === 'special-keys' && 'Pick a key'}
|
||||
{step === 'text-macro' && 'Text Macro'}
|
||||
</Text>
|
||||
<View style={styles.backSpacer} />
|
||||
@@ -123,18 +199,10 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
|
||||
<View style={styles.group}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => setStep('pick-ctrl')}
|
||||
onPress={() => setStep('shortcut-combo')}
|
||||
>
|
||||
<Text style={styles.rowLabel}>Ctrl + Key</Text>
|
||||
<Text style={styles.rowHint}>Control character shortcuts</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => setStep('pick-alt')}
|
||||
>
|
||||
<Text style={styles.rowLabel}>Alt + Key</Text>
|
||||
<Text style={styles.rowHint}>Alt/Option key combos</Text>
|
||||
<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
|
||||
@@ -147,21 +215,123 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{(step === 'pick-ctrl' || step === 'pick-alt') && (
|
||||
<View style={styles.group}>
|
||||
<ScrollView style={styles.keyGridScroll} contentContainerStyle={styles.keyGrid}>
|
||||
{ALPHA_KEYS.map((letter) => (
|
||||
<Pressable
|
||||
key={letter}
|
||||
style={({ pressed }) => [styles.keyCell, pressed && styles.keyCellPressed]}
|
||||
onPress={() =>
|
||||
step === 'pick-ctrl' ? handleCtrlKey(letter) : handleAltKey(letter)
|
||||
}
|
||||
>
|
||||
<Text style={styles.keyCellText}>{letter}</Text>
|
||||
</Pressable>
|
||||
{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>
|
||||
))}
|
||||
</ScrollView>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -202,7 +372,11 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
|
||||
disabled={!macroText.trim()}
|
||||
onPress={handleMacroSave}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>Add Shortcut</Text>
|
||||
<Text
|
||||
style={[styles.saveButtonText, !macroText.trim() && styles.saveButtonTextDisabled]}
|
||||
>
|
||||
Add Shortcut
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -264,33 +438,177 @@ const styles = StyleSheet.create({
|
||||
fontSize: 12,
|
||||
color: colors.textMuted
|
||||
},
|
||||
keyGridScroll: {
|
||||
maxHeight: 240
|
||||
shortcutForm: {
|
||||
paddingTop: spacing.sm
|
||||
},
|
||||
preview: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.lg + spacing.xs,
|
||||
flexWrap: 'wrap'
|
||||
},
|
||||
previewKeycapRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
previewPlus: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16
|
||||
},
|
||||
keycap: {
|
||||
minWidth: 48,
|
||||
height: 48,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
keycapModifier: {
|
||||
minWidth: 0
|
||||
},
|
||||
keycapWarn: {
|
||||
borderColor: colors.statusAmber
|
||||
},
|
||||
keycapText: {
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 17,
|
||||
fontWeight: '600'
|
||||
},
|
||||
keycapTextWarn: {
|
||||
color: colors.statusAmber
|
||||
},
|
||||
keycapModifierText: {
|
||||
color: colors.textSecondary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: '600'
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing.md
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
marginBottom: spacing.sm,
|
||||
paddingLeft: 2
|
||||
},
|
||||
mods: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
chip: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgPanel,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4
|
||||
},
|
||||
chipSelected: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
chipPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
chipText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: '500'
|
||||
},
|
||||
chipTextSelected: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
chipGlyph: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 13,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
chipGlyphSelected: {
|
||||
color: 'rgba(10,10,10,0.5)'
|
||||
},
|
||||
keyInput: {
|
||||
width: '100%',
|
||||
height: 56,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 22,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center'
|
||||
},
|
||||
moreLink: {
|
||||
paddingVertical: spacing.sm,
|
||||
alignItems: 'center'
|
||||
},
|
||||
moreLinkPressed: {
|
||||
opacity: 0.6
|
||||
},
|
||||
moreLinkText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 13,
|
||||
textDecorationLine: 'underline'
|
||||
},
|
||||
specialKeysForm: {
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.md,
|
||||
gap: spacing.md
|
||||
},
|
||||
specialGroup: {
|
||||
gap: spacing.xs
|
||||
},
|
||||
specialGroupTitle: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
paddingLeft: 2,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
keyGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.xs,
|
||||
justifyContent: 'center',
|
||||
padding: spacing.md
|
||||
marginHorizontal: -spacing.xs / 2
|
||||
},
|
||||
keyCellWrap: {
|
||||
paddingHorizontal: spacing.xs / 2,
|
||||
paddingVertical: spacing.xs / 2
|
||||
},
|
||||
keyCell: {
|
||||
width: 42,
|
||||
height: 38,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgBase,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgPanel,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
keyCellPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
keyCellSelected: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
keyCellText: {
|
||||
fontSize: 15,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
keyCellTextSelected: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
macroForm: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm
|
||||
@@ -322,17 +640,21 @@ const styles = StyleSheet.create({
|
||||
color: colors.textPrimary
|
||||
},
|
||||
saveButton: {
|
||||
marginTop: spacing.md,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center'
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.5
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
saveButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontSize: 15,
|
||||
fontWeight: '600'
|
||||
},
|
||||
saveButtonTextDisabled: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { TERMINAL_ACCESSORY_KEYS } from './terminal-accessory-keys'
|
||||
import { buildTerminalShortcutKey, TERMINAL_ACCESSORY_KEYS } from './terminal-accessory-keys'
|
||||
|
||||
describe('TERMINAL_ACCESSORY_KEYS', () => {
|
||||
it('sends reverse-tab with a non-repeatable Shift+Tab key', () => {
|
||||
@@ -20,4 +20,57 @@ describe('TERMINAL_ACCESSORY_KEYS', () => {
|
||||
expect(key.repeatable === true).toBe(repeatableLabels.has(key.label))
|
||||
}
|
||||
})
|
||||
|
||||
it('builds Ctrl, Alt, and Shift printable shortcut bytes', () => {
|
||||
expect(buildTerminalShortcutKey({ key: 'c', modifiers: ['ctrl'] })).toEqual({
|
||||
label: 'Ctrl+C',
|
||||
bytes: '\x03',
|
||||
accessibilityLabel: 'Ctrl C'
|
||||
})
|
||||
expect(buildTerminalShortcutKey({ key: 'k', modifiers: ['ctrl', 'alt'] })).toEqual({
|
||||
label: 'Ctrl+Alt+K',
|
||||
bytes: '\x1b\x0b',
|
||||
accessibilityLabel: 'Ctrl Alt K'
|
||||
})
|
||||
expect(buildTerminalShortcutKey({ key: '1', modifiers: ['alt', 'shift'] })).toEqual({
|
||||
label: 'Alt+Shift+1',
|
||||
bytes: '\x1b!',
|
||||
accessibilityLabel: 'Alt Shift 1'
|
||||
})
|
||||
})
|
||||
|
||||
it('builds modified special-key terminal sequences', () => {
|
||||
expect(buildTerminalShortcutKey({ key: 'tab', modifiers: ['shift'] })).toEqual({
|
||||
label: 'Shift+Tab',
|
||||
bytes: '\x1b[Z',
|
||||
accessibilityLabel: 'Shift Tab'
|
||||
})
|
||||
expect(buildTerminalShortcutKey({ key: 'arrowRight', modifiers: ['ctrl', 'shift'] })).toEqual({
|
||||
label: 'Ctrl+Shift+→',
|
||||
bytes: '\x1b[1;6C',
|
||||
accessibilityLabel: 'Ctrl Shift →'
|
||||
})
|
||||
expect(buildTerminalShortcutKey({ key: 'delete', modifiers: ['alt'] })).toEqual({
|
||||
label: 'Alt+Del',
|
||||
bytes: '\x1b[3;3~',
|
||||
accessibilityLabel: 'Alt Del'
|
||||
})
|
||||
})
|
||||
|
||||
it('builds function-key terminal sequences', () => {
|
||||
expect(buildTerminalShortcutKey({ key: 'f1', modifiers: [] })).toEqual({
|
||||
label: 'F1',
|
||||
bytes: '\x1bOP',
|
||||
accessibilityLabel: 'F1'
|
||||
})
|
||||
expect(buildTerminalShortcutKey({ key: 'f5', modifiers: ['shift'] })).toEqual({
|
||||
label: 'Shift+F5',
|
||||
bytes: '\x1b[15;2~',
|
||||
accessibilityLabel: 'Shift F5'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects control combinations that terminals cannot encode as control bytes', () => {
|
||||
expect(buildTerminalShortcutKey({ key: '1', modifiers: ['ctrl'] })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,199 @@ export type TerminalAccessoryKey = {
|
||||
repeatable?: boolean
|
||||
}
|
||||
|
||||
export type TerminalShortcutModifier = 'ctrl' | 'alt' | 'shift'
|
||||
|
||||
export type TerminalShortcutBinding = {
|
||||
key: string
|
||||
modifiers: TerminalShortcutModifier[]
|
||||
}
|
||||
|
||||
export type TerminalShortcutBuildResult = {
|
||||
label: string
|
||||
bytes: string
|
||||
accessibilityLabel: string
|
||||
}
|
||||
|
||||
export type TerminalShortcutSpecialKey = {
|
||||
id: string
|
||||
label: string
|
||||
accessibilityLabel: string
|
||||
}
|
||||
|
||||
const ESC = '\x1b'
|
||||
|
||||
const MODIFIER_LABELS: Record<TerminalShortcutModifier, string> = {
|
||||
ctrl: 'Ctrl',
|
||||
alt: 'Alt',
|
||||
shift: 'Shift'
|
||||
}
|
||||
|
||||
const MODIFIER_ORDER: TerminalShortcutModifier[] = ['ctrl', 'alt', 'shift']
|
||||
|
||||
const SHIFTED_PRINTABLE: Record<string, string> = {
|
||||
'`': '~',
|
||||
'1': '!',
|
||||
'2': '@',
|
||||
'3': '#',
|
||||
'4': '$',
|
||||
'5': '%',
|
||||
'6': '^',
|
||||
'7': '&',
|
||||
'8': '*',
|
||||
'9': '(',
|
||||
'0': ')',
|
||||
'-': '_',
|
||||
'=': '+',
|
||||
'[': '{',
|
||||
']': '}',
|
||||
'\\': '|',
|
||||
';': ':',
|
||||
"'": '"',
|
||||
',': '<',
|
||||
'.': '>',
|
||||
'/': '?'
|
||||
}
|
||||
|
||||
const CTRL_PRINTABLE_BYTES: Record<string, string> = {
|
||||
' ': '\x00',
|
||||
'@': '\x00',
|
||||
'`': '\x00',
|
||||
'[': '\x1b',
|
||||
'{': '\x1b',
|
||||
'\\': '\x1c',
|
||||
'|': '\x1c',
|
||||
']': '\x1d',
|
||||
'}': '\x1d',
|
||||
'^': '\x1e',
|
||||
'~': '\x1e',
|
||||
_: '\x1f',
|
||||
'?': '\x7f'
|
||||
}
|
||||
|
||||
const SPECIAL_KEY_LABELS: Record<string, string> = {
|
||||
escape: 'Esc',
|
||||
tab: 'Tab',
|
||||
enter: 'Enter',
|
||||
backspace: '⌫',
|
||||
delete: 'Del',
|
||||
insert: 'Ins',
|
||||
arrowUp: '↑',
|
||||
arrowDown: '↓',
|
||||
arrowLeft: '←',
|
||||
arrowRight: '→',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageUp: 'PgUp',
|
||||
pageDown: 'PgDn',
|
||||
space: 'Space',
|
||||
f1: 'F1',
|
||||
f2: 'F2',
|
||||
f3: 'F3',
|
||||
f4: 'F4',
|
||||
f5: 'F5',
|
||||
f6: 'F6',
|
||||
f7: 'F7',
|
||||
f8: 'F8',
|
||||
f9: 'F9',
|
||||
f10: 'F10',
|
||||
f11: 'F11',
|
||||
f12: 'F12'
|
||||
}
|
||||
|
||||
const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record<string, string> = {
|
||||
escape: 'Escape',
|
||||
tab: 'Tab',
|
||||
enter: 'Enter',
|
||||
backspace: 'Backspace',
|
||||
delete: 'Forward delete',
|
||||
insert: 'Insert',
|
||||
arrowUp: 'Arrow up',
|
||||
arrowDown: 'Arrow down',
|
||||
arrowLeft: 'Arrow left',
|
||||
arrowRight: 'Arrow right',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageUp: 'Page up',
|
||||
pageDown: 'Page down',
|
||||
space: 'Space',
|
||||
f1: 'F1',
|
||||
f2: 'F2',
|
||||
f3: 'F3',
|
||||
f4: 'F4',
|
||||
f5: 'F5',
|
||||
f6: 'F6',
|
||||
f7: 'F7',
|
||||
f8: 'F8',
|
||||
f9: 'F9',
|
||||
f10: 'F10',
|
||||
f11: 'F11',
|
||||
f12: 'F12'
|
||||
}
|
||||
|
||||
const CSI_FINAL_SPECIAL_KEYS: Record<string, string> = {
|
||||
arrowUp: 'A',
|
||||
arrowDown: 'B',
|
||||
arrowRight: 'C',
|
||||
arrowLeft: 'D',
|
||||
home: 'H',
|
||||
end: 'F',
|
||||
f1: 'P',
|
||||
f2: 'Q',
|
||||
f3: 'R',
|
||||
f4: 'S'
|
||||
}
|
||||
|
||||
const SS3_BASE_SPECIAL_KEYS = new Set(['f1', 'f2', 'f3', 'f4'])
|
||||
|
||||
const CSI_TILDE_SPECIAL_KEYS: Record<string, number> = {
|
||||
insert: 2,
|
||||
delete: 3,
|
||||
pageUp: 5,
|
||||
pageDown: 6,
|
||||
f5: 15,
|
||||
f6: 17,
|
||||
f7: 18,
|
||||
f8: 19,
|
||||
f9: 20,
|
||||
f10: 21,
|
||||
f11: 23,
|
||||
f12: 24
|
||||
}
|
||||
|
||||
export const TERMINAL_SHORTCUT_SPECIAL_KEYS: TerminalShortcutSpecialKey[] = [
|
||||
'escape',
|
||||
'tab',
|
||||
'enter',
|
||||
'backspace',
|
||||
'delete',
|
||||
'insert',
|
||||
'arrowUp',
|
||||
'arrowDown',
|
||||
'arrowLeft',
|
||||
'arrowRight',
|
||||
'home',
|
||||
'end',
|
||||
'pageUp',
|
||||
'pageDown',
|
||||
'space',
|
||||
'f1',
|
||||
'f2',
|
||||
'f3',
|
||||
'f4',
|
||||
'f5',
|
||||
'f6',
|
||||
'f7',
|
||||
'f8',
|
||||
'f9',
|
||||
'f10',
|
||||
'f11',
|
||||
'f12'
|
||||
].map((id) => ({
|
||||
id,
|
||||
label: SPECIAL_KEY_LABELS[id]!,
|
||||
accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]!
|
||||
}))
|
||||
|
||||
export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [
|
||||
{ label: 'Esc', bytes: '\x1b' },
|
||||
{ label: 'Tab', bytes: '\t' },
|
||||
@@ -26,3 +219,177 @@ export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [
|
||||
{ label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
|
||||
{ label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
|
||||
]
|
||||
|
||||
export function buildTerminalShortcutKey(
|
||||
binding: TerminalShortcutBinding
|
||||
): TerminalShortcutBuildResult | null {
|
||||
const key = normalizeShortcutKey(binding.key)
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
const modifiers = normalizeModifiers(binding.modifiers)
|
||||
const bytes = buildShortcutBytes(key, modifiers)
|
||||
if (bytes == null) {
|
||||
return null
|
||||
}
|
||||
const label = formatShortcutLabel(key, modifiers)
|
||||
return {
|
||||
label,
|
||||
bytes,
|
||||
accessibilityLabel: label.replaceAll('+', ' ')
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeShortcutKeyInput(value: string): string | null {
|
||||
const chars = Array.from(value)
|
||||
const firstVisible = chars.find((char) => char !== '\n' && char !== '\r' && char !== '\t')
|
||||
if (!firstVisible) {
|
||||
return null
|
||||
}
|
||||
return normalizeShortcutKey(firstVisible)
|
||||
}
|
||||
|
||||
function buildShortcutBytes(key: string, modifiers: TerminalShortcutModifier[]): string | null {
|
||||
if (key === 'space') {
|
||||
return buildPrintableShortcutBytes(' ', modifiers)
|
||||
}
|
||||
const csiFinal = CSI_FINAL_SPECIAL_KEYS[key]
|
||||
if (csiFinal) {
|
||||
// Why: xterm encodes unmodified F1-F4 as SS3 (ESC O P/S). Once a
|
||||
// modifier is present it switches to the CSI 1;N form like arrows.
|
||||
if (SS3_BASE_SPECIAL_KEYS.has(key) && csiModifierParameter(modifiers) === 1) {
|
||||
return `${ESC}O${csiFinal}`
|
||||
}
|
||||
return buildCsiFinalShortcut(csiFinal, modifiers)
|
||||
}
|
||||
const csiTilde = CSI_TILDE_SPECIAL_KEYS[key]
|
||||
if (csiTilde) {
|
||||
return buildCsiTildeShortcut(csiTilde, modifiers)
|
||||
}
|
||||
if (key === 'tab') {
|
||||
if (
|
||||
hasModifier(modifiers, 'shift') &&
|
||||
!hasModifier(modifiers, 'ctrl') &&
|
||||
!hasModifier(modifiers, 'alt')
|
||||
) {
|
||||
return `${ESC}[Z`
|
||||
}
|
||||
const bytes = '\t'
|
||||
return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes
|
||||
}
|
||||
if (key === 'escape') {
|
||||
const bytes = ESC
|
||||
return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes
|
||||
}
|
||||
if (key === 'enter') {
|
||||
const bytes = '\r'
|
||||
return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes
|
||||
}
|
||||
if (key === 'backspace') {
|
||||
const bytes = hasModifier(modifiers, 'ctrl') ? '\b' : '\x7f'
|
||||
return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes
|
||||
}
|
||||
if (isPrintableShortcutKey(key)) {
|
||||
return buildPrintableShortcutBytes(key, modifiers)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildPrintableShortcutBytes(
|
||||
key: string,
|
||||
modifiers: TerminalShortcutModifier[]
|
||||
): string | null {
|
||||
const shifted = hasModifier(modifiers, 'shift') ? applyShift(key) : key
|
||||
let bytes = shifted
|
||||
if (hasModifier(modifiers, 'ctrl')) {
|
||||
const ctrlBytes = controlBytesForPrintable(shifted)
|
||||
if (ctrlBytes == null) {
|
||||
return null
|
||||
}
|
||||
bytes = ctrlBytes
|
||||
}
|
||||
return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes
|
||||
}
|
||||
|
||||
function buildCsiFinalShortcut(final: string, modifiers: TerminalShortcutModifier[]): string {
|
||||
const parameter = csiModifierParameter(modifiers)
|
||||
return parameter === 1 ? `${ESC}[${final}` : `${ESC}[1;${parameter}${final}`
|
||||
}
|
||||
|
||||
function buildCsiTildeShortcut(code: number, modifiers: TerminalShortcutModifier[]): string {
|
||||
const parameter = csiModifierParameter(modifiers)
|
||||
return parameter === 1 ? `${ESC}[${code}~` : `${ESC}[${code};${parameter}~`
|
||||
}
|
||||
|
||||
function csiModifierParameter(modifiers: TerminalShortcutModifier[]): number {
|
||||
let parameter = 1
|
||||
if (hasModifier(modifiers, 'shift')) {
|
||||
parameter += 1
|
||||
}
|
||||
if (hasModifier(modifiers, 'alt')) {
|
||||
parameter += 2
|
||||
}
|
||||
if (hasModifier(modifiers, 'ctrl')) {
|
||||
parameter += 4
|
||||
}
|
||||
return parameter
|
||||
}
|
||||
|
||||
function controlBytesForPrintable(key: string): string | null {
|
||||
const lower = key.toLowerCase()
|
||||
if (lower >= 'a' && lower <= 'z') {
|
||||
return String.fromCharCode(lower.charCodeAt(0) - 96)
|
||||
}
|
||||
return CTRL_PRINTABLE_BYTES[key] ?? null
|
||||
}
|
||||
|
||||
function applyShift(key: string): string {
|
||||
if (key >= 'a' && key <= 'z') {
|
||||
return key.toUpperCase()
|
||||
}
|
||||
if (key >= 'A' && key <= 'Z') {
|
||||
return key
|
||||
}
|
||||
return SHIFTED_PRINTABLE[key] ?? key
|
||||
}
|
||||
|
||||
function normalizeModifiers(modifiers: TerminalShortcutModifier[]): TerminalShortcutModifier[] {
|
||||
const selected = new Set(modifiers)
|
||||
return MODIFIER_ORDER.filter((modifier) => selected.has(modifier))
|
||||
}
|
||||
|
||||
function normalizeShortcutKey(key: string): string | null {
|
||||
if (SPECIAL_KEY_LABELS[key]) {
|
||||
return key
|
||||
}
|
||||
if (key.length === 1 && isPrintableShortcutKey(key)) {
|
||||
return key >= 'A' && key <= 'Z' ? key.toLowerCase() : key
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isPrintableShortcutKey(key: string): boolean {
|
||||
return key.length === 1 && key >= ' ' && key <= '~'
|
||||
}
|
||||
|
||||
function formatShortcutLabel(key: string, modifiers: TerminalShortcutModifier[]): string {
|
||||
const modifierLabels = modifiers.map((modifier) => MODIFIER_LABELS[modifier])
|
||||
return [...modifierLabels, displayKeyLabel(key)].join('+')
|
||||
}
|
||||
|
||||
function displayKeyLabel(key: string): string {
|
||||
if (SPECIAL_KEY_LABELS[key]) {
|
||||
return SPECIAL_KEY_LABELS[key]
|
||||
}
|
||||
if (key === ' ') {
|
||||
return 'Space'
|
||||
}
|
||||
return key.length === 1 && key >= 'a' && key <= 'z' ? key.toUpperCase() : key
|
||||
}
|
||||
|
||||
function hasModifier(
|
||||
modifiers: TerminalShortcutModifier[],
|
||||
modifier: TerminalShortcutModifier
|
||||
): boolean {
|
||||
return modifiers.includes(modifier)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user