import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { useTheme, selectedForeground } from "@tui/context/theme" import { entries, filter, flatMap, groupBy, pipe, take } 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" export interface DialogSelectProps { title: string placeholder?: string options: DialogSelectOption[] ref?: (ref: DialogSelectRef) => void onMove?: (option: DialogSelectOption) => void onFilter?: (query: string) => void onSelect?: (option: DialogSelectOption) => void skipFilter?: boolean keybind?: { keybind: Keybind.Info title: string disabled?: boolean onTrigger: (option: DialogSelectOption) => void }[] current?: T } export interface DialogSelectOption { title: string value: T description?: string footer?: JSX.Element | string category?: string disabled?: boolean bg?: RGBA gutter?: JSX.Element 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: "", }) 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 result = pipe( props.options, filter((x) => x.disabled !== true), (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( on([() => store.filter, () => props.current], ([filter, current]) => { if (filter.length > 0) { setStore("selected", 0) } else if (current) { const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) if (currentIndex >= 0) { setStore("selected", currentIndex) } } scroll?.scrollTo(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) } function moveTo(next: number) { setStore("selected", next) props.onMove?.(selected()!) 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 (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 (item.disabled) 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) ?? []) return ( {props.title} esc { batch(() => { setStore("filter", e) props.onFilter?.(e) }) }} focusedBackgroundColor={theme.backgroundPanel} cursorColor={theme.primary} focusedTextColor={theme.textMuted} ref={(r) => { input = r setTimeout(() => input.focus(), 1) }} placeholder={props.placeholder ?? "Search"} /> 0} fallback={ No results found } > (scroll = r)} maxHeight={height()} > {([category, options], index) => ( <> 0 ? 1 : 0} paddingLeft={3}> {category} {(option) => { const active = createMemo(() => isDeepEqual(option.value, selected()?.value)) const current = createMemo(() => isDeepEqual(option.value, props.current)) return ( { option.onSelect?.(dialog) props.onSelect?.(option) }} onMouseOver={() => { 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} > ) }} )} }> {(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} ) }