diff --git a/app/components/ui/Accordion.tsx b/app/components/ui/Accordion.tsx index 0290fb1..0408e68 100644 --- a/app/components/ui/Accordion.tsx +++ b/app/components/ui/Accordion.tsx @@ -15,6 +15,7 @@ export function AccordionSection({ open: controlledOpen, onToggle, children, + containerClassName, }: { label: string; icon?: React.ReactNode; @@ -22,6 +23,7 @@ export function AccordionSection({ open?: boolean; onToggle?: () => void; children: React.ReactNode; + containerClassName?: string; }) { const [internalOpen, setInternalOpen] = useState(defaultOpen); const isControlled = controlledOpen !== undefined; @@ -29,7 +31,7 @@ export function AccordionSection({ const muted = useThemeColor()("muted-foreground"); return ( - + { if (isControlled) onToggle?.(); diff --git a/app/components/ui/Input.tsx b/app/components/ui/Input.tsx index 1901f94..de773fd 100644 --- a/app/components/ui/Input.tsx +++ b/app/components/ui/Input.tsx @@ -30,10 +30,13 @@ export const Input = forwardRef(function Input( // centered row — it must allow height and top-align its children. Single-line // keeps the original fixed 40px row. const layout = multiline - ? "flex-row items-start gap-2 min-h-10 py-2 px-2.5" - : "flex-row items-center gap-2 h-10 px-2.5"; + ? "flex-row items-start gap-2 py-2" + : "flex-row items-center gap-2"; + const inputLayout = multiline + ? "min-h-10 px-2.5" + : "h-10 px-2.5"; - return ( + return ( @@ -42,7 +45,7 @@ export const Input = forwardRef(function Input( ref={ref} multiline={multiline} placeholderTextColor={placeholderColor} - className={`flex-1 text-sm text-foreground ${className ?? ""}`} + className={`${inputLayout} flex-1 text-sm text-foreground ${className ?? ""}`} style={[ { fontFamily: MONO_FONT, paddingVertical: 0 }, multiline ? { textAlignVertical: "top" } : null, diff --git a/app/contexts/KeyboardCustomizationContext.tsx b/app/contexts/KeyboardCustomizationContext.tsx index 2e5d89b..06c59d9 100644 --- a/app/contexts/KeyboardCustomizationContext.tsx +++ b/app/contexts/KeyboardCustomizationContext.tsx @@ -14,6 +14,10 @@ import { KeyboardSettings, } from "@/types/keyboard"; import { getPresetById } from "@/app/tabs/sessions/terminal/keyboard/KeyDefinitions"; +import { + DEFAULT_KEY_REPEAT_INTERVAL, + DEFAULT_KEY_REPEAT_INITIAL_DELAY, +} from "@/constants/keyboard-repeat-config"; const STORAGE_KEY = "keyboardCustomization"; const DEFAULT_PRESET_ID: PresetType = "default"; @@ -42,6 +46,8 @@ const getDefaultConfig = (): KeyboardCustomization => { compactMode: false, hapticFeedback: false, showHints: true, + keyRepeatInterval: DEFAULT_KEY_REPEAT_INTERVAL, + keyRepeatInitialDelay: DEFAULT_KEY_REPEAT_INITIAL_DELAY, }, }; }; diff --git a/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx b/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx index 09dc59c..db7b520 100644 --- a/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx +++ b/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx @@ -6,6 +6,7 @@ import KeyboardKey from "./KeyboardKey"; import { useKeyboardCustomization } from "@/app/contexts/KeyboardCustomizationContext"; import { KeyConfig } from "@/types/keyboard"; import { BACKGROUNDS, BORDER_COLORS, TEXT_COLORS } from "@/app/constants/designTokens"; +import { isRepeatingKey } from "@/constants/keyboard-repeat-config"; interface CustomKeyboardProps { terminalRef: React.RefObject; @@ -181,6 +182,9 @@ export default function CustomKeyboard({ isActive={key.id === "shift" && shiftPressed} keySize={config.settings.keySize} hapticFeedback={config.settings.hapticFeedback} + keyRepeatEnabled={isRepeatingKey(key.id)} + keyRepeatInterval={config.settings.keyRepeatInterval} + keyRepeatInitialDelay={config.settings.keyRepeatInitialDelay} /> ))} diff --git a/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx b/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx index ce26c94..40fb653 100644 --- a/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx +++ b/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx @@ -7,6 +7,7 @@ import { useKeyboardCustomization } from "@/app/contexts/KeyboardCustomizationCo import { KeyConfig } from "@/types/keyboard"; import { useOrientation } from "@/app/utils/orientation"; import { BACKGROUNDS, BORDER_COLORS, ACCENT } from "@/app/constants/designTokens"; +import { isRepeatingKey } from "@/constants/keyboard-repeat-config"; interface KeyboardBarProps { terminalRef: React.RefObject; @@ -146,6 +147,9 @@ export default function KeyboardBar({ } keySize={config.settings.keySize} hapticFeedback={config.settings.hapticFeedback} + keyRepeatEnabled={isRepeatingKey(keyConfig.id)} + keyRepeatInterval={config.settings.keyRepeatInterval} + keyRepeatInitialDelay={config.settings.keyRepeatInitialDelay} /> ); }; diff --git a/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx b/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx index e30c0ff..16f31f7 100644 --- a/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx +++ b/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx @@ -1,8 +1,13 @@ -import React from "react"; +import React, { useState, useRef, useEffect, useCallback } from "react"; import { TouchableOpacity, Text } from "react-native"; import * as Haptics from "expo-haptics"; import { KeySize } from "@/types/keyboard"; import { BACKGROUNDS, BORDER_COLORS, ACCENT } from "@/app/constants/designTokens"; +import { + DEFAULT_KEY_REPEAT_INTERVAL, + DEFAULT_KEY_REPEAT_INITIAL_DELAY, + useKeyRepeat, +} from "@/constants/keyboard-repeat-config"; interface KeyboardKeyProps { label: string; @@ -14,6 +19,9 @@ interface KeyboardKeyProps { keySize?: KeySize; hapticFeedback?: boolean; onLongPress?: () => void; + keyRepeatEnabled?: boolean; + keyRepeatInterval?: number; + keyRepeatInitialDelay?: number; } export default function KeyboardKey({ @@ -26,16 +34,43 @@ export default function KeyboardKey({ keySize = "medium", hapticFeedback = false, onLongPress, + keyRepeatEnabled = false, + keyRepeatInterval = DEFAULT_KEY_REPEAT_INTERVAL, + keyRepeatInitialDelay = DEFAULT_KEY_REPEAT_INITIAL_DELAY, }: KeyboardKeyProps) { - const handlePress = () => { - if (hapticFeedback) { + const [isPressed, setIsPressed] = useState(false); + const onPressRef = useRef(onPress); + const hapticFeedbackRef = useRef(hapticFeedback); + + useEffect(() => { + onPressRef.current = onPress; + }, [onPress]); + + useEffect(() => { + hapticFeedbackRef.current = hapticFeedback; + }, [hapticFeedback]); + + const handlePressIn = useCallback(() => { + setIsPressed(true); + }, []); + + const handlePress = useCallback(() => { + if (hapticFeedbackRef.current) { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } - onPress(); - }; + onPressRef.current(); + }, []); + + useKeyRepeat( + isPressed, keyRepeatEnabled, handlePress, keyRepeatInterval, keyRepeatInitialDelay, + ); + + const handlePressOut = useCallback(() => { + setIsPressed(false); + }, []); const handleLongPress = () => { - if (hapticFeedback) { + if (hapticFeedbackRef.current) { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); } if (onLongPress) { @@ -84,6 +119,8 @@ export default function KeyboardKey({ borderColor, borderRadius: 0, }} + onPressIn={handlePressIn} + onPressOut={handlePressOut} onPress={handlePress} onLongPress={onLongPress ? handleLongPress : undefined} activeOpacity={0.7} diff --git a/app/tabs/settings/KeyboardCustomization.tsx b/app/tabs/settings/KeyboardCustomization.tsx index 00f506e..b1c4d43 100644 --- a/app/tabs/settings/KeyboardCustomization.tsx +++ b/app/tabs/settings/KeyboardCustomization.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useEffect } from "react"; import { View, ScrollView, Pressable } from "react-native"; import { useRouter } from "expo-router"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -10,11 +10,17 @@ import { SlidersHorizontal, } from "lucide-react-native"; import { useKeyboardCustomization } from "@/app/contexts/KeyboardCustomizationContext"; -import { PRESET_DEFINITIONS } from "@/app/tabs/sessions/terminal/keyboard/KeyDefinitions"; +import { ALL_KEYS, PRESET_DEFINITIONS } from "@/app/tabs/sessions/terminal/keyboard/KeyDefinitions"; import { PresetType, KeyConfig } from "@/types/keyboard"; import { toast } from "@/app/utils/toast"; -import { Text, Button, Label, FakeSwitch, Dialog } from "@/app/components/ui"; +import { Text, Button, Label, FakeSwitch, Dialog, Input, SettingRow } from "@/app/components/ui"; +import { AccordionSection } from "@/app/components/ui/Accordion"; import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { + DEFAULT_KEY_REPEAT_INTERVAL, + DEFAULT_KEY_REPEAT_INITIAL_DELAY, + REPEATING_KEY_IDS, +} from "@/constants/keyboard-repeat-config"; import KeySelector from "./components/KeySelector"; import UnifiedDraggableList, { UnifiedListItem, @@ -78,6 +84,15 @@ export default function KeyboardCustomization() { const [activeTab, setActiveTab] = useState("presets"); const [showResetConfirm, setShowResetConfirm] = useState(false); + + const [repeatIntervalText, setRepeatIntervalText] = useState(config.settings.keyRepeatInterval?.toString() || ""); + const [initialDelayText, setInitialDelayText] = useState(config.settings.keyRepeatInitialDelay?.toString() || ""); + + useEffect(() => { + setRepeatIntervalText(config.settings.keyRepeatInterval?.toString() || ""); + setInitialDelayText(config.settings.keyRepeatInitialDelay?.toString() || ""); + }, [config.settings.keyRepeatInterval, config.settings.keyRepeatInitialDelay]); + const [resetType, setResetType] = useState<"all" | "topbar" | "fullkeyboard">( "all", ); @@ -565,7 +580,70 @@ export default function KeyboardCustomization() { onChange={(v) => updateSettings({ showHints: v })} /> + + + + + {/* Key Repeat */} + + + + + + setInitialDelayText(t.replace(/\D/g, "")) } + onEndEditing={(e) => { + const val = parseInt(e.nativeEvent.text.replace(/\D/g, "")); + updateSettings({ keyRepeatInitialDelay: val || undefined }); + setInitialDelayText(String(val || "")); + }} + /> + + + + setRepeatIntervalText(t.replace(/\D/g, "")) } + onEndEditing={(e) => { + const val = parseInt(e.nativeEvent.text.replace(/\D/g, "")); + updateSettings({ keyRepeatInterval: val || undefined }); + setRepeatIntervalText(String(val || "")); + }} + /> + + + + + These keys repeat continuously when held down + {Array.from(REPEATING_KEY_IDS).map((id) => { + const keyConfig = ALL_KEYS[id]; + if (!keyConfig) return null; + return ( + + + {keyConfig.label} + + + {keyConfig.description ?? id} + + + ); + })} + {/* Reset */} diff --git a/constants/keyboard-repeat-config.ts b/constants/keyboard-repeat-config.ts new file mode 100644 index 0000000..86526d4 --- /dev/null +++ b/constants/keyboard-repeat-config.ts @@ -0,0 +1,100 @@ +/** + * Keyboard repeat configuration constants. + * + * These constants define the default behavior for keyboard key repeat + * (auto-repeat when keys are held down) functionality in the virtual keyboard. + * + * Standard behavior across operating systems: + * - Initial delay: 250-500ms (time before repeat starts) + * - Repeat interval: 50-200ms (time between repeated keystrokes) + * + * Optimization for touch devices: + * - Slightly longer initial delay (250ms) to allow natural key release + * - Slightly longer repeat interval (100ms) for comfortable touch interaction + */ + +import { useRef, useEffect } from "react"; + +/** + * Set of key IDs that should always auto-repeat on long-press, + * regardless of which keyboard (main grid or top bar) they appear on. + * + * Arrows, backspace, delete, space, and page keys repeat to support + * rapid navigation and text editing. Character keys are excluded so + * mobile long-press menus (e.g. é, ñ, ü) remain functional. + */ +export const REPEATING_KEY_IDS = new Set([ + "arrowUp", "arrowDown", "arrowLeft", "arrowRight", + "backspace", "delete", + "space", + "pageUp", "pageDown", +]); + +/** + * Check whether a key ID should auto-repeat on long-press. + * Used in both CustomKeyboard (main grid) and KeyboardBar (top bar). + */ +export const isRepeatingKey = (id: string): boolean => + REPEATING_KEY_IDS.has(id); + +/** + * Default initial delay before key repeat starts (in milliseconds). + * This gives users time to release the key without triggering repeat. + * Standard OS keyboards typically use 250-500ms. + */ +export const DEFAULT_KEY_REPEAT_INITIAL_DELAY = 250; + +/** + * Default interval between repeated keystrokes (in milliseconds). + * For touch devices, 100ms (10 presses/sec) is more natural than typical desktop 50ms. + */ +export const DEFAULT_KEY_REPEAT_INTERVAL = 100; + + +const clearTimers = ( + timeoutRef: React.MutableRefObject | null>, + intervalRef: React.MutableRefObject | null>, +) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } +}; + +export const useKeyRepeat = ( + isPressed: boolean, + enabled: boolean, + onPress: () => void, + repeatingInterval: number, + initialDelay: number, +): void => { + const repeatTimeoutRef = useRef | null>(null); + const repeatIntervalRef = useRef | null>(null); + const onPressRef = useRef(onPress); + + useEffect(() => { + onPressRef.current = onPress; + }, [onPress]); + + useEffect(() => { + return () => clearTimers(repeatTimeoutRef, repeatIntervalRef); + }, []); + + useEffect(() => { + if (isPressed && enabled) { + repeatTimeoutRef.current = setTimeout(() => { + repeatIntervalRef.current = setInterval(() => { + onPressRef.current(); + }, repeatingInterval); + }, initialDelay); + + return () => clearTimers(repeatTimeoutRef, repeatIntervalRef); + } + + clearTimers(repeatTimeoutRef, repeatIntervalRef); + }, [isPressed, enabled, repeatingInterval, initialDelay]); +}; diff --git a/types/keyboard.ts b/types/keyboard.ts index 753bcb6..eed97e2 100644 --- a/types/keyboard.ts +++ b/types/keyboard.ts @@ -56,6 +56,8 @@ export interface KeyboardSettings { compactMode: boolean; hapticFeedback: boolean; showHints: boolean; + keyRepeatInterval: number; + keyRepeatInitialDelay: number; } export interface KeyboardCustomization {