import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { useTheme } from "@tui/context/theme" import { entries, filter, flatMap, groupBy, pipe, take } from "remeda" import { batch, createEffect, createMemo, For, Show } 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" export interface DialogSelectProps { title: string options: DialogSelectOption[] ref?: (ref: DialogSelectRef) => void onMove?: (option: DialogSelectOption) => void onFilter?: (query: string) => void onSelect?: (option: DialogSelectOption) => void keybind?: { keybind: Keybind.Info title: string onTrigger: (option: DialogSelectOption) => void }[] limit?: number current?: T } export interface DialogSelectOption { title: string value: T description?: string footer?: string category?: string disabled?: boolean bg?: RGBA onSelect?: (ctx: DialogContext, trigger?: "prompt") => void } export type DialogSelectRef = { filter: string filtered: DialogSelectOption[] } export function DialogSelect(props: DialogSelectProps) { const dialog = useDialog() const { theme } = useTheme() const [store, setStore] = createStore({ selected: 0, filter: "", }) let input: InputRenderable const filtered = createMemo(() => { const needle = store.filter.toLowerCase() const result = pipe( props.options, filter((x) => x.disabled !== true), take(props.limit ?? Infinity), (x) => !needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj), ) return result }) const grouped = createMemo(() => { 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 dimensions = useTerminalDimensions() const height = createMemo(() => Math.min(flat().length + grouped().length * 2 - 1, Math.floor(dimensions().height / 2) - 6), ) const selected = createMemo(() => flat()[store.selected]) createEffect(() => { store.filter setStore("selected", 0) scroll.scrollTo(0) }) function move(direction: number) { let next = store.selected + direction if (next < 0) next = flat().length - 1 if (next >= flat().length) next = 0 moveTo(next) } function moveTo(next: number) { setStore("selected", next) props.onMove?.(selected()!) const target = scroll.getChildren().find((child) => { return child.id === JSON.stringify(selected()?.value) }) if (!target) return const y = target.y - scroll.y 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) => { 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 === "return") { const option = selected() if (option) { evt.preventDefault() if (option.onSelect) option.onSelect(dialog) props.onSelect?.(option) } } for (const item of props.keybind ?? []) { if (Keybind.match(item.keybind, keybind.parse(evt))) { const s = selected() if (s) { evt.preventDefault() item.onTrigger(s) } } } }) let scroll: ScrollBoxRenderable const ref: DialogSelectRef = { get filter() { return store.filter }, get filtered() { return filtered() }, } props.ref?.(ref) return ( {props.title} esc { batch(() => { setStore("filter", e) props.onFilter?.(e) }) }} focusedBackgroundColor={theme.backgroundPanel} cursorColor={theme.primary} focusedTextColor={theme.textMuted} ref={(r) => { input = r input.focus() }} placeholder="Enter search term" /> (scroll = r)} maxHeight={height()} > {([category, options], index) => ( <> 0 ? 1 : 0} paddingLeft={1}> {category} {(option) => { const active = createMemo(() => isDeepEqual(option.value, selected()?.value)) return ( { option.onSelect?.(dialog) props.onSelect?.(option) }} onMouseOver={() => { const index = filtered().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={1} paddingRight={1} gap={1} > ) }} )} {(item) => ( {Keybind.toString(item.keybind)} {item.title} )} ) } function Option(props: { title: string description?: string active?: boolean current?: boolean footer?: string onMouseOver?: () => void }) { const { theme } = useTheme() return ( <> {Locale.truncate(props.title, 62)} {" "} {props.description} {props.footer} ) }