import { useKeyboard } from '@opentui/react'; import { useCallback, useRef } from 'react'; import { navKeyFromInput, nextCursor, type ScrollEcho, shouldSwallowEcho } from '../state/list-cursor'; const SCROLL_KEY_ECHO_WINDOW_MS = 250; export interface UseListCursorOptions { /** Only handle keys when this is true (e.g. panel is focused, no overlay open). */ isActive: boolean; /** Number of items the cursor can land on. */ length: number; /** Current cursor position (caller's source of truth). */ currentIndex: number; /** Called with the new index when a navigation key is handled. */ setIndex: (index: number) => void; /** Optional guard for `up`/`down` only — used to swallow scroll-echo keys. */ shouldIgnore?: (key: 'up' | 'down') => boolean; } /** * Wires `j/k/g/G` (plus arrow keys) to a linear cursor. The hook is * intentionally narrow: it owns *only* navigation. Domain keys (Enter, mark * read, fetch, etc.) stay in each consumer's own `useKeyboard`. */ export function useListCursor(options: UseListCursorOptions): void { useKeyboard((key) => { if (!options.isActive) return; const navKey = navKeyFromInput(key.name, !!key.shift); if (!navKey) return; if ((navKey === 'down' || navKey === 'up') && options.shouldIgnore?.(navKey)) { return; } const next = nextCursor(options.currentIndex, navKey, options.length); if (next !== options.currentIndex) { options.setIndex(next); } else if (navKey === 'home' || navKey === 'end') { // Allow the consumer to react to "jump to edge even if already there" // by always firing for home/end. Keeps existing behavior. options.setIndex(next); } }); } /** * Per-direction scroll-echo guard. Mouse-wheel handlers call `armEcho(dir)` * after a scroll; the very next matching-direction key is swallowed exactly * once. Mismatched-direction keys pass through (so a user who scrolls down * with the wheel and then presses `k` to go back doesn't lose that keypress). */ export function useScrollEchoGuard(): { armEcho: (direction: 'up' | 'down') => void; shouldIgnore: (key: 'up' | 'down') => boolean; } { const echoRef = useRef(null); const armEcho = useCallback((direction: 'up' | 'down') => { echoRef.current = { direction, expiresAt: Date.now() + SCROLL_KEY_ECHO_WINDOW_MS }; }, []); const shouldIgnore = useCallback((key: 'up' | 'down') => { const echo = echoRef.current; if (!shouldSwallowEcho(echo, key, Date.now())) { if (echo && Date.now() > echo.expiresAt) echoRef.current = null; return false; } echoRef.current = null; return true; }, []); return { armEcho, shouldIgnore }; }