import React, { useEffect, useMemo, useState } from 'react' import { Box, Text } from 'ink' import { theme, gradientColor, PANEL_WIDTH } from '../../../../ui/theme.js' import { fitHint } from '../../../../ui/Select.js' import { useAppInput } from '../../../../app/input/AppInputProvider.js' export type LazyMenuItem = { kind?: 'item' value: T label: string shortcut?: string disabled?: boolean hint?: string note?: string noteColor?: string inlineNote?: string inlineNoteColor?: string } export type LazyMenuSection = { kind: 'section' label: string } export type LazyMenuRow = LazyMenuItem | LazyMenuSection function isItem(row: LazyMenuRow): row is LazyMenuItem { return row.kind !== 'section' } function rainbowColor(index: number, total: number): string { return gradientColor(total <= 1 ? 0 : index / (total - 1)) } type Props = { rows: Array> width?: number onSubmit: (value: T) => void onCancel?: () => void } export function LazyMenu({ rows, width = PANEL_WIDTH, onSubmit, onCancel }: Props) { const firstSelectable = Math.max(0, rows.findIndex(r => isItem(r) && !r.disabled)) const [index, setIndex] = useState(firstSelectable) const sig = useMemo( () => rows.map(r => isItem(r) ? `i:${r.shortcut ?? ''}${r.disabled ? '!' : ''}` : `s:${r.label}`).join('|'), [rows], ) useEffect(() => { setIndex(prev => { const at = rows[prev] if (at && isItem(at) && !at.disabled) return prev const next = rows.findIndex(r => isItem(r) && !r.disabled) return next === -1 ? 0 : next }) }, [sig, rows]) const moveBy = (delta: number) => { if (rows.length === 0) return let next = index for (let i = 0; i < rows.length; i += 1) { next = (next + delta + rows.length) % rows.length const candidate = rows[next] if (candidate && isItem(candidate) && !candidate.disabled) { setIndex(next); return } } } useAppInput((input, key) => { if (key.upArrow || input === 'k') moveBy(-1) else if (key.downArrow || input === 'j') moveBy(1) else if (key.return) { const r = rows[index] if (r && isItem(r) && !r.disabled) onSubmit(r.value) } else if (key.escape || (key.ctrl && input === 'c')) { onCancel?.() } else if (input && !key.ctrl && !key.meta) { const lower = input.toLowerCase() const hit = rows.findIndex(r => isItem(r) && !r.disabled && r.shortcut?.toLowerCase() === lower) if (hit >= 0) { const candidate = rows[hit]! if (isItem(candidate)) { setIndex(hit); onSubmit(candidate.value) } } } }) return ( {rows.map((row, i) => { if (!isItem(row)) { return ( {row.label} ) } const active = i === index const disabled = !!row.disabled const cursorColor = disabled ? theme.border : active ? theme.accentPeriwinkle : theme.dim const shortcutColor = disabled ? theme.border : theme.menuShortcut const chars = row.label.split('') const shortcut = row.shortcut const noteBudget = width - 2 - row.label.length - 2 - (shortcut ? shortcut.length + 2 : 0) const inline = row.inlineNote ? fitHint(row.inlineNote, noteBudget) : '' const inlineWidth = inline ? inline.length + 2 : 0 const pad = shortcut ? Math.max(2, width - (2 + row.label.length + inlineWidth + shortcut.length)) : 0 return ( {active ? '❯ ' : ' '} {active && !disabled ? chars.map((ch, ci) => ( {ch} )) : {row.label} } {inline ? ( <> {' '} {inline} ) : null} {shortcut ? ( <> {' '.repeat(pad)} {shortcut} ) : null} {row.hint && disabled ? ( {row.hint} ) : null} {row.note ? ( {row.note} ) : null} ) })} ) }