|
@@ -9,7 +9,7 @@ import {
|
|
|
import type { Binding } from "@opentui/keymap"
|
|
import type { Binding } from "@opentui/keymap"
|
|
|
import { useTheme, selectedForeground } from "@tui/context/theme"
|
|
import { useTheme, selectedForeground } from "@tui/context/theme"
|
|
|
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
|
|
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
|
|
|
-import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js"
|
|
|
|
|
|
|
+import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on } from "solid-js"
|
|
|
import { createStore } from "solid-js/store"
|
|
import { createStore } from "solid-js/store"
|
|
|
import { useTerminalDimensions } from "@opentui/solid"
|
|
import { useTerminalDimensions } from "@opentui/solid"
|
|
|
import * as fuzzysort from "fuzzysort"
|
|
import * as fuzzysort from "fuzzysort"
|
|
@@ -74,6 +74,10 @@ export type DialogSelectRef<T> = {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|
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 dialog = useDialog()
|
|
|
const { theme } = useTheme()
|
|
const { theme } = useTheme()
|
|
|
const tuiConfig = useTuiConfig()
|
|
const tuiConfig = useTuiConfig()
|
|
@@ -84,6 +88,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|
|
filter: "",
|
|
filter: "",
|
|
|
input: "keyboard" as "keyboard" | "mouse",
|
|
input: "keyboard" as "keyboard" | "mouse",
|
|
|
})
|
|
})
|
|
|
|
|
+ const [focusedAction, setFocusedAction] = createSignal<number>()
|
|
|
|
|
+ const actionFocused = createMemo(() => focusedAction() !== undefined)
|
|
|
|
|
|
|
|
createEffect(
|
|
createEffect(
|
|
|
on(
|
|
on(
|
|
@@ -119,6 +125,18 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|
|
|
|
|
|
|
return labels
|
|
return labels
|
|
|
})
|
|
})
|
|
|
|
|
+ const visibleActions = createMemo(() => [
|
|
|
|
|
+ ...actions()
|
|
|
|
|
+ .map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
|
|
|
|
|
+ .filter((item) => !item.disabled && item.label),
|
|
|
|
|
+ ...(props.footerHints ?? []),
|
|
|
|
|
+ ])
|
|
|
|
|
+ const actionItems = createMemo(() => visibleActions().filter(isActionItem))
|
|
|
|
|
+
|
|
|
|
|
+ createEffect(() => {
|
|
|
|
|
+ const index = focusedAction()
|
|
|
|
|
+ if (index !== undefined && index >= actionItems().length) setFocusedAction(undefined)
|
|
|
|
|
+ })
|
|
|
|
|
|
|
|
const filtered = createMemo(() => {
|
|
const filtered = createMemo(() => {
|
|
|
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
|
|
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
|
|
@@ -147,6 +165,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|
|
createEffect(() => {
|
|
createEffect(() => {
|
|
|
filtered()
|
|
filtered()
|
|
|
setStore("input", "keyboard")
|
|
setStore("input", "keyboard")
|
|
|
|
|
+ setFocusedAction(undefined)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
const flatten = createMemo(() => props.flat && store.filter.length > 0)
|
|
const flatten = createMemo(() => props.flat && store.filter.length > 0)
|
|
@@ -206,6 +225,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function moveTo(next: number, center = false) {
|
|
function moveTo(next: number, center = false) {
|
|
|
|
|
+ setFocusedAction(undefined)
|
|
|
setStore("selected", next)
|
|
setStore("selected", next)
|
|
|
const option = selected()
|
|
const option = selected()
|
|
|
if (option) props.onMove?.(option)
|
|
if (option) props.onMove?.(option)
|
|
@@ -233,12 +253,27 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|
|
|
|
|
|
|
function submit() {
|
|
function submit() {
|
|
|
setStore("input", "keyboard")
|
|
setStore("input", "keyboard")
|
|
|
|
|
+ const index = focusedAction()
|
|
|
|
|
+ if (index !== undefined) {
|
|
|
|
|
+ triggerAction(actionItems()[index])
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
const option = selected()
|
|
const option = selected()
|
|
|
if (!option) return
|
|
if (!option) return
|
|
|
option.onSelect?.(dialog)
|
|
option.onSelect?.(dialog)
|
|
|
props.onSelect?.(option)
|
|
props.onSelect?.(option)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ function moveAction(direction: 1 | -1) {
|
|
|
|
|
+ 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(() => {
|
|
useBindings(() => {
|
|
|
const enabledActions = actions().filter((item) => !item.disabled)
|
|
const enabledActions = actions().filter((item) => !item.disabled)
|
|
|
|
|
|
|
@@ -327,6 +362,22 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|
|
"dialog.select.submit",
|
|
"dialog.select.submit",
|
|
|
]),
|
|
]),
|
|
|
...enabledActions.flatMap((item) => tuiConfig.keybinds.get(item.command)),
|
|
...enabledActions.flatMap((item) => tuiConfig.keybinds.get(item.command)),
|
|
|
|
|
+ ...(enabledActions.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) => {
|
|
...(props.bindings ?? []).filter((binding) => {
|
|
|
if (typeof binding.cmd !== "string") return true
|
|
if (typeof binding.cmd !== "string") return true
|
|
|
return enabledActions.some((item) => item.command === binding.cmd)
|
|
return enabledActions.some((item) => item.command === binding.cmd)
|
|
@@ -353,15 +404,53 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|
|
}
|
|
}
|
|
|
props.ref?.(ref)
|
|
props.ref?.(ref)
|
|
|
|
|
|
|
|
- const visibleActions = createMemo(() => [
|
|
|
|
|
- ...actions()
|
|
|
|
|
- .map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
|
|
|
|
|
- .filter((item) => !item.disabled && item.label),
|
|
|
|
|
- ...(props.footerHints ?? []),
|
|
|
|
|
- ])
|
|
|
|
|
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
|
|
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
|
|
|
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
|
|
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
|
|
|
|
|
|
|
|
|
|
+ function triggerAction(item: VisibleAction | undefined) {
|
|
|
|
|
+ if (!item || !isActionItem(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 isActionFocused(item: VisibleAction) {
|
|
|
|
|
+ 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 active = createMemo(() => isActionFocused(action.item))
|
|
|
|
|
+ const fg = selectedForeground(theme)
|
|
|
|
|
+ return (
|
|
|
|
|
+ <box
|
|
|
|
|
+ flexDirection="row"
|
|
|
|
|
+ paddingRight={1}
|
|
|
|
|
+ backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
|
|
|
|
+ onMouseUp={() => triggerAction(action.item)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <text fg={active() ? fg : theme.text} attributes={active() ? TextAttributes.BOLD : undefined}>
|
|
|
|
|
+ {action.item.title}
|
|
|
|
|
+ </text>
|
|
|
|
|
+ <text fg={active() ? fg : theme.textMuted}> {action.item.label}</text>
|
|
|
|
|
+ </box>
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
<box gap={1} paddingBottom={1} flexGrow={1}>
|
|
<box gap={1} paddingBottom={1} flexGrow={1}>
|
|
|
<box paddingLeft={4} paddingRight={4}>
|
|
<box paddingLeft={4} paddingRight={4}>
|
|
@@ -445,6 +534,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|
|
position="relative"
|
|
position="relative"
|
|
|
onMouseMove={() => {
|
|
onMouseMove={() => {
|
|
|
setStore("input", "mouse")
|
|
setStore("input", "mouse")
|
|
|
|
|
+ setFocusedAction(undefined)
|
|
|
}}
|
|
}}
|
|
|
onMouseUp={() => {
|
|
onMouseUp={() => {
|
|
|
option.onSelect?.(dialog)
|
|
option.onSelect?.(dialog)
|
|
@@ -467,7 +557,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|
|
paddingLeft={current() || option.gutter ? 1 : 3}
|
|
paddingLeft={current() || option.gutter ? 1 : 3}
|
|
|
paddingRight={3}
|
|
paddingRight={3}
|
|
|
gap={1}
|
|
gap={1}
|
|
|
- backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
|
|
|
|
|
|
|
+ backgroundColor={
|
|
|
|
|
+ active()
|
|
|
|
|
+ ? actionFocused()
|
|
|
|
|
+ ? theme.backgroundElement
|
|
|
|
|
+ : (option.bg ?? theme.primary)
|
|
|
|
|
+ : RGBA.fromInts(0, 0, 0, 0)
|
|
|
|
|
+ }
|
|
|
>
|
|
>
|
|
|
<Show when={!current() && option.margin}>
|
|
<Show when={!current() && option.margin}>
|
|
|
<box position="absolute" left={1} flexShrink={0}>
|
|
<box position="absolute" left={1} flexShrink={0}>
|
|
@@ -483,6 +579,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|
|
description={option.description !== category ? option.description : undefined}
|
|
description={option.description !== category ? option.description : undefined}
|
|
|
active={active()}
|
|
active={active()}
|
|
|
current={current()}
|
|
current={current()}
|
|
|
|
|
+ muted={actionFocused()}
|
|
|
gutter={option.gutter}
|
|
gutter={option.gutter}
|
|
|
/>
|
|
/>
|
|
|
</box>
|
|
</box>
|
|
@@ -512,31 +609,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|
|
flexDirection="row"
|
|
flexDirection="row"
|
|
|
justifyContent="space-between"
|
|
justifyContent="space-between"
|
|
|
flexShrink={0}
|
|
flexShrink={0}
|
|
|
- paddingTop={1}
|
|
|
|
|
>
|
|
>
|
|
|
<box flexDirection="row" gap={2}>
|
|
<box flexDirection="row" gap={2}>
|
|
|
{props.footer}
|
|
{props.footer}
|
|
|
<For each={left()}>
|
|
<For each={left()}>
|
|
|
- {(item) => (
|
|
|
|
|
- <text>
|
|
|
|
|
- <span style={{ fg: theme.text }}>
|
|
|
|
|
- <b>{item.title}</b>{" "}
|
|
|
|
|
- </span>
|
|
|
|
|
- <span style={{ fg: theme.textMuted }}>{item.label}</span>
|
|
|
|
|
- </text>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ {(item) => <FooterAction item={item} />}
|
|
|
</For>
|
|
</For>
|
|
|
</box>
|
|
</box>
|
|
|
<box flexDirection="row" gap={2}>
|
|
<box flexDirection="row" gap={2}>
|
|
|
<For each={right()}>
|
|
<For each={right()}>
|
|
|
- {(item) => (
|
|
|
|
|
- <text>
|
|
|
|
|
- <span style={{ fg: theme.text }}>
|
|
|
|
|
- <b>{item.title}</b>{" "}
|
|
|
|
|
- </span>
|
|
|
|
|
- <span style={{ fg: theme.textMuted }}>{item.label}</span>
|
|
|
|
|
- </text>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ {(item) => <FooterAction item={item} />}
|
|
|
</For>
|
|
</For>
|
|
|
</box>
|
|
</box>
|
|
|
</box>
|
|
</box>
|
|
@@ -551,6 +633,7 @@ function Option(props: {
|
|
|
description?: string
|
|
description?: string
|
|
|
active?: boolean
|
|
active?: boolean
|
|
|
current?: boolean
|
|
current?: boolean
|
|
|
|
|
+ muted?: boolean
|
|
|
footer?: JSX.Element | string
|
|
footer?: JSX.Element | string
|
|
|
titleWidth?: number
|
|
titleWidth?: number
|
|
|
truncateTitle?: boolean | "left"
|
|
truncateTitle?: boolean | "left"
|
|
@@ -559,11 +642,17 @@ function Option(props: {
|
|
|
}) {
|
|
}) {
|
|
|
const { theme } = useTheme()
|
|
const { theme } = useTheme()
|
|
|
const fg = selectedForeground(theme)
|
|
const fg = selectedForeground(theme)
|
|
|
|
|
+ const text = createMemo(() => {
|
|
|
|
|
+ if (props.active && !props.muted) return fg
|
|
|
|
|
+ if (props.muted && (props.active || props.current)) return theme.textMuted
|
|
|
|
|
+ if (props.current) return theme.primary
|
|
|
|
|
+ return theme.text
|
|
|
|
|
+ })
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<>
|
|
<>
|
|
|
<Show when={props.current}>
|
|
<Show when={props.current}>
|
|
|
- <text flexShrink={0} fg={props.active ? fg : props.current ? theme.primary : theme.text} marginRight={0}>
|
|
|
|
|
|
|
+ <text flexShrink={0} fg={text()} marginRight={0}>
|
|
|
●
|
|
●
|
|
|
</text>
|
|
</text>
|
|
|
</Show>
|
|
</Show>
|
|
@@ -574,8 +663,8 @@ function Option(props: {
|
|
|
</Show>
|
|
</Show>
|
|
|
<text
|
|
<text
|
|
|
flexGrow={1}
|
|
flexGrow={1}
|
|
|
- fg={props.active ? fg : props.current ? theme.primary : theme.text}
|
|
|
|
|
- attributes={props.active ? TextAttributes.BOLD : undefined}
|
|
|
|
|
|
|
+ fg={text()}
|
|
|
|
|
+ attributes={props.active && !props.muted ? TextAttributes.BOLD : undefined}
|
|
|
overflow="hidden"
|
|
overflow="hidden"
|
|
|
wrapMode="none"
|
|
wrapMode="none"
|
|
|
paddingLeft={3}
|
|
paddingLeft={3}
|
|
@@ -587,12 +676,12 @@ function Option(props: {
|
|
|
? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
|
|
? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
|
|
|
: Locale.truncate(props.title, props.titleWidth ?? 61))}
|
|
: Locale.truncate(props.title, props.titleWidth ?? 61))}
|
|
|
<Show when={props.description}>
|
|
<Show when={props.description}>
|
|
|
- <span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
|
|
|
|
|
|
|
+ <span style={{ fg: props.active && !props.muted ? fg : theme.textMuted }}> {props.description}</span>
|
|
|
</Show>
|
|
</Show>
|
|
|
</text>
|
|
</text>
|
|
|
<Show when={props.footer}>
|
|
<Show when={props.footer}>
|
|
|
<box flexShrink={0}>
|
|
<box flexShrink={0}>
|
|
|
- <text fg={props.active ? fg : theme.textMuted}>{props.footer}</text>
|
|
|
|
|
|
|
+ <text fg={props.active && !props.muted ? fg : theme.textMuted}>{props.footer}</text>
|
|
|
</box>
|
|
</box>
|
|
|
</Show>
|
|
</Show>
|
|
|
</>
|
|
</>
|