import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { useTheme, selectedForeground } from "@tui/context/theme" import { entries, filter, flatMap, groupBy, pipe } from "remeda" import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js" import { createStore } from "solid-js/store" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import * as fuzzysort from "fuzzysort" import { isDeepEqual } from "remeda" import { useDialog, type DialogContext } from "@tui/ui/dialog" import { useKeybind } from "@tui/context/keybind" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" import { getScrollAcceleration } from "../util/scroll" import { useTuiConfig } from "../context/tui-config" export interface DialogSelectProps { title: string placeholder?: string options: DialogSelectOption[] flat?: boolean ref?: (ref: DialogSelectRef) => void onMove?: (option: DialogSelectOption) => void onFilter?: (query: string) => void onSelect?: (option: DialogSelectOption) => void skipFilter?: boolean keybind?: { keybind?: Keybind.Info title: string side?: "left" | "right" disabled?: boolean onTrigger: (option: DialogSelectOption) => void }[] current?: T } export interface DialogSelectOption { title: string value: T description?: string footer?: JSX.Element | string 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[] } export function DialogSelect(props: DialogSelectProps) { const dialog = useDialog() const { theme } = useTheme() const tuiConfig = useTuiConfig() const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const [store, setStore] = createStore({ selected: 0, filter: "", input: "keyboard" as "keyboard" | "mouse", }) createEffect( on( () => props.current, (current) => { if (current) { const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) if (currentIndex >= 0) { setStore("selected", currentIndex) } } }, ), ) let input: InputRenderable const filtered = createMemo(() => { if (props.skipFilter) 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") }) 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().length + headers }) const dimensions = useTerminalDimensions() const height = createMemo(() => Math.min(rows(), Math.floor(dimensions().height / 2) - 6)) const selected = createMemo(() => flat()[store.selected]) createEffect( on([() => store.filter, () => props.current], ([filter, current]) => { setTimeout(() => { if (filter.length > 0) { moveTo(0, true) } else if (current) { const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) if (currentIndex >= 0) { moveTo(currentIndex, true) } } }, 0) }), ) function move(direction: number) { 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) { setStore("selected", next) const option = selected() if (option) props.onMove?.(option) if (!scroll) return const target = scroll.getChildren().find((child) => { return child.id === JSON.stringify(selected()?.value) }) 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) } } } } const keybind = useKeybind() useKeyboard((evt) => { setStore("input", "keyboard") if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1) if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1) if (evt.name === "pageup") move(-10) if (evt.name === "pagedown") move(10) if (evt.name === "home") moveTo(0) if (evt.name === "end") moveTo(flat().length - 1) if (evt.name === "return") { const option = selected() if (option) { evt.preventDefault() evt.stopPropagation() if (option.onSelect) option.onSelect(dialog) props.onSelect?.(option) } } for (const item of props.keybind ?? []) { if (item.disabled || !item.keybind) continue if (Keybind.match(item.keybind, keybind.parse(evt))) { const s = selected() if (s) { evt.preventDefault() item.onTrigger(s) } } } }) let scroll: ScrollBoxRenderable | undefined const ref: DialogSelectRef = { get filter() { return store.filter }, get filtered() { return filtered() }, } props.ref?.(ref) const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? []) const left = createMemo(() => keybinds().filter((item) => item.side !== "right")) const right = createMemo(() => keybinds().filter((item) => item.side === "right")) return ( {props.title} dialog.clear()}> esc { 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={ No results found } > (scroll = r)} maxHeight={height()} > {([category, options], index) => ( <> 0 ? 1 : 0} paddingLeft={3}> {category} } > {options[0]?.categoryView} {(option) => { const active = createMemo(() => isDeepEqual(option.value, selected()?.value)) const current = createMemo(() => isDeepEqual(option.value, props.current)) return ( { setStore("input", "mouse") }} onMouseUp={() => { option.onSelect?.(dialog) props.onSelect?.(option) }} onMouseOver={() => { if (store.input !== "mouse") return const index = flat().findIndex((x) => isDeepEqual(x.value, option.value)) if (index === -1) return moveTo(index) }} onMouseDown={() => { const index = flat().findIndex((x) => isDeepEqual(x.value, option.value)) if (index === -1) return moveTo(index) }} backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)} paddingLeft={current() || option.gutter ? 1 : 3} paddingRight={3} gap={1} > {option.margin} ) }} )} }> {(item) => ( {item.title}{" "} {Keybind.toString(item.keybind)} )} {(item) => ( {item.title}{" "} {Keybind.toString(item.keybind)} )} ) } function Option(props: { title: string description?: string active?: boolean current?: boolean footer?: JSX.Element | string gutter?: () => JSX.Element onMouseOver?: () => void }) { const { theme } = useTheme() const fg = selectedForeground(theme) return ( <> {props.gutter?.()} {Locale.truncate(props.title, 61)} {props.description} {props.footer} ) }