import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes, type KeyEvent, type Renderable, } from "@opentui/core" import type { Binding } from "@opentui/keymap" import { useTheme, selectedForeground } from "../context/theme" import { entries, filter, flatMap, groupBy, pipe } from "remeda" import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { useTerminalDimensions } from "@opentui/solid" import * as fuzzysort from "fuzzysort" import { isDeepEqual } from "remeda" import { useDialog, type DialogContext } from "./dialog" import { Locale } from "../util/locale" import { getScrollAcceleration } from "../util/scroll" import { useConfig } from "../config" import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap" export interface DialogSelectProps { title: string titleView?: JSX.Element placeholder?: string footer?: JSX.Element emptyView?: JSX.Element options: DialogSelectOption[] flat?: boolean ref?: (ref: DialogSelectRef) => void onMove?: (option: DialogSelectOption) => void onFilter?: (query: string) => void onSelect?: (option: DialogSelectOption) => void skipFilter?: boolean renderFilter?: boolean locked?: boolean preserveSelection?: boolean actions?: { command: string title: string side?: "left" | "right" hidden?: boolean disabled?: boolean | ((option: DialogSelectOption | undefined) => boolean) onTrigger: (option: DialogSelectOption) => void }[] footerHints?: { title: string label: string side?: "left" | "right" }[] bindings?: readonly Binding[] current?: T } export interface DialogSelectOption { title: string titleView?: JSX.Element value: T description?: string details?: string[] detailsColor?: RGBA detailsWrap?: boolean footer?: JSX.Element | string titleWidth?: number truncateTitle?: boolean | "left" category?: string categoryView?: JSX.Element disabled?: boolean bg?: RGBA gutter?: () => JSX.Element margin?: JSX.Element onSelect?: (ctx: DialogContext) => void } export type DialogSelectRef = { filter: string filtered: DialogSelectOption[] moveTo(value: T): void } export function DialogSelect(props: DialogSelectProps) { type Action = NonNullable["actions"]>[number] type FooterHint = NonNullable["footerHints"]>[number] type VisibleAction = (Action & { label: string }) | FooterHint const dialog = useDialog() const { theme } = useTheme() const config = useConfig().data const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) const [store, setStore] = createStore({ selected: 0, filter: "", input: "keyboard" as "keyboard" | "mouse", }) const [focusedAction, setFocusedAction] = createSignal() const actionFocused = createMemo(() => focusedAction() !== undefined) let selection: { value: T; category?: string } | undefined let resetSelection = false let visibilityGeneration = 0 createEffect( on( () => props.current, (current) => { if (current) { const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) if (currentIndex >= 0) { setStore("selected", currentIndex) selection = flat()[currentIndex] } } }, ), ) let input: InputRenderable const actions = createMemo(() => props.actions ?? []) const shownActions = createMemo(() => actions().filter((item) => !item.hidden)) const actionBindings = useKeymapSelector((keymap) => keymap.getCommandBindings({ visibility: "registered", commands: shownActions().map((item) => item.command), }), ) const actionLabels = createMemo(() => { const labels = new Map() for (const action of shownActions()) { const label = formatKeyBindings(actionBindings().get(action.command), config) if (label) labels.set(action.command, label) } return labels }) const visibleActions = createMemo(() => [ ...shownActions() .map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" })) .filter((item) => item.label), ...(props.footerHints ?? []), ]) const actionItems = createMemo(() => visibleActions() .filter(isActionItem) .filter((item) => !isActionDisabled(item)), ) createEffect(() => { const index = focusedAction() if (index !== undefined && index >= actionItems().length) setFocusedAction(undefined) }) const filtered = createMemo(() => { if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true) const needle = store.filter.toLowerCase() const options = pipe( props.options, filter((x) => x.disabled !== true), ) if (!needle) return options // prioritize title matches (weight: 2) over category matches (weight: 1). // users typically search by the item name, and not its category. const result = fuzzysort .go(needle, options, { keys: ["title", "category"], scoreFn: (r) => r[0].score * 2 + r[1].score, }) .map((x) => x.obj) return result }) // When the filter changes due to how TUI works, the mousemove might still be triggered // via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard // that the mouseover event doesn't trigger when filtering. createEffect(() => { filtered() setStore("input", "keyboard") setFocusedAction(undefined) }) const flatten = createMemo(() => props.flat && store.filter.length > 0) const grouped = createMemo<[string, DialogSelectOption[]][]>(() => { if (flatten()) return [["", filtered()]] const result = pipe( filtered(), groupBy((x) => x.category ?? ""), // mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))), entries(), ) return result }) const flat = createMemo(() => { return pipe( grouped(), flatMap(([_, options]) => options), ) }) const rows = createMemo(() => { const headers = grouped().reduce((acc, [category], i) => { if (!category) return acc return acc + (i > 0 ? 2 : 1) }, 0) return flat().reduce((acc, option) => acc + 1 + (option.details?.length ?? 0), headers) }) const dimensions = useTerminalDimensions() const height = createMemo(() => Math.min(rows(), Math.floor(dimensions().height / 2) - 6)) const selected = createMemo(() => flat()[store.selected]) createEffect( on( () => props.options, () => { if (!props.preserveSelection) return if (resetSelection && store.filter.length > 0) { const option = flat()[0] if (!option) return setStore("selected", 0) selection = option return } if (!selection) { if (props.current !== undefined) { const index = flat().findIndex((option) => isDeepEqual(option.value, props.current)) if (index >= 0) { setStore("selected", index) selection = flat()[index] return } } const option = selected() if (!option) return selection = option return } const previous = selection const index = flat().findIndex((option) => isDeepEqual(option.value, previous.value)) if (index >= 0) { const option = flat()[index] const moved = index !== store.selected || option.category !== previous.category setStore("selected", index) selection = option if (!moved) return const value = option.value const generation = ++visibilityGeneration requestAnimationFrame(() => { requestAnimationFrame(() => { if (generation !== visibilityGeneration) return if (!props.preserveSelection || store.filter.length > 0) return if (!isDeepEqual(selected()?.value, value)) return scrollToSelection(false) }) }) return } const next = Math.min(store.selected, flat().length - 1) if (next < 0) return setStore("selected", next) selection = flat()[next] }, ), ) onCleanup(() => { visibilityGeneration++ }) createEffect( on([() => store.filter, () => props.current], ([filter, current]) => { if (filter.length > 0) resetSelection = true setTimeout(() => { if (filter.length > 0) { moveTo(0, true, false) } else if (current) { const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) if (currentIndex >= 0) { moveTo(currentIndex, true) } } }, 0) }), ) function move(direction: number) { if (props.locked) return if (flat().length === 0) return let next = store.selected + direction if (next < 0) next = flat().length - 1 if (next >= flat().length) next = 0 moveTo(next, true) } function moveTo(next: number, center = false, preserve = true) { setFocusedAction(undefined) setStore("selected", next) const option = selected() if (option) { selection = option resetSelection = !preserve } if (option) props.onMove?.(option) scrollToSelection(center) } function scrollToSelection(center: boolean) { if (!scroll) return let remaining = store.selected let index = 0 // Locate the row by position because a unique renderable ID cannot currently be ensured. for (const [category, options] of grouped()) { if (category) index++ if (remaining < options.length) { index += remaining break } index += options.length remaining -= options.length } const target = scroll.getChildren()[index] if (!target) return const y = target.y - scroll.y if (center) { const centerOffset = Math.floor(scroll.height / 2) scroll.scrollBy(y - centerOffset) } else { if (y >= scroll.height) { scroll.scrollBy(y - scroll.height + 1) } if (y < 0) { scroll.scrollBy(y) if (isDeepEqual(flat()[0].value, selected()?.value)) { scroll.scrollTo(0) } } } } function submit() { if (props.locked) return setStore("input", "keyboard") const index = focusedAction() if (index !== undefined) { triggerAction(actionItems()[index]) return } const option = selected() if (!option) return option.onSelect?.(dialog) props.onSelect?.(option) } function moveAction(direction: 1 | -1) { if (props.locked) return const total = actionItems().length if (total === 0) return setFocusedAction((index) => { if (index === undefined) return direction === 1 ? 0 : total - 1 const next = index + direction return next < 0 || next >= total ? undefined : next }) } useBindings(() => { const visible = shownActions() return { commands: [ { name: "dialog.select.prev", title: "Previous item", category: "Dialog", run() { setStore("input", "keyboard") move(-1) }, }, { name: "dialog.select.next", title: "Next item", category: "Dialog", run() { setStore("input", "keyboard") move(1) }, }, { name: "dialog.select.page_up", title: "Page up", category: "Dialog", run() { setStore("input", "keyboard") move(-10) }, }, { name: "dialog.select.page_down", title: "Page down", category: "Dialog", run() { setStore("input", "keyboard") move(10) }, }, { name: "dialog.select.home", title: "First item", category: "Dialog", run() { if (props.locked) return setStore("input", "keyboard") moveTo(0) }, }, { name: "dialog.select.end", title: "Last item", category: "Dialog", run() { if (props.locked) return setStore("input", "keyboard") moveTo(flat().length - 1) }, }, { name: "dialog.select.submit", title: "Select item", category: "Dialog", run: submit, }, ...visible.map((item) => ({ name: item.command, title: item.title, category: "Dialog", run() { if (props.locked) return if (isActionDisabled(item)) return setStore("input", "keyboard") const option = selected() if (!option) return item.onTrigger(option) }, })), ], bindings: [ ...config.keybinds.gather("dialog.select", [ "dialog.select.prev", "dialog.select.next", "dialog.select.page_up", "dialog.select.page_down", "dialog.select.home", "dialog.select.end", "dialog.select.submit", ]), ...visible.flatMap((item) => config.keybinds.get(item.command)), ...(visible.length ? [ { key: "tab", desc: "Next dialog action", group: "Dialog", cmd: () => moveAction(1), }, { key: "shift+tab", desc: "Previous dialog action", group: "Dialog", cmd: () => moveAction(-1), }, ] : []), ...(props.bindings ?? []).filter((binding) => { if (typeof binding.cmd !== "string") return true return visible.some((item) => item.command === binding.cmd) }), ], } }) let scroll: ScrollBoxRenderable | undefined const ref: DialogSelectRef = { get filter() { return store.filter }, get filtered() { return filtered() }, moveTo(value) { const index = flat().findIndex((option) => isDeepEqual(option.value, value)) if (index >= 0) moveTo(index, true) }, } props.ref?.(ref) const left = createMemo(() => visibleActions().filter((item) => item.side !== "right")) const right = createMemo(() => visibleActions().filter((item) => item.side === "right")) function triggerAction(item: VisibleAction | undefined) { if (props.locked) return if (!item || !isActionItem(item) || isActionDisabled(item)) return setStore("input", "keyboard") const option = selected() if (!option) return item.onTrigger(option) } function isActionItem(item: VisibleAction): item is Action & { label: string } { return "onTrigger" in item } function isActionDisabled(item: Action) { return typeof item.disabled === "function" ? item.disabled(selected()) : item.disabled } function isActionFocused(item: VisibleAction) { if (props.locked) return false if (!isActionItem(item)) return false return actionItems().indexOf(item) === focusedAction() } function FooterAction(action: { item: VisibleAction }) { if (!isActionItem(action.item)) return ( {action.item.title}{" "} {action.item.label} ) const item = action.item const active = createMemo(() => isActionFocused(item)) const disabled = createMemo(() => isActionDisabled(item)) const fg = selectedForeground(theme) return ( triggerAction(item)} > {item.title} {item.label} ) } return ( {props.titleView ?? ( {props.title} )} dialog.clear()}> esc { if (props.locked) return batch(() => { setStore("filter", e) props.onFilter?.(e) }) }} focusedBackgroundColor={theme.backgroundPanel} cursorColor={theme.primary} focusedTextColor={theme.textMuted} ref={(r) => { input = r input.traits = { status: "FILTER" } setTimeout(() => { if (!input) return if (input.isDestroyed) return input.focus() }, 1) }} placeholder={props.placeholder ?? "Search"} placeholderColor={theme.textMuted} /> 0} fallback={ props.emptyView ?? ( No results found ) } > (scroll = r)} maxHeight={height()} > {([category, options], index) => ( <> 0 ? 1 : 0} paddingLeft={3}> {category} } > {options[0]?.categoryView} {(option) => { const active = createMemo(() => !props.locked && isDeepEqual(option.value, selected()?.value)) const current = createMemo(() => isDeepEqual(option.value, props.current)) return ( { if (props.locked) return setStore("input", "mouse") setFocusedAction(undefined) }} onMouseUp={() => { if (props.locked) return option.onSelect?.(dialog) props.onSelect?.(option) }} onMouseOver={() => { if (props.locked) return if (store.input !== "mouse") return const index = flat().findIndex((x) => isDeepEqual(x.value, option.value)) if (index === -1) return moveTo(index) }} onMouseDown={() => { if (props.locked) return const index = flat().findIndex((x) => isDeepEqual(x.value, option.value)) if (index === -1) return moveTo(index) }} > {option.margin} {(detail) => ( {option.detailsWrap ? detail : Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))} )} ) }} )} }> {props.footer} {(item) => } {(item) => } ) } function Option(props: { title: string titleView?: JSX.Element description?: string active?: boolean current?: boolean muted?: boolean footer?: JSX.Element | string titleWidth?: number truncateTitle?: boolean | "left" gutter?: () => JSX.Element onMouseOver?: () => void }) { const { theme } = useTheme() const text = createMemo(() => { if (props.active && !props.muted) return theme.text if (props.muted && (props.active || props.current)) return theme.textMuted if (props.current) return theme.primary return theme.text }) return ( <> {props.gutter?.()} {props.titleView ?? (props.truncateTitle === false ? props.title : props.truncateTitle === "left" ? Locale.truncateLeft(props.title, props.titleWidth ?? 61) : Locale.truncate(props.title, props.titleWidth ?? 61))} {props.description} {props.footer} ) }