From e7594c7991839b3936ddfe6f032849112f134736 Mon Sep 17 00:00:00 2001 From: Marcio Rinaldi Date: Tue, 7 Jul 2026 13:02:26 -0400 Subject: [PATCH 1/2] feat: implement key repeat for virtual keyboard Repeat arrow keys, backspace, delete, space, Page Up, and Page Down on long press. Create repeat interval and initial delay settings to control the long press. --- app/components/ui/Accordion.tsx | 4 +- app/contexts/KeyboardCustomizationContext.tsx | 6 ++ .../terminal/keyboard/CustomKeyboard.tsx | 4 + .../terminal/keyboard/KeyboardBar.tsx | 4 + .../terminal/keyboard/KeyboardKey.tsx | 49 +++++++-- app/tabs/settings/KeyboardCustomization.tsx | 84 ++++++++++++++- constants/keyboard-repeat-config.ts | 100 ++++++++++++++++++ types/keyboard.ts | 2 + 8 files changed, 243 insertions(+), 10 deletions(-) create mode 100644 constants/keyboard-repeat-config.ts 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/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 { From e114108f5ba65eba286ed9e4575437fe4263e9cd Mon Sep 17 00:00:00 2001 From: Marcio Rinaldi Date: Fri, 17 Jul 2026 23:15:36 -0400 Subject: [PATCH 2/2] style: increase Input clickable surface area for focus Apply padding directly to TextInput instead of container View --- app/components/ui/Input.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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,