dialog-select.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
  2. import { useTheme } from "@tui/context/theme"
  3. import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
  4. import { batch, createEffect, createMemo, For, Show } 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. options: DialogSelectOption<T>[]
  16. ref?: (ref: DialogSelectRef<T>) => void
  17. onMove?: (option: DialogSelectOption<T>) => void
  18. onFilter?: (query: string) => void
  19. onSelect?: (option: DialogSelectOption<T>) => void
  20. keybind?: {
  21. keybind: Keybind.Info
  22. title: string
  23. onTrigger: (option: DialogSelectOption<T>) => void
  24. }[]
  25. limit?: number
  26. current?: T
  27. }
  28. export interface DialogSelectOption<T = any> {
  29. title: string
  30. value: T
  31. description?: string
  32. footer?: string
  33. category?: string
  34. disabled?: boolean
  35. bg?: RGBA
  36. onSelect?: (ctx: DialogContext, trigger?: "prompt") => void
  37. }
  38. export type DialogSelectRef<T> = {
  39. filter: string
  40. filtered: DialogSelectOption<T>[]
  41. }
  42. export function DialogSelect<T>(props: DialogSelectProps<T>) {
  43. const dialog = useDialog()
  44. const { theme } = useTheme()
  45. const [store, setStore] = createStore({
  46. selected: 0,
  47. filter: "",
  48. })
  49. let input: InputRenderable
  50. const filtered = createMemo(() => {
  51. const needle = store.filter.toLowerCase()
  52. const result = pipe(
  53. props.options,
  54. filter((x) => x.disabled !== true),
  55. take(props.limit ?? Infinity),
  56. (x) =>
  57. !needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj),
  58. )
  59. return result
  60. })
  61. const grouped = createMemo(() => {
  62. const result = pipe(
  63. filtered(),
  64. groupBy((x) => x.category ?? ""),
  65. // mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
  66. entries(),
  67. )
  68. return result
  69. })
  70. const flat = createMemo(() => {
  71. return pipe(
  72. grouped(),
  73. flatMap(([_, options]) => options),
  74. )
  75. })
  76. const dimensions = useTerminalDimensions()
  77. const height = createMemo(() =>
  78. Math.min(flat().length + grouped().length * 2 - 1, Math.floor(dimensions().height / 2) - 6),
  79. )
  80. const selected = createMemo(() => flat()[store.selected])
  81. createEffect(() => {
  82. store.filter
  83. setStore("selected", 0)
  84. scroll.scrollTo(0)
  85. })
  86. function move(direction: number) {
  87. let next = store.selected + direction
  88. if (next < 0) next = flat().length - 1
  89. if (next >= flat().length) next = 0
  90. moveTo(next)
  91. }
  92. function moveTo(next: number) {
  93. setStore("selected", next)
  94. props.onMove?.(selected()!)
  95. const target = scroll.getChildren().find((child) => {
  96. return child.id === JSON.stringify(selected()?.value)
  97. })
  98. if (!target) return
  99. const y = target.y - scroll.y
  100. if (y >= scroll.height) {
  101. scroll.scrollBy(y - scroll.height + 1)
  102. }
  103. if (y < 0) {
  104. scroll.scrollBy(y)
  105. if (isDeepEqual(flat()[0].value, selected()?.value)) {
  106. scroll.scrollTo(0)
  107. }
  108. }
  109. }
  110. const keybind = useKeybind()
  111. useKeyboard((evt) => {
  112. if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
  113. if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
  114. if (evt.name === "pageup") move(-10)
  115. if (evt.name === "pagedown") move(10)
  116. if (evt.name === "return") {
  117. const option = selected()
  118. if (option) {
  119. evt.preventDefault()
  120. if (option.onSelect) option.onSelect(dialog)
  121. props.onSelect?.(option)
  122. }
  123. }
  124. for (const item of props.keybind ?? []) {
  125. if (Keybind.match(item.keybind, keybind.parse(evt))) {
  126. const s = selected()
  127. if (s) {
  128. evt.preventDefault()
  129. item.onTrigger(s)
  130. }
  131. }
  132. }
  133. })
  134. let scroll: ScrollBoxRenderable
  135. const ref: DialogSelectRef<T> = {
  136. get filter() {
  137. return store.filter
  138. },
  139. get filtered() {
  140. return filtered()
  141. },
  142. }
  143. props.ref?.(ref)
  144. return (
  145. <box gap={1}>
  146. <box paddingLeft={3} paddingRight={2}>
  147. <box flexDirection="row" justifyContent="space-between">
  148. <text fg={theme.text} attributes={TextAttributes.BOLD}>
  149. {props.title}
  150. </text>
  151. <text fg={theme.textMuted}>esc</text>
  152. </box>
  153. <box paddingTop={1} paddingBottom={1}>
  154. <input
  155. onInput={(e) => {
  156. batch(() => {
  157. setStore("filter", e)
  158. props.onFilter?.(e)
  159. })
  160. }}
  161. focusedBackgroundColor={theme.backgroundPanel}
  162. cursorColor={theme.primary}
  163. focusedTextColor={theme.textMuted}
  164. ref={(r) => {
  165. input = r
  166. input.focus()
  167. }}
  168. placeholder="Enter search term"
  169. />
  170. </box>
  171. </box>
  172. <scrollbox
  173. paddingLeft={2}
  174. paddingRight={2}
  175. scrollbarOptions={{ visible: false }}
  176. ref={(r: ScrollBoxRenderable) => (scroll = r)}
  177. maxHeight={height()}
  178. >
  179. <For each={grouped()}>
  180. {([category, options], index) => (
  181. <>
  182. <Show when={category}>
  183. <box paddingTop={index() > 0 ? 1 : 0} paddingLeft={1}>
  184. <text fg={theme.accent} attributes={TextAttributes.BOLD}>
  185. {category}
  186. </text>
  187. </box>
  188. </Show>
  189. <For each={options}>
  190. {(option) => {
  191. const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
  192. return (
  193. <box
  194. id={JSON.stringify(option.value)}
  195. flexDirection="row"
  196. onMouseUp={() => {
  197. option.onSelect?.(dialog)
  198. props.onSelect?.(option)
  199. }}
  200. onMouseOver={() => {
  201. const index = filtered().findIndex((x) =>
  202. isDeepEqual(x.value, option.value),
  203. )
  204. if (index === -1) return
  205. moveTo(index)
  206. }}
  207. backgroundColor={
  208. active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)
  209. }
  210. paddingLeft={1}
  211. paddingRight={1}
  212. gap={1}
  213. >
  214. <Option
  215. title={option.title}
  216. footer={option.footer}
  217. description={
  218. option.description !== category ? option.description : undefined
  219. }
  220. active={active()}
  221. current={isDeepEqual(option.value, props.current)}
  222. />
  223. </box>
  224. )
  225. }}
  226. </For>
  227. </>
  228. )}
  229. </For>
  230. </scrollbox>
  231. <box paddingRight={2} paddingLeft={3} flexDirection="row" paddingBottom={1} gap={1}>
  232. <For each={props.keybind ?? []}>
  233. {(item) => (
  234. <text>
  235. <span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>
  236. {Keybind.toString(item.keybind)}
  237. </span>
  238. <span style={{ fg: theme.textMuted }}> {item.title}</span>
  239. </text>
  240. )}
  241. </For>
  242. </box>
  243. </box>
  244. )
  245. }
  246. function Option(props: {
  247. title: string
  248. description?: string
  249. active?: boolean
  250. current?: boolean
  251. footer?: string
  252. onMouseOver?: () => void
  253. }) {
  254. const { theme } = useTheme()
  255. return (
  256. <>
  257. <text
  258. flexGrow={1}
  259. fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
  260. attributes={props.active ? TextAttributes.BOLD : undefined}
  261. overflow="hidden"
  262. wrapMode="none"
  263. >
  264. {Locale.truncate(props.title, 62)}
  265. <span style={{ fg: props.active ? theme.background : theme.textMuted }}>
  266. {" "}
  267. {props.description}
  268. </span>
  269. </text>
  270. <Show when={props.footer}>
  271. <box flexShrink={0}>
  272. <text fg={props.active ? theme.background : theme.textMuted}>{props.footer}</text>
  273. </box>
  274. </Show>
  275. </>
  276. )
  277. }