796 lines
25 KiB
TypeScript
796 lines
25 KiB
TypeScript
import {
|
|
InputRenderable,
|
|
RGBA,
|
|
ScrollBoxRenderable,
|
|
TextAttributes,
|
|
type KeyEvent,
|
|
type Renderable,
|
|
} from "@opentui/core"
|
|
import type { Binding } from "@opentui/keymap"
|
|
import { useTheme, selectedForeground } from "../context/theme"
|
|
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
|
|
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js"
|
|
import { createStore } from "solid-js/store"
|
|
import { useTerminalDimensions } from "@opentui/solid"
|
|
import * as fuzzysort from "fuzzysort"
|
|
import { isDeepEqual } from "remeda"
|
|
import { useDialog, type DialogContext } from "./dialog"
|
|
import { Locale } from "../util/locale"
|
|
import { getScrollAcceleration } from "../util/scroll"
|
|
import { useConfig } from "../config"
|
|
import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
|
|
|
|
export interface DialogSelectProps<T> {
|
|
title: string
|
|
titleView?: JSX.Element
|
|
placeholder?: string
|
|
footer?: JSX.Element
|
|
emptyView?: JSX.Element
|
|
options: DialogSelectOption<T>[]
|
|
flat?: boolean
|
|
ref?: (ref: DialogSelectRef<T>) => void
|
|
onMove?: (option: DialogSelectOption<T>) => void
|
|
onFilter?: (query: string) => void
|
|
onSelect?: (option: DialogSelectOption<T>) => void
|
|
skipFilter?: boolean
|
|
renderFilter?: boolean
|
|
locked?: boolean
|
|
preserveSelection?: boolean
|
|
actions?: {
|
|
command: string
|
|
title: string
|
|
side?: "left" | "right"
|
|
hidden?: boolean
|
|
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
|
|
onTrigger: (option: DialogSelectOption<T>) => void
|
|
}[]
|
|
footerHints?: {
|
|
title: string
|
|
label: string
|
|
side?: "left" | "right"
|
|
}[]
|
|
bindings?: readonly Binding<Renderable, KeyEvent>[]
|
|
current?: T
|
|
}
|
|
|
|
export interface DialogSelectOption<T = any> {
|
|
title: string
|
|
titleView?: JSX.Element
|
|
value: T
|
|
description?: string
|
|
details?: string[]
|
|
detailsColor?: RGBA
|
|
detailsWrap?: boolean
|
|
footer?: JSX.Element | string
|
|
titleWidth?: number
|
|
truncateTitle?: boolean | "left"
|
|
category?: string
|
|
categoryView?: JSX.Element
|
|
disabled?: boolean
|
|
bg?: RGBA
|
|
gutter?: () => JSX.Element
|
|
margin?: JSX.Element
|
|
onSelect?: (ctx: DialogContext) => void
|
|
}
|
|
|
|
export type DialogSelectRef<T> = {
|
|
filter: string
|
|
filtered: DialogSelectOption<T>[]
|
|
moveTo(value: T): void
|
|
}
|
|
|
|
export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|
type Action = NonNullable<DialogSelectProps<T>["actions"]>[number]
|
|
type FooterHint = NonNullable<DialogSelectProps<T>["footerHints"]>[number]
|
|
type VisibleAction = (Action & { label: string }) | FooterHint
|
|
|
|
const dialog = useDialog()
|
|
const { theme } = useTheme()
|
|
const config = useConfig().data
|
|
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
|
|
|
const [store, setStore] = createStore({
|
|
selected: 0,
|
|
filter: "",
|
|
input: "keyboard" as "keyboard" | "mouse",
|
|
})
|
|
const [focusedAction, setFocusedAction] = createSignal<number>()
|
|
const actionFocused = createMemo(() => focusedAction() !== undefined)
|
|
let selection: { value: T; category?: string } | undefined
|
|
let resetSelection = false
|
|
let visibilityGeneration = 0
|
|
|
|
createEffect(
|
|
on(
|
|
() => props.current,
|
|
(current) => {
|
|
if (current) {
|
|
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
|
|
if (currentIndex >= 0) {
|
|
setStore("selected", currentIndex)
|
|
selection = flat()[currentIndex]
|
|
}
|
|
}
|
|
},
|
|
),
|
|
)
|
|
|
|
let input: InputRenderable
|
|
|
|
const actions = createMemo(() => props.actions ?? [])
|
|
const shownActions = createMemo(() => actions().filter((item) => !item.hidden))
|
|
const actionBindings = useKeymapSelector((keymap) =>
|
|
keymap.getCommandBindings({
|
|
visibility: "registered",
|
|
commands: shownActions().map((item) => item.command),
|
|
}),
|
|
)
|
|
|
|
const actionLabels = createMemo(() => {
|
|
const labels = new Map<string, string>()
|
|
|
|
for (const action of shownActions()) {
|
|
const label = formatKeyBindings(actionBindings().get(action.command), config)
|
|
if (label) labels.set(action.command, label)
|
|
}
|
|
|
|
return labels
|
|
})
|
|
const visibleActions = createMemo(() => [
|
|
...shownActions()
|
|
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
|
|
.filter((item) => item.label),
|
|
...(props.footerHints ?? []),
|
|
])
|
|
const actionItems = createMemo(() =>
|
|
visibleActions()
|
|
.filter(isActionItem)
|
|
.filter((item) => !isActionDisabled(item)),
|
|
)
|
|
|
|
createEffect(() => {
|
|
const index = focusedAction()
|
|
if (index !== undefined && index >= actionItems().length) setFocusedAction(undefined)
|
|
})
|
|
|
|
const filtered = createMemo(() => {
|
|
if (props.skipFilter || props.renderFilter === false) 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")
|
|
setFocusedAction(undefined)
|
|
})
|
|
|
|
const flatten = createMemo(() => props.flat && store.filter.length > 0)
|
|
|
|
const grouped = createMemo<[string, DialogSelectOption<T>[]][]>(() => {
|
|
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().reduce((acc, option) => acc + 1 + (option.details?.length ?? 0), headers)
|
|
})
|
|
|
|
const dimensions = useTerminalDimensions()
|
|
const height = createMemo(() => Math.min(rows(), Math.floor(dimensions().height / 2) - 6))
|
|
|
|
const selected = createMemo(() => flat()[store.selected])
|
|
|
|
createEffect(
|
|
on(
|
|
() => props.options,
|
|
() => {
|
|
if (!props.preserveSelection) return
|
|
if (resetSelection && store.filter.length > 0) {
|
|
const option = flat()[0]
|
|
if (!option) return
|
|
setStore("selected", 0)
|
|
selection = option
|
|
return
|
|
}
|
|
if (!selection) {
|
|
if (props.current !== undefined) {
|
|
const index = flat().findIndex((option) => isDeepEqual(option.value, props.current))
|
|
if (index >= 0) {
|
|
setStore("selected", index)
|
|
selection = flat()[index]
|
|
return
|
|
}
|
|
}
|
|
const option = selected()
|
|
if (!option) return
|
|
selection = option
|
|
return
|
|
}
|
|
const previous = selection
|
|
const index = flat().findIndex((option) => isDeepEqual(option.value, previous.value))
|
|
if (index >= 0) {
|
|
const option = flat()[index]
|
|
const moved = index !== store.selected || option.category !== previous.category
|
|
setStore("selected", index)
|
|
selection = option
|
|
if (!moved) return
|
|
const value = option.value
|
|
const generation = ++visibilityGeneration
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
if (generation !== visibilityGeneration) return
|
|
if (!props.preserveSelection || store.filter.length > 0) return
|
|
if (!isDeepEqual(selected()?.value, value)) return
|
|
scrollToSelection(false)
|
|
})
|
|
})
|
|
return
|
|
}
|
|
const next = Math.min(store.selected, flat().length - 1)
|
|
if (next < 0) return
|
|
setStore("selected", next)
|
|
selection = flat()[next]
|
|
},
|
|
),
|
|
)
|
|
onCleanup(() => {
|
|
visibilityGeneration++
|
|
})
|
|
|
|
createEffect(
|
|
on([() => store.filter, () => props.current], ([filter, current]) => {
|
|
if (filter.length > 0) resetSelection = true
|
|
setTimeout(() => {
|
|
if (filter.length > 0) {
|
|
moveTo(0, true, false)
|
|
} else if (current) {
|
|
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
|
|
if (currentIndex >= 0) {
|
|
moveTo(currentIndex, true)
|
|
}
|
|
}
|
|
}, 0)
|
|
}),
|
|
)
|
|
|
|
function move(direction: number) {
|
|
if (props.locked) return
|
|
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, preserve = true) {
|
|
setFocusedAction(undefined)
|
|
setStore("selected", next)
|
|
const option = selected()
|
|
if (option) {
|
|
selection = option
|
|
resetSelection = !preserve
|
|
}
|
|
if (option) props.onMove?.(option)
|
|
scrollToSelection(center)
|
|
}
|
|
|
|
function scrollToSelection(center: boolean) {
|
|
if (!scroll) return
|
|
let remaining = store.selected
|
|
let index = 0
|
|
// Locate the row by position because a unique renderable ID cannot currently be ensured.
|
|
for (const [category, options] of grouped()) {
|
|
if (category) index++
|
|
if (remaining < options.length) {
|
|
index += remaining
|
|
break
|
|
}
|
|
index += options.length
|
|
remaining -= options.length
|
|
}
|
|
const target = scroll.getChildren()[index]
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function submit() {
|
|
if (props.locked) return
|
|
setStore("input", "keyboard")
|
|
const index = focusedAction()
|
|
if (index !== undefined) {
|
|
triggerAction(actionItems()[index])
|
|
return
|
|
}
|
|
const option = selected()
|
|
if (!option) return
|
|
option.onSelect?.(dialog)
|
|
props.onSelect?.(option)
|
|
}
|
|
|
|
function moveAction(direction: 1 | -1) {
|
|
if (props.locked) return
|
|
const total = actionItems().length
|
|
if (total === 0) return
|
|
setFocusedAction((index) => {
|
|
if (index === undefined) return direction === 1 ? 0 : total - 1
|
|
const next = index + direction
|
|
return next < 0 || next >= total ? undefined : next
|
|
})
|
|
}
|
|
|
|
useBindings(() => {
|
|
const visible = shownActions()
|
|
|
|
return {
|
|
commands: [
|
|
{
|
|
name: "dialog.select.prev",
|
|
title: "Previous item",
|
|
category: "Dialog",
|
|
run() {
|
|
setStore("input", "keyboard")
|
|
move(-1)
|
|
},
|
|
},
|
|
{
|
|
name: "dialog.select.next",
|
|
title: "Next item",
|
|
category: "Dialog",
|
|
run() {
|
|
setStore("input", "keyboard")
|
|
move(1)
|
|
},
|
|
},
|
|
{
|
|
name: "dialog.select.page_up",
|
|
title: "Page up",
|
|
category: "Dialog",
|
|
run() {
|
|
setStore("input", "keyboard")
|
|
move(-10)
|
|
},
|
|
},
|
|
{
|
|
name: "dialog.select.page_down",
|
|
title: "Page down",
|
|
category: "Dialog",
|
|
run() {
|
|
setStore("input", "keyboard")
|
|
move(10)
|
|
},
|
|
},
|
|
{
|
|
name: "dialog.select.home",
|
|
title: "First item",
|
|
category: "Dialog",
|
|
run() {
|
|
if (props.locked) return
|
|
setStore("input", "keyboard")
|
|
moveTo(0)
|
|
},
|
|
},
|
|
{
|
|
name: "dialog.select.end",
|
|
title: "Last item",
|
|
category: "Dialog",
|
|
run() {
|
|
if (props.locked) return
|
|
setStore("input", "keyboard")
|
|
moveTo(flat().length - 1)
|
|
},
|
|
},
|
|
{
|
|
name: "dialog.select.submit",
|
|
title: "Select item",
|
|
category: "Dialog",
|
|
run: submit,
|
|
},
|
|
...visible.map((item) => ({
|
|
name: item.command,
|
|
title: item.title,
|
|
category: "Dialog",
|
|
run() {
|
|
if (props.locked) return
|
|
if (isActionDisabled(item)) return
|
|
setStore("input", "keyboard")
|
|
const option = selected()
|
|
if (!option) return
|
|
item.onTrigger(option)
|
|
},
|
|
})),
|
|
],
|
|
bindings: [
|
|
...config.keybinds.gather("dialog.select", [
|
|
"dialog.select.prev",
|
|
"dialog.select.next",
|
|
"dialog.select.page_up",
|
|
"dialog.select.page_down",
|
|
"dialog.select.home",
|
|
"dialog.select.end",
|
|
"dialog.select.submit",
|
|
]),
|
|
...visible.flatMap((item) => config.keybinds.get(item.command)),
|
|
...(visible.length
|
|
? [
|
|
{
|
|
key: "tab",
|
|
desc: "Next dialog action",
|
|
group: "Dialog",
|
|
cmd: () => moveAction(1),
|
|
},
|
|
{
|
|
key: "shift+tab",
|
|
desc: "Previous dialog action",
|
|
group: "Dialog",
|
|
cmd: () => moveAction(-1),
|
|
},
|
|
]
|
|
: []),
|
|
...(props.bindings ?? []).filter((binding) => {
|
|
if (typeof binding.cmd !== "string") return true
|
|
return visible.some((item) => item.command === binding.cmd)
|
|
}),
|
|
],
|
|
}
|
|
})
|
|
|
|
let scroll: ScrollBoxRenderable | undefined
|
|
const ref: DialogSelectRef<T> = {
|
|
get filter() {
|
|
return store.filter
|
|
},
|
|
get filtered() {
|
|
return filtered()
|
|
},
|
|
moveTo(value) {
|
|
const index = flat().findIndex((option) => isDeepEqual(option.value, value))
|
|
if (index >= 0) moveTo(index, true)
|
|
},
|
|
}
|
|
props.ref?.(ref)
|
|
|
|
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
|
|
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
|
|
|
|
function triggerAction(item: VisibleAction | undefined) {
|
|
if (props.locked) return
|
|
if (!item || !isActionItem(item) || isActionDisabled(item)) return
|
|
setStore("input", "keyboard")
|
|
const option = selected()
|
|
if (!option) return
|
|
item.onTrigger(option)
|
|
}
|
|
|
|
function isActionItem(item: VisibleAction): item is Action & { label: string } {
|
|
return "onTrigger" in item
|
|
}
|
|
|
|
function isActionDisabled(item: Action) {
|
|
return typeof item.disabled === "function" ? item.disabled(selected()) : item.disabled
|
|
}
|
|
|
|
function isActionFocused(item: VisibleAction) {
|
|
if (props.locked) return false
|
|
if (!isActionItem(item)) return false
|
|
return actionItems().indexOf(item) === focusedAction()
|
|
}
|
|
|
|
function FooterAction(action: { item: VisibleAction }) {
|
|
if (!isActionItem(action.item))
|
|
return (
|
|
<text>
|
|
<span style={{ fg: theme.text }}>
|
|
<b>{action.item.title}</b>{" "}
|
|
</span>
|
|
<span style={{ fg: theme.textMuted }}>{action.item.label}</span>
|
|
</text>
|
|
)
|
|
const item = action.item
|
|
const active = createMemo(() => isActionFocused(item))
|
|
const disabled = createMemo(() => isActionDisabled(item))
|
|
const fg = selectedForeground(theme)
|
|
return (
|
|
<box
|
|
flexDirection="row"
|
|
backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
|
onMouseUp={() => triggerAction(item)}
|
|
>
|
|
<text
|
|
fg={disabled() ? theme.textMuted : active() ? fg : theme.text}
|
|
attributes={active() ? TextAttributes.BOLD : undefined}
|
|
>
|
|
{item.title}
|
|
</text>
|
|
<text fg={disabled() ? theme.textMuted : active() ? fg : theme.textMuted}> {item.label}</text>
|
|
</box>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<box gap={1} paddingBottom={1} flexGrow={1}>
|
|
<box paddingLeft={4} paddingRight={4}>
|
|
<box flexDirection="row" justifyContent="space-between">
|
|
{props.titleView ?? (
|
|
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
|
{props.title}
|
|
</text>
|
|
)}
|
|
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
|
esc
|
|
</text>
|
|
</box>
|
|
<Show when={props.renderFilter !== false}>
|
|
<box paddingTop={1}>
|
|
<input
|
|
onInput={(e) => {
|
|
if (props.locked) return
|
|
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}
|
|
/>
|
|
</box>
|
|
</Show>
|
|
</box>
|
|
<box flexGrow={1} flexShrink={1}>
|
|
<Show
|
|
when={grouped().length > 0}
|
|
fallback={
|
|
props.emptyView ?? (
|
|
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
|
<text fg={theme.textMuted}>No results found</text>
|
|
</box>
|
|
)
|
|
}
|
|
>
|
|
<scrollbox
|
|
paddingLeft={1}
|
|
paddingRight={1}
|
|
scrollbarOptions={{ visible: false }}
|
|
scrollAcceleration={scrollAcceleration()}
|
|
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
|
maxHeight={height()}
|
|
>
|
|
<For each={grouped()}>
|
|
{([category, options], index) => (
|
|
<>
|
|
<Show when={category}>
|
|
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
|
|
<Show
|
|
when={options[0]?.categoryView}
|
|
fallback={
|
|
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
|
|
{category}
|
|
</text>
|
|
}
|
|
>
|
|
{options[0]?.categoryView}
|
|
</Show>
|
|
</box>
|
|
</Show>
|
|
<For each={options}>
|
|
{(option) => {
|
|
const active = createMemo(() => !props.locked && isDeepEqual(option.value, selected()?.value))
|
|
const current = createMemo(() => isDeepEqual(option.value, props.current))
|
|
return (
|
|
<box
|
|
flexDirection="column"
|
|
position="relative"
|
|
onMouseMove={() => {
|
|
if (props.locked) return
|
|
setStore("input", "mouse")
|
|
setFocusedAction(undefined)
|
|
}}
|
|
onMouseUp={() => {
|
|
if (props.locked) return
|
|
option.onSelect?.(dialog)
|
|
props.onSelect?.(option)
|
|
}}
|
|
onMouseOver={() => {
|
|
if (props.locked) return
|
|
if (store.input !== "mouse") return
|
|
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
|
|
if (index === -1) return
|
|
moveTo(index)
|
|
}}
|
|
onMouseDown={() => {
|
|
if (props.locked) return
|
|
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
|
|
if (index === -1) return
|
|
moveTo(index)
|
|
}}
|
|
>
|
|
<box
|
|
flexDirection="row"
|
|
paddingLeft={current() || option.gutter ? 1 : 3}
|
|
paddingRight={3}
|
|
gap={1}
|
|
backgroundColor={
|
|
active()
|
|
? actionFocused()
|
|
? theme.backgroundElement
|
|
: (option.bg ?? theme.backgroundElement)
|
|
: RGBA.fromInts(0, 0, 0, 0)
|
|
}
|
|
>
|
|
<Show when={!current() && option.margin}>
|
|
<box position="absolute" left={1} flexShrink={0}>
|
|
{option.margin}
|
|
</box>
|
|
</Show>
|
|
<Option
|
|
title={option.title}
|
|
titleView={option.titleView}
|
|
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
|
titleWidth={option.titleWidth}
|
|
truncateTitle={option.truncateTitle}
|
|
description={option.description !== category ? option.description : undefined}
|
|
active={active()}
|
|
current={current()}
|
|
muted={actionFocused()}
|
|
gutter={option.gutter}
|
|
/>
|
|
</box>
|
|
<For each={option.details}>
|
|
{(detail) => (
|
|
<box paddingLeft={3} paddingRight={3}>
|
|
<text
|
|
fg={option.detailsColor ?? theme.textMuted}
|
|
wrapMode={option.detailsWrap ? "word" : "none"}
|
|
>
|
|
{option.detailsWrap
|
|
? detail
|
|
: Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))}
|
|
</text>
|
|
</box>
|
|
)}
|
|
</For>
|
|
</box>
|
|
)
|
|
}}
|
|
</For>
|
|
</>
|
|
)}
|
|
</For>
|
|
</scrollbox>
|
|
</Show>
|
|
</box>
|
|
<Show when={props.footer || visibleActions().length} fallback={<box flexShrink={0} />}>
|
|
<box paddingRight={2} paddingLeft={4} flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
|
<box flexDirection="row" gap={2}>
|
|
{props.footer}
|
|
<For each={left()}>{(item) => <FooterAction item={item} />}</For>
|
|
</box>
|
|
<box flexDirection="row" gap={2}>
|
|
<For each={right()}>{(item) => <FooterAction item={item} />}</For>
|
|
</box>
|
|
</box>
|
|
</Show>
|
|
</box>
|
|
)
|
|
}
|
|
|
|
function Option(props: {
|
|
title: string
|
|
titleView?: JSX.Element
|
|
description?: string
|
|
active?: boolean
|
|
current?: boolean
|
|
muted?: boolean
|
|
footer?: JSX.Element | string
|
|
titleWidth?: number
|
|
truncateTitle?: boolean | "left"
|
|
gutter?: () => JSX.Element
|
|
onMouseOver?: () => void
|
|
}) {
|
|
const { theme } = useTheme()
|
|
const text = createMemo(() => {
|
|
if (props.active && !props.muted) return theme.text
|
|
if (props.muted && (props.active || props.current)) return theme.textMuted
|
|
if (props.current) return theme.primary
|
|
return theme.text
|
|
})
|
|
|
|
return (
|
|
<>
|
|
<Show when={props.current && !props.gutter}>
|
|
<text flexShrink={0} fg={text()} marginRight={0}>
|
|
●
|
|
</text>
|
|
</Show>
|
|
<Show when={props.gutter}>
|
|
<box flexShrink={0} marginRight={0}>
|
|
{props.gutter?.()}
|
|
</box>
|
|
</Show>
|
|
<text
|
|
flexGrow={1}
|
|
fg={text()}
|
|
attributes={props.active && !props.muted ? TextAttributes.BOLD : undefined}
|
|
overflow="hidden"
|
|
wrapMode="none"
|
|
paddingLeft={3}
|
|
>
|
|
{props.titleView ??
|
|
(props.truncateTitle === false
|
|
? props.title
|
|
: props.truncateTitle === "left"
|
|
? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
|
|
: Locale.truncate(props.title, props.titleWidth ?? 61))}
|
|
<Show when={props.description}>
|
|
<span style={{ fg: props.active && !props.muted ? theme.text : theme.textMuted }}> {props.description}</span>
|
|
</Show>
|
|
</text>
|
|
<Show when={props.footer}>
|
|
<box flexShrink={0}>
|
|
<text fg={props.active && !props.muted ? theme.text : theme.textMuted}>{props.footer}</text>
|
|
</box>
|
|
</Show>
|
|
</>
|
|
)
|
|
}
|