dialog-select.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
  2. import { useTheme, selectedForeground } from "@tui/context/theme"
  3. import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
  4. import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js"
  5. import { createStore } from "solid-js/store"
  6. import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
  7. import * as fuzzysort from "fuzzysort"
  8. import { isDeepEqual } from "remeda"
  9. import { useDialog, type DialogContext } from "@tui/ui/dialog"
  10. import { useKeybind } from "@tui/context/keybind"
  11. import { Keybind } from "@/util/keybind"
  12. import { Locale } from "@/util/locale"
  13. export interface DialogSelectProps<T> {
  14. title: string
  15. placeholder?: string
  16. options: DialogSelectOption<T>[]
  17. ref?: (ref: DialogSelectRef<T>) => void
  18. onMove?: (option: DialogSelectOption<T>) => void
  19. onFilter?: (query: string) => void
  20. onSelect?: (option: DialogSelectOption<T>) => void
  21. skipFilter?: boolean
  22. keybind?: {
  23. keybind?: Keybind.Info
  24. title: string
  25. disabled?: boolean
  26. onTrigger: (option: DialogSelectOption<T>) => void
  27. }[]
  28. current?: T
  29. }
  30. export interface DialogSelectOption<T = any> {
  31. title: string
  32. value: T
  33. description?: string
  34. footer?: JSX.Element | string
  35. category?: string
  36. disabled?: boolean
  37. bg?: RGBA
  38. gutter?: JSX.Element
  39. onSelect?: (ctx: DialogContext) => void
  40. }
  41. export type DialogSelectRef<T> = {
  42. filter: string
  43. filtered: DialogSelectOption<T>[]
  44. }
  45. export function DialogSelect<T>(props: DialogSelectProps<T>) {
  46. const dialog = useDialog()
  47. const { theme } = useTheme()
  48. const [store, setStore] = createStore({
  49. selected: 0,
  50. filter: "",
  51. input: "keyboard" as "keyboard" | "mouse",
  52. })
  53. createEffect(
  54. on(
  55. () => props.current,
  56. (current) => {
  57. if (current) {
  58. const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
  59. if (currentIndex >= 0) {
  60. setStore("selected", currentIndex)
  61. }
  62. }
  63. },
  64. ),
  65. )
  66. let input: InputRenderable
  67. const filtered = createMemo(() => {
  68. if (props.skipFilter) return props.options.filter((x) => x.disabled !== true)
  69. const needle = store.filter.toLowerCase()
  70. const options = pipe(
  71. props.options,
  72. filter((x) => x.disabled !== true),
  73. )
  74. if (!needle) return options
  75. // prioritize title matches (weight: 2) over category matches (weight: 1).
  76. // users typically search by the item name, and not its category.
  77. const result = fuzzysort
  78. .go(needle, options, {
  79. keys: ["title", "category"],
  80. scoreFn: (r) => r[0].score * 2 + r[1].score,
  81. })
  82. .map((x) => x.obj)
  83. return result
  84. })
  85. // When the filter changes due to how TUI works, the mousemove might still be triggered
  86. // via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard
  87. // that the mouseover event doesn't trigger when filtering.
  88. createEffect(() => {
  89. filtered()
  90. setStore("input", "keyboard")
  91. })
  92. const grouped = createMemo(() => {
  93. const result = pipe(
  94. filtered(),
  95. groupBy((x) => x.category ?? ""),
  96. // mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
  97. entries(),
  98. )
  99. return result
  100. })
  101. const flat = createMemo(() => {
  102. return pipe(
  103. grouped(),
  104. flatMap(([_, options]) => options),
  105. )
  106. })
  107. const dimensions = useTerminalDimensions()
  108. const height = createMemo(() =>
  109. Math.min(flat().length + grouped().length * 2 - 1, Math.floor(dimensions().height / 2) - 6),
  110. )
  111. const selected = createMemo(() => flat()[store.selected])
  112. createEffect(
  113. on([() => store.filter, () => props.current], ([filter, current]) => {
  114. setTimeout(() => {
  115. if (filter.length > 0) {
  116. moveTo(0, true)
  117. } else if (current) {
  118. const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
  119. if (currentIndex >= 0) {
  120. moveTo(currentIndex, true)
  121. }
  122. }
  123. }, 0)
  124. }),
  125. )
  126. function move(direction: number) {
  127. if (flat().length === 0) return
  128. let next = store.selected + direction
  129. if (next < 0) next = flat().length - 1
  130. if (next >= flat().length) next = 0
  131. moveTo(next, true)
  132. }
  133. function moveTo(next: number, center = false) {
  134. setStore("selected", next)
  135. const option = selected()
  136. if (option) props.onMove?.(option)
  137. if (!scroll) return
  138. const target = scroll.getChildren().find((child) => {
  139. return child.id === JSON.stringify(selected()?.value)
  140. })
  141. if (!target) return
  142. const y = target.y - scroll.y
  143. if (center) {
  144. const centerOffset = Math.floor(scroll.height / 2)
  145. scroll.scrollBy(y - centerOffset)
  146. } else {
  147. if (y >= scroll.height) {
  148. scroll.scrollBy(y - scroll.height + 1)
  149. }
  150. if (y < 0) {
  151. scroll.scrollBy(y)
  152. if (isDeepEqual(flat()[0].value, selected()?.value)) {
  153. scroll.scrollTo(0)
  154. }
  155. }
  156. }
  157. }
  158. const keybind = useKeybind()
  159. useKeyboard((evt) => {
  160. setStore("input", "keyboard")
  161. if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
  162. if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
  163. if (evt.name === "pageup") move(-10)
  164. if (evt.name === "pagedown") move(10)
  165. if (evt.name === "home") moveTo(0)
  166. if (evt.name === "end") moveTo(flat().length - 1)
  167. if (evt.name === "return") {
  168. const option = selected()
  169. if (option) {
  170. evt.preventDefault()
  171. evt.stopPropagation()
  172. if (option.onSelect) option.onSelect(dialog)
  173. props.onSelect?.(option)
  174. }
  175. }
  176. for (const item of props.keybind ?? []) {
  177. if (item.disabled || !item.keybind) continue
  178. if (Keybind.match(item.keybind, keybind.parse(evt))) {
  179. const s = selected()
  180. if (s) {
  181. evt.preventDefault()
  182. item.onTrigger(s)
  183. }
  184. }
  185. }
  186. })
  187. let scroll: ScrollBoxRenderable | undefined
  188. const ref: DialogSelectRef<T> = {
  189. get filter() {
  190. return store.filter
  191. },
  192. get filtered() {
  193. return filtered()
  194. },
  195. }
  196. props.ref?.(ref)
  197. const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? [])
  198. return (
  199. <box gap={1} paddingBottom={1}>
  200. <box paddingLeft={4} paddingRight={4}>
  201. <box flexDirection="row" justifyContent="space-between">
  202. <text fg={theme.text} attributes={TextAttributes.BOLD}>
  203. {props.title}
  204. </text>
  205. <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
  206. esc
  207. </text>
  208. </box>
  209. <box paddingTop={1}>
  210. <input
  211. onInput={(e) => {
  212. batch(() => {
  213. setStore("filter", e)
  214. props.onFilter?.(e)
  215. })
  216. }}
  217. focusedBackgroundColor={theme.backgroundPanel}
  218. cursorColor={theme.primary}
  219. focusedTextColor={theme.textMuted}
  220. ref={(r) => {
  221. input = r
  222. setTimeout(() => {
  223. if (!input) return
  224. if (input.isDestroyed) return
  225. input.focus()
  226. }, 1)
  227. }}
  228. placeholder={props.placeholder ?? "Search"}
  229. />
  230. </box>
  231. </box>
  232. <Show
  233. when={grouped().length > 0}
  234. fallback={
  235. <box paddingLeft={4} paddingRight={4} paddingTop={1}>
  236. <text fg={theme.textMuted}>No results found</text>
  237. </box>
  238. }
  239. >
  240. <scrollbox
  241. paddingLeft={1}
  242. paddingRight={1}
  243. scrollbarOptions={{ visible: false }}
  244. ref={(r: ScrollBoxRenderable) => (scroll = r)}
  245. maxHeight={height()}
  246. >
  247. <For each={grouped()}>
  248. {([category, options], index) => (
  249. <>
  250. <Show when={category}>
  251. <box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
  252. <text fg={theme.accent} attributes={TextAttributes.BOLD}>
  253. {category}
  254. </text>
  255. </box>
  256. </Show>
  257. <For each={options}>
  258. {(option) => {
  259. const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
  260. const current = createMemo(() => isDeepEqual(option.value, props.current))
  261. return (
  262. <box
  263. id={JSON.stringify(option.value)}
  264. flexDirection="row"
  265. onMouseMove={() => {
  266. setStore("input", "mouse")
  267. }}
  268. onMouseUp={() => {
  269. option.onSelect?.(dialog)
  270. props.onSelect?.(option)
  271. }}
  272. onMouseOver={() => {
  273. if (store.input !== "mouse") return
  274. const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
  275. if (index === -1) return
  276. moveTo(index)
  277. }}
  278. onMouseDown={() => {
  279. const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
  280. if (index === -1) return
  281. moveTo(index)
  282. }}
  283. backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
  284. paddingLeft={current() || option.gutter ? 1 : 3}
  285. paddingRight={3}
  286. gap={1}
  287. >
  288. <Option
  289. title={option.title}
  290. footer={option.footer}
  291. description={option.description !== category ? option.description : undefined}
  292. active={active()}
  293. current={current()}
  294. gutter={option.gutter}
  295. />
  296. </box>
  297. )
  298. }}
  299. </For>
  300. </>
  301. )}
  302. </For>
  303. </scrollbox>
  304. </Show>
  305. <Show when={keybinds().length} fallback={<box flexShrink={0} />}>
  306. <box paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}>
  307. <For each={keybinds()}>
  308. {(item) => (
  309. <text>
  310. <span style={{ fg: theme.text }}>
  311. <b>{item.title}</b>{" "}
  312. </span>
  313. <span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
  314. </text>
  315. )}
  316. </For>
  317. </box>
  318. </Show>
  319. </box>
  320. )
  321. }
  322. function Option(props: {
  323. title: string
  324. description?: string
  325. active?: boolean
  326. current?: boolean
  327. footer?: JSX.Element | string
  328. gutter?: JSX.Element
  329. onMouseOver?: () => void
  330. }) {
  331. const { theme } = useTheme()
  332. const fg = selectedForeground(theme)
  333. return (
  334. <>
  335. <Show when={props.current}>
  336. <text flexShrink={0} fg={props.active ? fg : props.current ? theme.primary : theme.text} marginRight={0}>
  337. ●
  338. </text>
  339. </Show>
  340. <Show when={!props.current && props.gutter}>
  341. <box flexShrink={0} marginRight={0}>
  342. {props.gutter}
  343. </box>
  344. </Show>
  345. <text
  346. flexGrow={1}
  347. fg={props.active ? fg : props.current ? theme.primary : theme.text}
  348. attributes={props.active ? TextAttributes.BOLD : undefined}
  349. overflow="hidden"
  350. wrapMode="none"
  351. paddingLeft={3}
  352. >
  353. {Locale.truncate(props.title, 61)}
  354. <Show when={props.description}>
  355. <span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
  356. </Show>
  357. </text>
  358. <Show when={props.footer}>
  359. <box flexShrink={0}>
  360. <text fg={props.active ? fg : theme.textMuted}>{props.footer}</text>
  361. </box>
  362. </Show>
  363. </>
  364. )
  365. }