refactor(server): canonicalize service API (#31049)
This commit is contained in:
parent
53ff1b57c9
commit
fe0c4f8c74
388 changed files with 7075 additions and 4064 deletions
21
packages/tui/src/ui/border.ts
Normal file
21
packages/tui/src/ui/border.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export const EmptyBorder = {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
vertical: "",
|
||||
topRight: "",
|
||||
bottomRight: "",
|
||||
horizontal: " ",
|
||||
bottomT: "",
|
||||
topT: "",
|
||||
cross: "",
|
||||
leftT: "",
|
||||
rightT: "",
|
||||
}
|
||||
|
||||
export const SplitBorder = {
|
||||
border: ["left" as const, "right" as const],
|
||||
customBorderChars: {
|
||||
...EmptyBorder,
|
||||
vertical: "┃",
|
||||
},
|
||||
}
|
||||
66
packages/tui/src/ui/dialog-alert.tsx
Normal file
66
packages/tui/src/ui/dialog-alert.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
export type DialogAlertProps = {
|
||||
title: string
|
||||
message: string
|
||||
onConfirm?: () => void
|
||||
}
|
||||
|
||||
export function DialogAlert(props: DialogAlertProps) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{
|
||||
key: "return",
|
||||
desc: "Confirm alert",
|
||||
group: "Dialog",
|
||||
cmd: () => {
|
||||
props.onConfirm?.()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={theme.textMuted}>{props.message}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={theme.primary}
|
||||
onMouseUp={() => {
|
||||
props.onConfirm?.()
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={theme.selectedListItemText}>ok</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogAlert.show = (dialog: DialogContext, title: string, message: string) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
dialog.replace(
|
||||
() => <DialogAlert title={title} message={message} onConfirm={() => resolve()} />,
|
||||
() => resolve(),
|
||||
)
|
||||
})
|
||||
}
|
||||
108
packages/tui/src/ui/dialog-confirm.tsx
Normal file
108
packages/tui/src/ui/dialog-confirm.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
import { Locale } from "../util/locale"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
export type DialogConfirmProps = {
|
||||
title: string
|
||||
message: string
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
label?: string
|
||||
}
|
||||
|
||||
export type DialogConfirmResult = boolean | undefined
|
||||
|
||||
export function DialogConfirm(props: DialogConfirmProps) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const [store, setStore] = createStore({
|
||||
active: "confirm" as "confirm" | "cancel",
|
||||
})
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{
|
||||
key: "return",
|
||||
desc: "Confirm dialog selection",
|
||||
group: "Dialog",
|
||||
cmd: () => {
|
||||
if (store.active === "confirm") props.onConfirm?.()
|
||||
if (store.active === "cancel") props.onCancel?.()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "left",
|
||||
desc: "Previous dialog option",
|
||||
group: "Dialog",
|
||||
cmd: () => {
|
||||
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "right",
|
||||
desc: "Next dialog option",
|
||||
group: "Dialog",
|
||||
cmd: () => {
|
||||
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={theme.textMuted}>{props.message}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<For each={["cancel", "confirm"] as const}>
|
||||
{(key) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={key === store.active ? theme.primary : undefined}
|
||||
onMouseUp={() => {
|
||||
if (key === "confirm") props.onConfirm?.()
|
||||
if (key === "cancel") props.onCancel?.()
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}>
|
||||
{Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: string) => {
|
||||
return new Promise<DialogConfirmResult>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogConfirm
|
||||
title={title}
|
||||
message={message}
|
||||
onConfirm={() => resolve(true)}
|
||||
onCancel={() => resolve(false)}
|
||||
label={label}
|
||||
/>
|
||||
),
|
||||
() => resolve(undefined),
|
||||
)
|
||||
})
|
||||
}
|
||||
217
packages/tui/src/ui/dialog-export-options.tsx
Normal file
217
packages/tui/src/ui/dialog-export-options.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { TextareaRenderable, TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { onMount, Show } from "solid-js"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
export type DialogExportOptionsProps = {
|
||||
defaultFilename: string
|
||||
defaultThinking: boolean
|
||||
defaultToolDetails: boolean
|
||||
defaultAssistantMetadata: boolean
|
||||
defaultOpenWithoutSaving: boolean
|
||||
onConfirm?: (options: {
|
||||
filename: string
|
||||
thinking: boolean
|
||||
toolDetails: boolean
|
||||
assistantMetadata: boolean
|
||||
openWithoutSaving: boolean
|
||||
}) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
let textarea: TextareaRenderable
|
||||
const [store, setStore] = createStore({
|
||||
thinking: props.defaultThinking,
|
||||
toolDetails: props.defaultToolDetails,
|
||||
assistantMetadata: props.defaultAssistantMetadata,
|
||||
openWithoutSaving: props.defaultOpenWithoutSaving,
|
||||
active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving",
|
||||
})
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{
|
||||
key: "tab",
|
||||
desc: "Next export option",
|
||||
group: "Dialog",
|
||||
cmd: () => {
|
||||
const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [
|
||||
"filename",
|
||||
"thinking",
|
||||
"toolDetails",
|
||||
"assistantMetadata",
|
||||
"openWithoutSaving",
|
||||
]
|
||||
const currentIndex = order.indexOf(store.active)
|
||||
const nextIndex = (currentIndex + 1) % order.length
|
||||
setStore("active", order[nextIndex])
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: store.active !== "filename",
|
||||
bindings: [
|
||||
{
|
||||
key: "space",
|
||||
desc: "Toggle export option",
|
||||
group: "Dialog",
|
||||
cmd: () => {
|
||||
if (store.active === "thinking") setStore("thinking", !store.thinking)
|
||||
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
|
||||
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
|
||||
if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
setTimeout(() => {
|
||||
if (!textarea || textarea.isDestroyed) return
|
||||
textarea.focus()
|
||||
}, 1)
|
||||
textarea.gotoLineEnd()
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
Export Options
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box gap={1}>
|
||||
<box>
|
||||
<text fg={theme.text}>Filename:</text>
|
||||
</box>
|
||||
<textarea
|
||||
onSubmit={() => {
|
||||
props.onConfirm?.({
|
||||
filename: textarea.plainText,
|
||||
thinking: store.thinking,
|
||||
toolDetails: store.toolDetails,
|
||||
assistantMetadata: store.assistantMetadata,
|
||||
openWithoutSaving: store.openWithoutSaving,
|
||||
})
|
||||
}}
|
||||
height={3}
|
||||
ref={(val: TextareaRenderable) => {
|
||||
textarea = val
|
||||
val.traits = { status: "FILENAME" }
|
||||
}}
|
||||
initialValue={props.defaultFilename}
|
||||
placeholder="Enter filename"
|
||||
placeholderColor={theme.textMuted}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
cursorColor={theme.text}
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="column">
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={2}
|
||||
paddingLeft={1}
|
||||
backgroundColor={store.active === "thinking" ? theme.backgroundElement : undefined}
|
||||
onMouseUp={() => setStore("active", "thinking")}
|
||||
>
|
||||
<text fg={store.active === "thinking" ? theme.primary : theme.textMuted}>
|
||||
{store.thinking ? "[x]" : "[ ]"}
|
||||
</text>
|
||||
<text fg={store.active === "thinking" ? theme.primary : theme.text}>Include thinking</text>
|
||||
</box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={2}
|
||||
paddingLeft={1}
|
||||
backgroundColor={store.active === "toolDetails" ? theme.backgroundElement : undefined}
|
||||
onMouseUp={() => setStore("active", "toolDetails")}
|
||||
>
|
||||
<text fg={store.active === "toolDetails" ? theme.primary : theme.textMuted}>
|
||||
{store.toolDetails ? "[x]" : "[ ]"}
|
||||
</text>
|
||||
<text fg={store.active === "toolDetails" ? theme.primary : theme.text}>Include tool details</text>
|
||||
</box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={2}
|
||||
paddingLeft={1}
|
||||
backgroundColor={store.active === "assistantMetadata" ? theme.backgroundElement : undefined}
|
||||
onMouseUp={() => setStore("active", "assistantMetadata")}
|
||||
>
|
||||
<text fg={store.active === "assistantMetadata" ? theme.primary : theme.textMuted}>
|
||||
{store.assistantMetadata ? "[x]" : "[ ]"}
|
||||
</text>
|
||||
<text fg={store.active === "assistantMetadata" ? theme.primary : theme.text}>Include assistant metadata</text>
|
||||
</box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={2}
|
||||
paddingLeft={1}
|
||||
backgroundColor={store.active === "openWithoutSaving" ? theme.backgroundElement : undefined}
|
||||
onMouseUp={() => setStore("active", "openWithoutSaving")}
|
||||
>
|
||||
<text fg={store.active === "openWithoutSaving" ? theme.primary : theme.textMuted}>
|
||||
{store.openWithoutSaving ? "[x]" : "[ ]"}
|
||||
</text>
|
||||
<text fg={store.active === "openWithoutSaving" ? theme.primary : theme.text}>Open without saving</text>
|
||||
</box>
|
||||
</box>
|
||||
<Show when={store.active !== "filename"}>
|
||||
<text fg={theme.textMuted} paddingBottom={1}>
|
||||
Press <span style={{ fg: theme.text }}>space</span> to toggle, <span style={{ fg: theme.text }}>return</span>{" "}
|
||||
to confirm
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={store.active === "filename"}>
|
||||
<text fg={theme.textMuted} paddingBottom={1}>
|
||||
Press <span style={{ fg: theme.text }}>return</span> to confirm, <span style={{ fg: theme.text }}>tab</span>{" "}
|
||||
for options
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogExportOptions.show = (
|
||||
dialog: DialogContext,
|
||||
defaultFilename: string,
|
||||
defaultThinking: boolean,
|
||||
defaultToolDetails: boolean,
|
||||
defaultAssistantMetadata: boolean,
|
||||
defaultOpenWithoutSaving: boolean,
|
||||
) => {
|
||||
return new Promise<{
|
||||
filename: string
|
||||
thinking: boolean
|
||||
toolDetails: boolean
|
||||
assistantMetadata: boolean
|
||||
openWithoutSaving: boolean
|
||||
} | null>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogExportOptions
|
||||
defaultFilename={defaultFilename}
|
||||
defaultThinking={defaultThinking}
|
||||
defaultToolDetails={defaultToolDetails}
|
||||
defaultAssistantMetadata={defaultAssistantMetadata}
|
||||
defaultOpenWithoutSaving={defaultOpenWithoutSaving}
|
||||
onConfirm={(options) => resolve(options)}
|
||||
onCancel={() => resolve(null)}
|
||||
/>
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
}
|
||||
40
packages/tui/src/ui/dialog-help.tsx
Normal file
40
packages/tui/src/ui/dialog-help.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "./dialog"
|
||||
import { useBindings, useCommandShortcut } from "../keymap"
|
||||
|
||||
export function DialogHelp() {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const commandShortcut = useCommandShortcut("command.palette.show")
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{ key: "return", desc: "Close help", group: "Dialog", cmd: () => dialog.clear() },
|
||||
{ key: "escape", desc: "Close help", group: "Dialog", cmd: () => dialog.clear() },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
Help
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc/enter
|
||||
</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
Press {commandShortcut()} to see all available actions and commands in any context.
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.selectedListItemText}>ok</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
126
packages/tui/src/ui/dialog-prompt.tsx
Normal file
126
packages/tui/src/ui/dialog-prompt.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { TextareaRenderable, TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
|
||||
import { Spinner } from "../component/spinner"
|
||||
import { useTuiConfig } from "../config"
|
||||
import { useBindings, useCommandShortcut } from "../keymap"
|
||||
|
||||
export type DialogPromptProps = {
|
||||
title: string
|
||||
description?: () => JSX.Element
|
||||
placeholder?: string
|
||||
value?: string
|
||||
busy?: boolean
|
||||
busyText?: string
|
||||
onConfirm?: (value: string) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function DialogPrompt(props: DialogPromptProps) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const submitShortcut = useCommandShortcut("dialog.prompt.submit")
|
||||
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
|
||||
let textarea: TextareaRenderable
|
||||
|
||||
function confirm() {
|
||||
if (props.busy) return
|
||||
props.onConfirm?.(textarea.plainText)
|
||||
}
|
||||
|
||||
useBindings(() => ({
|
||||
target: textareaTarget,
|
||||
enabled: textareaTarget() !== undefined && !props.busy,
|
||||
// Dialog form semantics must win over the global managed textarea input layer.
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
name: "dialog.prompt.submit",
|
||||
title: "Submit dialog prompt",
|
||||
category: "Dialog",
|
||||
run: confirm,
|
||||
},
|
||||
],
|
||||
bindings: tuiConfig.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]),
|
||||
}))
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
setTimeout(() => {
|
||||
if (!textarea || textarea.isDestroyed) return
|
||||
if (props.busy) return
|
||||
textarea.focus()
|
||||
}, 1)
|
||||
textarea.gotoLineEnd()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!textarea || textarea.isDestroyed) return
|
||||
const traits = props.busy
|
||||
? {
|
||||
suspend: true,
|
||||
status: "BUSY",
|
||||
}
|
||||
: {}
|
||||
textarea.traits = traits
|
||||
if (props.busy) {
|
||||
textarea.blur()
|
||||
return
|
||||
}
|
||||
textarea.focus()
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box gap={1}>
|
||||
{props.description}
|
||||
<textarea
|
||||
height={3}
|
||||
ref={(val: TextareaRenderable) => {
|
||||
textarea = val
|
||||
setTextareaTarget(val)
|
||||
}}
|
||||
initialValue={props.value}
|
||||
placeholder={props.placeholder ?? "Enter text"}
|
||||
placeholderColor={theme.textMuted}
|
||||
textColor={props.busy ? theme.textMuted : theme.text}
|
||||
focusedTextColor={props.busy ? theme.textMuted : theme.text}
|
||||
cursorColor={props.busy ? theme.backgroundElement : theme.text}
|
||||
/>
|
||||
<Show when={props.busy}>
|
||||
<Spinner color={theme.textMuted}>{props.busyText ?? "Working..."}</Spinner>
|
||||
</Show>
|
||||
</box>
|
||||
<box paddingBottom={1} gap={1} flexDirection="row">
|
||||
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
|
||||
<Show when={submitShortcut()}>
|
||||
<text fg={theme.text}>
|
||||
{submitShortcut()} <span style={{ fg: theme.textMuted }}>submit</span>
|
||||
</text>
|
||||
</Show>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogPrompt.show = (dialog: DialogContext, title: string, options?: Omit<DialogPromptProps, "title">) => {
|
||||
return new Promise<string | null>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogPrompt title={title} {...options} onConfirm={(value) => resolve(value)} onCancel={() => resolve(null)} />
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
}
|
||||
712
packages/tui/src/ui/dialog-select.tsx
Normal file
712
packages/tui/src/ui/dialog-select.tsx
Normal file
|
|
@ -0,0 +1,712 @@
|
|||
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 } 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 { useTuiConfig } from "../config"
|
||||
import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
|
||||
|
||||
export interface DialogSelectProps<T> {
|
||||
title: string
|
||||
titleView?: JSX.Element
|
||||
placeholder?: string
|
||||
footer?: 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
|
||||
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[]
|
||||
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>[]
|
||||
selected: DialogSelectOption<T> | undefined
|
||||
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 tuiConfig = useTuiConfig()
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
selected: 0,
|
||||
filter: "",
|
||||
input: "keyboard" as "keyboard" | "mouse",
|
||||
})
|
||||
const [focusedAction, setFocusedAction] = createSignal<number>()
|
||||
const actionFocused = createMemo(() => focusedAction() !== undefined)
|
||||
|
||||
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 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), tuiConfig)
|
||||
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([() => store.filter, () => props.current], ([filter, current]) => {
|
||||
setTimeout(() => {
|
||||
if (filter.length > 0) {
|
||||
moveTo(0, true)
|
||||
} 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) {
|
||||
setFocusedAction(undefined)
|
||||
setStore("selected", next)
|
||||
const option = selected()
|
||||
if (option) props.onMove?.(option)
|
||||
if (!scroll) return
|
||||
const target = scroll.getChildren().find((child: { id?: string }) => {
|
||||
return child.id === JSON.stringify(selected()?.value)
|
||||
})
|
||||
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: [
|
||||
...tuiConfig.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) => tuiConfig.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()
|
||||
},
|
||||
get selected() {
|
||||
return selected()
|
||||
},
|
||||
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"
|
||||
paddingRight={1}
|
||||
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={
|
||||
<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
|
||||
id={JSON.stringify(option.value)}
|
||||
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.primary)
|
||||
: 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={theme.textMuted} wrapMode="none">
|
||||
{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 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 (
|
||||
<>
|
||||
<Show when={props.current}>
|
||||
<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 ? fg : theme.textMuted }}> {props.description}</span>
|
||||
</Show>
|
||||
</text>
|
||||
<Show when={props.footer}>
|
||||
<box flexShrink={0}>
|
||||
<text fg={props.active && !props.muted ? fg : theme.textMuted}>{props.footer}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
229
packages/tui/src/ui/dialog.tsx
Normal file
229
packages/tui/src/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { MouseButton, Renderable, RGBA } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useToast } from "./toast"
|
||||
import { useTuiEnvironment } from "../runtime"
|
||||
import { useBindings, useOpencodeModeStack } from "../keymap"
|
||||
import { useOptionalTuiPlatform } from "../platform"
|
||||
|
||||
export function Dialog(
|
||||
props: ParentProps<{
|
||||
size?: "medium" | "large" | "xlarge"
|
||||
onClose: () => void
|
||||
}>,
|
||||
) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
|
||||
let dismiss = false
|
||||
const width = () => {
|
||||
if (props.size === "xlarge") return 116
|
||||
if (props.size === "large") return 88
|
||||
return 60
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
onMouseDown={() => {
|
||||
dismiss = !!renderer.getSelection()
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (dismiss) {
|
||||
dismiss = false
|
||||
return
|
||||
}
|
||||
props.onClose?.()
|
||||
}}
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
alignItems="center"
|
||||
position="absolute"
|
||||
zIndex={3000}
|
||||
paddingTop={dimensions().height / 4}
|
||||
left={0}
|
||||
top={0}
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
|
||||
>
|
||||
<box
|
||||
onMouseUp={(e: { stopPropagation(): void }) => {
|
||||
dismiss = false
|
||||
e.stopPropagation()
|
||||
}}
|
||||
width={width()}
|
||||
maxWidth={dimensions().width - 2}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
paddingTop={1}
|
||||
>
|
||||
{props.children}
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function init() {
|
||||
const [store, setStore] = createStore({
|
||||
stack: [] as {
|
||||
element: JSX.Element
|
||||
onClose?: () => void
|
||||
}[],
|
||||
size: "medium" as "medium" | "large" | "xlarge",
|
||||
})
|
||||
|
||||
const renderer = useRenderer()
|
||||
const modeStack = useOpencodeModeStack()
|
||||
|
||||
createEffect(() => {
|
||||
if (store.stack.length === 0) return
|
||||
const popMode = modeStack.push("modal")
|
||||
onCleanup(popMode)
|
||||
})
|
||||
|
||||
let focus: Renderable | null
|
||||
function refocus() {
|
||||
setTimeout(() => {
|
||||
if (!focus) return
|
||||
if (focus.isDestroyed) return
|
||||
function find(item: Renderable) {
|
||||
for (const child of item.getChildren()) {
|
||||
if (child === focus) return true
|
||||
if (find(child)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const found = find(renderer.root)
|
||||
if (!found) return
|
||||
focus.focus()
|
||||
}, 1)
|
||||
}
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(),
|
||||
bindings: [
|
||||
{
|
||||
key: "escape",
|
||||
desc: "Close dialog",
|
||||
group: "Dialog",
|
||||
cmd: () => {
|
||||
if (renderer.getSelection()) {
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const current = store.stack.at(-1)
|
||||
current?.onClose?.()
|
||||
setStore("stack", store.stack.slice(0, -1))
|
||||
refocus()
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ctrl+c",
|
||||
desc: "Close dialog",
|
||||
group: "Dialog",
|
||||
cmd: () => {
|
||||
if (renderer.getSelection()) {
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const current = store.stack.at(-1)
|
||||
current?.onClose?.()
|
||||
setStore("stack", store.stack.slice(0, -1))
|
||||
refocus()
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return {
|
||||
clear() {
|
||||
for (const item of store.stack) {
|
||||
if (item.onClose) item.onClose()
|
||||
}
|
||||
batch(() => {
|
||||
setStore("size", "medium")
|
||||
setStore("stack", [])
|
||||
})
|
||||
refocus()
|
||||
},
|
||||
replace(input: any, onClose?: () => void) {
|
||||
if (store.stack.length === 0) {
|
||||
focus = renderer.currentFocusedRenderable
|
||||
focus?.blur()
|
||||
}
|
||||
for (const item of store.stack) {
|
||||
if (item.onClose) item.onClose()
|
||||
}
|
||||
setStore("size", "medium")
|
||||
setStore("stack", [
|
||||
{
|
||||
element: input,
|
||||
onClose,
|
||||
},
|
||||
])
|
||||
},
|
||||
get stack() {
|
||||
return store.stack
|
||||
},
|
||||
get size() {
|
||||
return store.size
|
||||
},
|
||||
setSize(size: "medium" | "large" | "xlarge") {
|
||||
setStore("size", size)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type DialogContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<DialogContext>()
|
||||
|
||||
export function DialogProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
const renderer = useRenderer()
|
||||
const toast = useToast()
|
||||
const environment = useTuiEnvironment()
|
||||
const platform = useOptionalTuiPlatform()
|
||||
|
||||
function copySelection() {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (!text || !platform?.clipboard?.write) return false
|
||||
void platform.clipboard.write(text).then(
|
||||
() => toast.show({ message: "Copied to clipboard", variant: "info" }),
|
||||
(error) => toast.error(error),
|
||||
)
|
||||
renderer.clearSelection()
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
<ctx.Provider value={value}>
|
||||
{props.children}
|
||||
<box
|
||||
position="absolute"
|
||||
zIndex={3000}
|
||||
onMouseDown={(evt: { button: number; preventDefault(): void; stopPropagation(): void }) => {
|
||||
if (environment.capabilities.copyOnSelect) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
|
||||
if (!copySelection()) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={environment.capabilities.copyOnSelect ? copySelection : undefined}
|
||||
>
|
||||
<Show when={value.stack.length}>
|
||||
<Dialog onClose={() => value.clear()} size={value.size}>
|
||||
{value.stack.at(-1)!.element}
|
||||
</Dialog>
|
||||
</Show>
|
||||
</box>
|
||||
</ctx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDialog() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useDialog must be used within a DialogProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
34
packages/tui/src/ui/link.tsx
Normal file
34
packages/tui/src/ui/link.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { JSX } from "solid-js"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import open from "open"
|
||||
|
||||
export interface LinkProps {
|
||||
href: string
|
||||
children?: JSX.Element | string
|
||||
fg?: RGBA
|
||||
bg?: RGBA
|
||||
width?: number | "auto" | `${number}%`
|
||||
wrapMode?: "word" | "none"
|
||||
}
|
||||
|
||||
/**
|
||||
* Link component that renders clickable hyperlinks.
|
||||
* Clicking anywhere on the link text opens the URL in the default browser.
|
||||
*/
|
||||
export function Link(props: LinkProps) {
|
||||
const displayText = props.children ?? props.href
|
||||
|
||||
return (
|
||||
<text
|
||||
fg={props.fg}
|
||||
bg={props.bg}
|
||||
width={props.width}
|
||||
wrapMode={props.wrapMode}
|
||||
onMouseUp={() => {
|
||||
open(props.href).catch(() => {})
|
||||
}}
|
||||
>
|
||||
{displayText}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
368
packages/tui/src/ui/spinner.ts
Normal file
368
packages/tui/src/ui/spinner.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
import type { ColorInput } from "@opentui/core"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import type { ColorGenerator } from "opentui-spinner"
|
||||
|
||||
interface AdvancedGradientOptions {
|
||||
colors: ColorInput[]
|
||||
trailLength: number
|
||||
defaultColor?: ColorInput
|
||||
direction?: "forward" | "backward" | "bidirectional"
|
||||
holdFrames?: { start?: number; end?: number }
|
||||
enableFading?: boolean
|
||||
minAlpha?: number
|
||||
}
|
||||
|
||||
interface ScannerState {
|
||||
activePosition: number
|
||||
isHolding: boolean
|
||||
holdProgress: number
|
||||
holdTotal: number
|
||||
movementProgress: number
|
||||
movementTotal: number
|
||||
isMovingForward: boolean
|
||||
}
|
||||
|
||||
function getScannerState(
|
||||
frameIndex: number,
|
||||
totalChars: number,
|
||||
options: Pick<AdvancedGradientOptions, "direction" | "holdFrames">,
|
||||
): ScannerState {
|
||||
const { direction = "forward", holdFrames = {} } = options
|
||||
|
||||
if (direction === "bidirectional") {
|
||||
const forwardFrames = totalChars
|
||||
const holdEndFrames = holdFrames.end ?? 0
|
||||
const backwardFrames = totalChars - 1
|
||||
|
||||
if (frameIndex < forwardFrames) {
|
||||
// Moving forward
|
||||
return {
|
||||
activePosition: frameIndex,
|
||||
isHolding: false,
|
||||
holdProgress: 0,
|
||||
holdTotal: 0,
|
||||
movementProgress: frameIndex,
|
||||
movementTotal: forwardFrames,
|
||||
isMovingForward: true,
|
||||
}
|
||||
} else if (frameIndex < forwardFrames + holdEndFrames) {
|
||||
// Holding at end
|
||||
return {
|
||||
activePosition: totalChars - 1,
|
||||
isHolding: true,
|
||||
holdProgress: frameIndex - forwardFrames,
|
||||
holdTotal: holdEndFrames,
|
||||
movementProgress: 0,
|
||||
movementTotal: 0,
|
||||
isMovingForward: true,
|
||||
}
|
||||
} else if (frameIndex < forwardFrames + holdEndFrames + backwardFrames) {
|
||||
// Moving backward
|
||||
const backwardIndex = frameIndex - forwardFrames - holdEndFrames
|
||||
return {
|
||||
activePosition: totalChars - 2 - backwardIndex,
|
||||
isHolding: false,
|
||||
holdProgress: 0,
|
||||
holdTotal: 0,
|
||||
movementProgress: backwardIndex,
|
||||
movementTotal: backwardFrames,
|
||||
isMovingForward: false,
|
||||
}
|
||||
} else {
|
||||
// Holding at start
|
||||
return {
|
||||
activePosition: 0,
|
||||
isHolding: true,
|
||||
holdProgress: frameIndex - forwardFrames - holdEndFrames - backwardFrames,
|
||||
holdTotal: holdFrames.start ?? 0,
|
||||
movementProgress: 0,
|
||||
movementTotal: 0,
|
||||
isMovingForward: false,
|
||||
}
|
||||
}
|
||||
} else if (direction === "backward") {
|
||||
return {
|
||||
activePosition: totalChars - 1 - (frameIndex % totalChars),
|
||||
isHolding: false,
|
||||
holdProgress: 0,
|
||||
holdTotal: 0,
|
||||
movementProgress: frameIndex % totalChars,
|
||||
movementTotal: totalChars,
|
||||
isMovingForward: false,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
activePosition: frameIndex % totalChars,
|
||||
isHolding: false,
|
||||
holdProgress: 0,
|
||||
holdTotal: 0,
|
||||
movementProgress: frameIndex % totalChars,
|
||||
movementTotal: totalChars,
|
||||
isMovingForward: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function calculateColorIndex(
|
||||
frameIndex: number,
|
||||
charIndex: number,
|
||||
totalChars: number,
|
||||
options: Pick<AdvancedGradientOptions, "direction" | "holdFrames" | "trailLength">,
|
||||
state?: ScannerState,
|
||||
): number {
|
||||
const { trailLength } = options
|
||||
const { activePosition, isHolding, holdProgress, isMovingForward } =
|
||||
state ?? getScannerState(frameIndex, totalChars, options)
|
||||
|
||||
// Calculate directional distance (positive means trailing behind)
|
||||
const directionalDistance = isMovingForward
|
||||
? activePosition - charIndex // For forward: trail is to the left (lower indices)
|
||||
: charIndex - activePosition // For backward: trail is to the right (higher indices)
|
||||
|
||||
// Handle hold frame fading: keep the lead bright, fade the trail
|
||||
if (isHolding) {
|
||||
// Shift the color index by how long we've been holding
|
||||
return directionalDistance + holdProgress
|
||||
}
|
||||
|
||||
// Normal movement - show gradient trail only behind the movement direction
|
||||
if (directionalDistance > 0 && directionalDistance < trailLength) {
|
||||
return directionalDistance
|
||||
}
|
||||
|
||||
// At the active position, show the brightest color
|
||||
if (directionalDistance === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function createKnightRiderTrail(options: AdvancedGradientOptions): ColorGenerator {
|
||||
const { colors, defaultColor, enableFading = true, minAlpha = 0 } = options
|
||||
|
||||
// Use the provided defaultColor if it's an RGBA instance, otherwise convert/default
|
||||
// We use RGBA.fromHex for the fallback to ensure we have an RGBA object.
|
||||
// Note: If defaultColor is a string, we convert it once here.
|
||||
const defaultRgba = defaultColor instanceof RGBA ? defaultColor : RGBA.fromHex((defaultColor as string) || "#000000")
|
||||
|
||||
// Store the base alpha from the inactive factor
|
||||
const baseInactiveAlpha = defaultRgba.a
|
||||
|
||||
let cachedFrameIndex = -1
|
||||
let cachedState: ScannerState | null = null
|
||||
|
||||
return (frameIndex: number, charIndex: number, _totalFrames: number, totalChars: number) => {
|
||||
if (frameIndex !== cachedFrameIndex) {
|
||||
cachedFrameIndex = frameIndex
|
||||
cachedState = getScannerState(frameIndex, totalChars, options)
|
||||
}
|
||||
|
||||
const state = cachedState!
|
||||
|
||||
const index = calculateColorIndex(frameIndex, charIndex, totalChars, options, state)
|
||||
|
||||
// Calculate global fade for inactive dots during hold or movement
|
||||
const { isHolding, holdProgress, holdTotal, movementProgress, movementTotal } = state
|
||||
|
||||
let fadeFactor = 1.0
|
||||
if (enableFading) {
|
||||
if (isHolding && holdTotal > 0) {
|
||||
// Fade out linearly to minAlpha
|
||||
const progress = Math.min(holdProgress / holdTotal, 1)
|
||||
fadeFactor = Math.max(minAlpha, 1 - progress * (1 - minAlpha))
|
||||
} else if (!isHolding && movementTotal > 0) {
|
||||
// Fade in linearly from minAlpha during movement
|
||||
const progress = Math.min(movementProgress / Math.max(1, movementTotal - 1), 1)
|
||||
fadeFactor = minAlpha + progress * (1 - minAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
// Combine base inactive alpha with the fade factor
|
||||
// This ensures inactiveFactor is respected while still allowing fading animation
|
||||
defaultRgba.a = baseInactiveAlpha * fadeFactor
|
||||
|
||||
if (index === -1) {
|
||||
return defaultRgba
|
||||
}
|
||||
|
||||
return colors[index] ?? defaultRgba
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a gradient of tail colors from a single bright color using alpha falloff
|
||||
* @param brightColor The brightest color (center/head of the scanner)
|
||||
* @param steps Number of gradient steps (default: 6)
|
||||
* @returns Array of RGBA colors with alpha-based trail fade (background-independent)
|
||||
*/
|
||||
export function deriveTrailColors(brightColor: ColorInput, steps: number = 6): RGBA[] {
|
||||
const baseRgba = brightColor instanceof RGBA ? brightColor : RGBA.fromHex(brightColor as string)
|
||||
|
||||
const colors: RGBA[] = []
|
||||
|
||||
for (let i = 0; i < steps; i++) {
|
||||
// Alpha-based falloff with optional bloom effect
|
||||
let alpha: number
|
||||
let brightnessFactor: number
|
||||
|
||||
if (i === 0) {
|
||||
// Lead position: full brightness and opacity
|
||||
alpha = 1.0
|
||||
brightnessFactor = 1.0
|
||||
} else if (i === 1) {
|
||||
// Slight bloom/glare effect: brighten color but reduce opacity slightly
|
||||
alpha = 0.9
|
||||
brightnessFactor = 1.15
|
||||
} else {
|
||||
// Exponential alpha decay for natural-looking trail fade
|
||||
alpha = Math.pow(0.65, i - 1)
|
||||
brightnessFactor = 1.0
|
||||
}
|
||||
|
||||
const r = Math.min(1.0, baseRgba.r * brightnessFactor)
|
||||
const g = Math.min(1.0, baseRgba.g * brightnessFactor)
|
||||
const b = Math.min(1.0, baseRgba.b * brightnessFactor)
|
||||
|
||||
colors.push(RGBA.fromValues(r, g, b, alpha))
|
||||
}
|
||||
|
||||
return colors
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the inactive/default color from a bright color using alpha
|
||||
* @param brightColor The brightest color (center/head of the scanner)
|
||||
* @param factor Alpha factor for inactive color (default: 0.2, range: 0-1)
|
||||
* @returns The same color with reduced alpha for background-independent dimming
|
||||
*/
|
||||
export function deriveInactiveColor(brightColor: ColorInput, factor: number = 0.2): RGBA {
|
||||
const baseRgba = brightColor instanceof RGBA ? brightColor : RGBA.fromHex(brightColor as string)
|
||||
|
||||
// Use the full color brightness but adjust alpha for background-independent dimming
|
||||
return RGBA.fromValues(baseRgba.r, baseRgba.g, baseRgba.b, factor)
|
||||
}
|
||||
|
||||
export type KnightRiderStyle = "blocks" | "diamonds"
|
||||
|
||||
export interface KnightRiderOptions {
|
||||
width?: number
|
||||
style?: KnightRiderStyle
|
||||
holdStart?: number
|
||||
holdEnd?: number
|
||||
colors?: ColorInput[]
|
||||
/** Single color to derive trail from (alternative to providing colors array) */
|
||||
color?: ColorInput
|
||||
/** Number of trail steps when using single color (default: 6) */
|
||||
trailSteps?: number
|
||||
defaultColor?: ColorInput
|
||||
/** Alpha factor for inactive color when using single color (default: 0.2, range: 0-1) */
|
||||
inactiveFactor?: number
|
||||
/** Enable fading of inactive dots during hold and movement (default: true) */
|
||||
enableFading?: boolean
|
||||
/** Minimum alpha value when fading (default: 0, range: 0-1) */
|
||||
minAlpha?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates frame strings for a Knight Rider style scanner animation
|
||||
* @param options Configuration options for the Knight Rider effect
|
||||
* @returns Array of frame strings
|
||||
*/
|
||||
export function createFrames(options: KnightRiderOptions = {}): string[] {
|
||||
const width = options.width ?? 8
|
||||
const style = options.style ?? "diamonds"
|
||||
const holdStart = options.holdStart ?? 30
|
||||
const holdEnd = options.holdEnd ?? 9
|
||||
|
||||
const colors =
|
||||
options.colors ??
|
||||
(options.color
|
||||
? deriveTrailColors(options.color, options.trailSteps)
|
||||
: [
|
||||
RGBA.fromHex("#ff0000"), // Brightest Red (Center)
|
||||
RGBA.fromHex("#ff5555"), // Glare/Bloom
|
||||
RGBA.fromHex("#dd0000"), // Trail 1
|
||||
RGBA.fromHex("#aa0000"), // Trail 2
|
||||
RGBA.fromHex("#770000"), // Trail 3
|
||||
RGBA.fromHex("#440000"), // Trail 4
|
||||
])
|
||||
|
||||
const defaultColor =
|
||||
options.defaultColor ??
|
||||
(options.color ? deriveInactiveColor(options.color, options.inactiveFactor) : RGBA.fromHex("#330000"))
|
||||
|
||||
const trailOptions = {
|
||||
colors,
|
||||
trailLength: colors.length,
|
||||
defaultColor,
|
||||
direction: "bidirectional" as const,
|
||||
holdFrames: { start: holdStart, end: holdEnd },
|
||||
enableFading: options.enableFading,
|
||||
minAlpha: options.minAlpha,
|
||||
}
|
||||
|
||||
// Bidirectional cycle: Forward (width) + Hold End + Backward (width-1) + Hold Start
|
||||
const totalFrames = width + holdEnd + (width - 1) + holdStart
|
||||
|
||||
// Generate dynamic frames where inactive pixels are dots and active ones are blocks
|
||||
const frames = Array.from({ length: totalFrames }, (_, frameIndex) => {
|
||||
return Array.from({ length: width }, (_, charIndex) => {
|
||||
const index = calculateColorIndex(frameIndex, charIndex, width, trailOptions)
|
||||
|
||||
if (style === "diamonds") {
|
||||
const shapes = ["⬥", "◆", "⬩", "⬪"]
|
||||
if (index >= 0 && index < trailOptions.colors.length) {
|
||||
return shapes[Math.min(index, shapes.length - 1)]
|
||||
}
|
||||
return "·"
|
||||
}
|
||||
|
||||
// Default to blocks
|
||||
// It's active if we have a valid color index that is within our colors array
|
||||
const isActive = index >= 0 && index < trailOptions.colors.length
|
||||
return isActive ? "■" : "⬝"
|
||||
}).join("")
|
||||
})
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a color generator function for Knight Rider style scanner animation
|
||||
* @param options Configuration options for the Knight Rider effect
|
||||
* @returns ColorGenerator function
|
||||
*/
|
||||
export function createColors(options: KnightRiderOptions = {}): ColorGenerator {
|
||||
const holdStart = options.holdStart ?? 30
|
||||
const holdEnd = options.holdEnd ?? 9
|
||||
|
||||
const colors =
|
||||
options.colors ??
|
||||
(options.color
|
||||
? deriveTrailColors(options.color, options.trailSteps)
|
||||
: [
|
||||
RGBA.fromHex("#ff0000"), // Brightest Red (Center)
|
||||
RGBA.fromHex("#ff5555"), // Glare/Bloom
|
||||
RGBA.fromHex("#dd0000"), // Trail 1
|
||||
RGBA.fromHex("#aa0000"), // Trail 2
|
||||
RGBA.fromHex("#770000"), // Trail 3
|
||||
RGBA.fromHex("#440000"), // Trail 4
|
||||
])
|
||||
|
||||
const defaultColor =
|
||||
options.defaultColor ??
|
||||
(options.color ? deriveInactiveColor(options.color, options.inactiveFactor) : RGBA.fromHex("#330000"))
|
||||
|
||||
const trailOptions = {
|
||||
colors,
|
||||
trailLength: colors.length,
|
||||
defaultColor,
|
||||
direction: "bidirectional" as const,
|
||||
holdFrames: { start: holdStart, end: holdEnd },
|
||||
enableFading: options.enableFading,
|
||||
minAlpha: options.minAlpha,
|
||||
}
|
||||
|
||||
return createKnightRiderTrail(trailOptions)
|
||||
}
|
||||
102
packages/tui/src/ui/toast.tsx
Normal file
102
packages/tui/src/ui/toast.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { createContext, useContext, type ParentProps, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { SplitBorder } from "./border"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
export type ToastOptions = {
|
||||
title?: string
|
||||
message: string
|
||||
variant: "info" | "success" | "warning" | "error"
|
||||
duration: number
|
||||
}
|
||||
type ToastInput = Omit<ToastOptions, "duration"> & { duration?: number }
|
||||
|
||||
export function Toast() {
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
return (
|
||||
<Show when={toast.currentToast}>
|
||||
{(current) => (
|
||||
<box
|
||||
position="absolute"
|
||||
justifyContent="center"
|
||||
alignItems="flex-start"
|
||||
top={2}
|
||||
right={2}
|
||||
maxWidth={Math.min(60, dimensions().width - 6)}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
borderColor={theme[current().variant]}
|
||||
border={["left", "right"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<Show when={current().title}>
|
||||
<text attributes={TextAttributes.BOLD} marginBottom={1} fg={theme.text}>
|
||||
{current().title}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text} wrapMode="word" width="100%">
|
||||
{current().message}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function init() {
|
||||
const [store, setStore] = createStore({
|
||||
currentToast: null as ToastOptions | null,
|
||||
})
|
||||
|
||||
let timeoutHandle: NodeJS.Timeout | null = null
|
||||
|
||||
const toast = {
|
||||
show(options: ToastInput) {
|
||||
const toastOptions = { ...options, duration: options.duration ?? 5000 }
|
||||
setStore("currentToast", toastOptions)
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle)
|
||||
timeoutHandle = setTimeout(() => {
|
||||
setStore("currentToast", null)
|
||||
}, toastOptions.duration).unref()
|
||||
},
|
||||
error: (err: any) => {
|
||||
if (err instanceof Error)
|
||||
return toast.show({
|
||||
variant: "error",
|
||||
message: err.message,
|
||||
})
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message: "An unknown error has occurred",
|
||||
})
|
||||
},
|
||||
get currentToast(): ToastOptions | null {
|
||||
return store.currentToast
|
||||
},
|
||||
}
|
||||
return toast
|
||||
}
|
||||
|
||||
export type ToastContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<ToastContext>()
|
||||
|
||||
export function ToastProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useToast must be used within a ToastProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue