import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { isDeepStrictEqual } from "node:util"; import { IndicatorComponent } from "../select.tsx"; import { useColor } from "../../theme.ts"; // Allowable A-Z hotkeys, minus reserved keys import { Span } from "paintcannon-react"; import { useKeyboard } from "../../hooks/use-keyboard.ts"; import { TerminalFlex } from "../terminal-flex.tsx"; export type Hotkey = | "a" | "b" | "c" | "d" | "e" | "f" | "g" | "i" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z"; export type Keymap = Partial>>; export type Item = { label: string; value: V; }; /* * Keyboard shortcuts can come in two varieties: * * 1. A-Z predefined key mappings. For any UI elements controlled by us, we should assign a static * a-z hotkey to trigger the UI element. * * 2. Automatic numeric, paginated hotkeys for lists. If there's a list in the UI whose elements we * don't fully control, which can grow or shrink, we can't pre-assign a-z hotkeys to the list * elements since we don't know what they are or how many of them there are. Instead, we paginate * them as necessary and assign 0-9 hotkeys per page. * * Since the paginated lists can potentially consume all hotkeys from 0-9, this means we can only * display one paginated list per screen (otherwise, there would be conflicting hotkeys). The tuple * types below help enforce at compile time that we only pass a single paginated list per select * input, while allowing unbounded predefined A-Z key mappings before or after the paginated list. */ type MapShortcutType = { type: "key"; mapping: Keymap; }; type AutolistShortcutType = { type: "auto-list"; order: Array>; }; export type ShortcutArray = | [MapShortcutType] | [AutolistShortcutType] | [MapShortcutType, AutolistShortcutType] | [AutolistShortcutType, MapShortcutType] | [MapShortcutType, AutolistShortcutType, MapShortcutType]; type KbSelectProps = { shortcutItems: ShortcutArray; readonly onSelect: (item: Item) => any; }; const PAGE_SIZE = 10; export function KbShortcutSelect({ shortcutItems, onSelect }: KbSelectProps) { const [page, setPage] = useState(0); let items = useMemo(() => { const result: Array<{ item: Item; shortcut: string; isNavItem?: boolean; }> = []; shortcutItems.forEach(shortcutType => { if (shortcutType.type === "key") { Object.entries(shortcutType.mapping).forEach(([k, v]) => { if (k === "j" || k === "k" || k === "h" || k === "l") { throw new Error("Can't use j, k, h, or l as shortcuts: reserved for nav"); } result.push({ item: v, shortcut: k, }); }); } else { const totalItems = shortcutType.order.length; const totalPages = Math.ceil(totalItems / PAGE_SIZE); const hasPrev = page > 0; const hasNext = page < totalPages - 1; const start = page * PAGE_SIZE; const end = Math.min(start + PAGE_SIZE, totalItems); const pageItems = shortcutType.order.slice(start, end); pageItems.forEach((item, index) => { result.push({ item: item, shortcut: `${index}`, }); }); if (hasPrev) { result.push({ item: { label: "Previous page", value: "prev-page", }, shortcut: "h", isNavItem: true, }); } if (hasNext) { result.push({ item: { label: "Next page", value: "next-page", }, shortcut: "l", isNavItem: true, }); } } }); return result; }, [shortcutItems, page]); const initialIndex = 0; const lastIndex = items.length - 1; const [rotateIndex, setRotateIndex] = useState( initialIndex > lastIndex ? lastIndex - initialIndex : 0, ); const [selectedIndex, setSelectedIndex] = useState( initialIndex ? (initialIndex > lastIndex ? lastIndex : initialIndex) : 0, ); const previousShortcutItems = useRef(shortcutItems); useEffect(() => { if (!isDeepStrictEqual(previousShortcutItems.current, shortcutItems)) { setRotateIndex(0); setSelectedIndex(0); setPage(0); } previousShortcutItems.current = shortcutItems; }, [shortcutItems]); const handleSelect = useCallback( (item: Item) => { if (item.value === "next-page" || item.value === "prev-page") { return; } onSelect(item as Item); }, [onSelect], ); useKeyboard(event => { if (event.ctrlKey) return; if (event.key === "l") { const hasNext = items.some(item => item.shortcut === "l" && item.isNavItem); if (hasNext) { setPage(prev => prev + 1); setSelectedIndex(0); setRotateIndex(0); return; } } if (event.key === "h") { const hasPrev = items.some(item => item.shortcut === "h" && item.isNavItem); if (hasPrev && page > 0) { setPage(prev => prev - 1); setSelectedIndex(0); setRotateIndex(0); return; } } for (const item of items) { if (item.shortcut.toLowerCase() === event.key.toLowerCase()) { handleSelect(item.item); return; } } if (event.key === "k" || event.key === "ArrowUp") { const lastIndex = items.length - 1; const atFirstIndex = selectedIndex === 0; const nextIndex = lastIndex; const nextRotateIndex = atFirstIndex ? rotateIndex + 1 : rotateIndex; const nextSelectedIndex = atFirstIndex ? nextIndex : selectedIndex - 1; setRotateIndex(nextRotateIndex); setSelectedIndex(nextSelectedIndex); } if (event.key === "j" || event.key === "ArrowDown") { const atLastIndex = selectedIndex === items.length - 1; const nextIndex = 0; const nextRotateIndex = atLastIndex ? rotateIndex - 1 : rotateIndex; const nextSelectedIndex = atLastIndex ? nextIndex : selectedIndex + 1; setRotateIndex(nextRotateIndex); setSelectedIndex(nextSelectedIndex); } if (event.key === "Enter") { event.preventDefault(); handleSelect(items[selectedIndex].item); } }); return ( {items.map((item, index) => { const isSelected = index === selectedIndex; return ( ); })} ); } function UnderlineItem({ isSelected = false, label, shortcut, }: { isSelected: boolean; label: string; shortcut: string; }) { const themeColor = useColor(); const color = isSelected ? themeColor : undefined; const isNumeric = !isNaN(parseInt(shortcut, 10)); if (isNumeric) { return ( <> {shortcut}: {label} ); } return ( <> {label} ({shortcut}) ); }