OpenTUI is here (#2685)

This commit is contained in:
Dax
2025-10-31 15:07:36 -04:00
committed by GitHub
parent 81c617770d
commit 96bdeb3c7b
104 changed files with 8459 additions and 716 deletions

View File

@@ -0,0 +1,55 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { useKeyboard } from "@opentui/solid"
export type DialogAlertProps = {
title: string
message: string
onConfirm?: () => void
}
export function DialogAlert(props: DialogAlertProps) {
const dialog = useDialog()
const { theme } = useTheme()
useKeyboard((evt) => {
if (evt.name === "return") {
props.onConfirm?.()
dialog.clear()
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={theme.textMuted}>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.background}>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(),
)
})
}

View File

@@ -0,0 +1,79 @@
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 { useKeyboard } from "@opentui/solid"
import { Locale } from "@/util/locale"
export type DialogConfirmProps = {
title: string
message: string
onConfirm?: () => void
onCancel?: () => void
}
export function DialogConfirm(props: DialogConfirmProps) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "confirm" as "confirm" | "cancel",
})
useKeyboard((evt) => {
if (evt.name === "return") {
if (store.active === "confirm") props.onConfirm?.()
if (store.active === "cancel") props.onCancel?.()
dialog.clear()
}
if (evt.name === "left" || evt.name === "right") {
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}>{props.title}</text>
<text fg={theme.textMuted}>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"]}>
{(key) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={key === store.active ? theme.primary : undefined}
onMouseUp={(evt) => {
if (key === "confirm") props.onConfirm?.()
if (key === "cancel") props.onCancel?.()
dialog.clear()
}}
>
<text fg={key === store.active ? theme.background : theme.textMuted}>{Locale.titlecase(key)}</text>
</box>
)}
</For>
</box>
</box>
)
}
DialogConfirm.show = (dialog: DialogContext, title: string, message: string) => {
return new Promise<boolean>((resolve) => {
dialog.replace(
() => (
<DialogConfirm
title={title}
message={message}
onConfirm={() => resolve(true)}
onCancel={() => resolve(false)}
/>
),
() => resolve(false),
)
})
}

View File

@@ -0,0 +1,39 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "@tui/context/theme"
import { useDialog } from "./dialog"
import { useKeyboard } from "@opentui/solid"
export function DialogHelp() {
const dialog = useDialog()
const { theme } = useTheme()
useKeyboard((evt) => {
if (evt.name === "return" || evt.name === "escape") {
dialog.clear()
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>Help</text>
<text fg={theme.textMuted}>esc/enter</text>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>
Press Ctrl+P 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.background}>ok</text>
</box>
</box>
</box>
)
}

View File

@@ -0,0 +1,275 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "@tui/context/theme"
import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
import { batch, createEffect, createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import * as fuzzysort from "fuzzysort"
import { isDeepEqual } from "remeda"
import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
export interface DialogSelectProps<T> {
title: string
options: DialogSelectOption<T>[]
ref?: (ref: DialogSelectRef<T>) => void
onMove?: (option: DialogSelectOption<T>) => void
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void
keybind?: {
keybind: Keybind.Info
title: string
onTrigger: (option: DialogSelectOption<T>) => void
}[]
limit?: number
current?: T
}
export interface DialogSelectOption<T = any> {
title: string
value: T
description?: string
footer?: string
category?: string
disabled?: boolean
bg?: RGBA
onSelect?: (ctx: DialogContext) => void
}
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
}
export function DialogSelect<T>(props: DialogSelectProps<T>) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
selected: 0,
filter: "",
})
let input: InputRenderable
const filtered = createMemo(() => {
const needle = store.filter.toLowerCase()
const result = pipe(
props.options,
filter((x) => x.disabled !== true),
take(props.limit ?? Infinity),
(x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)),
)
return result
})
const grouped = createMemo(() => {
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 dimensions = useTerminalDimensions()
const height = createMemo(() =>
Math.min(flat().length + grouped().length * 2 - 1, Math.floor(dimensions().height / 2) - 6),
)
const selected = createMemo(() => flat()[store.selected])
createEffect(() => {
store.filter
setStore("selected", 0)
scroll.scrollTo(0)
})
function move(direction: number) {
let next = store.selected + direction
if (next < 0) next = flat().length - 1
if (next >= flat().length) next = 0
moveTo(next)
}
function moveTo(next: number) {
setStore("selected", next)
props.onMove?.(selected()!)
const target = scroll.getChildren().find((child) => {
return child.id === JSON.stringify(selected()?.value)
})
if (!target) return
const y = target.y - scroll.y
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)
}
}
}
const keybind = useKeybind()
useKeyboard((evt) => {
if (evt.name === "up") move(-1)
if (evt.name === "down") move(1)
if (evt.name === "pageup") move(-10)
if (evt.name === "pagedown") move(10)
if (evt.name === "return") {
const option = selected()
if (option.onSelect) option.onSelect(dialog)
props.onSelect?.(option)
}
for (const item of props.keybind ?? []) {
if (Keybind.match(item.keybind, keybind.parse(evt))) {
const s = selected()
if (s) item.onTrigger(s)
}
}
})
let scroll: ScrollBoxRenderable
const ref: DialogSelectRef<T> = {
get filter() {
return store.filter
},
get filtered() {
return filtered()
},
}
props.ref?.(ref)
return (
<box gap={1}>
<box paddingLeft={3} paddingRight={2}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box paddingTop={1} paddingBottom={1}>
<input
onInput={(e) => {
batch(() => {
setStore("filter", e)
props.onFilter?.(e)
})
}}
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
focusedTextColor={theme.textMuted}
ref={(r) => {
input = r
input.focus()
}}
placeholder="Enter search term"
/>
</box>
</box>
<scrollbox
paddingLeft={2}
paddingRight={2}
scrollbarOptions={{ visible: false }}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
maxHeight={height()}
>
<For each={grouped()}>
{([category, options], index) => (
<>
<Show when={category}>
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={1}>
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
{category}
</text>
</box>
</Show>
<For each={options}>
{(option) => {
const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
return (
<box
id={JSON.stringify(option.value)}
flexDirection="row"
onMouseUp={() => {
option.onSelect?.(dialog)
props.onSelect?.(option)
}}
onMouseOver={() => {
const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return
moveTo(index)
}}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={1}
paddingRight={1}
gap={1}
>
<Option
title={option.title}
footer={option.footer}
description={option.description !== category ? option.description : undefined}
active={active()}
current={isDeepEqual(option.value, props.current)}
/>
</box>
)
}}
</For>
</>
)}
</For>
</scrollbox>
<box paddingRight={2} paddingLeft={3} flexDirection="row" paddingBottom={1}>
<For each={props.keybind ?? []}>
{(item) => (
<text>
<span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span>
<span style={{ fg: theme.textMuted }}> {item.title}</span>
</text>
)}
</For>
</box>
</box>
)
}
function Option(props: {
title: string
description?: string
active?: boolean
current?: boolean
footer?: string
onMouseOver?: () => void
}) {
const { theme } = useTheme()
return (
<>
<text
flexGrow={1}
fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
attributes={props.active ? TextAttributes.BOLD : undefined}
overflow="hidden"
wrapMode="none"
>
{Locale.truncate(props.title, 62)}
<span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span>
</text>
<Show when={props.footer}>
<box flexShrink={0}>
<text fg={props.active ? theme.background : theme.textMuted}>{props.footer}</text>
</box>
</Show>
</>
)
}

View File

@@ -0,0 +1,171 @@
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, createEffect, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { createEventBus } from "@solid-primitives/event-bus"
const Border = {
topLeft: "┃",
topRight: "┃",
bottomLeft: "┃",
bottomRight: "┃",
horizontal: "",
vertical: "┃",
topT: "+",
bottomT: "+",
leftT: "+",
rightT: "+",
cross: "+",
}
export function Dialog(
props: ParentProps<{
size?: "medium" | "large"
onClose: () => void
}>,
) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
return (
<box
onMouseUp={async () => {
props.onClose?.()
}}
width={dimensions().width}
height={dimensions().height}
alignItems="center"
position="absolute"
paddingTop={dimensions().height / 4}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
>
<box
onMouseUp={async (e) => {
e.stopPropagation()
}}
customBorderChars={Border}
width={props.size === "large" ? 80 : 60}
maxWidth={dimensions().width - 2}
backgroundColor={theme.backgroundPanel}
borderColor={theme.border}
paddingTop={1}
>
{props.children}
</box>
</box>
)
}
function init() {
const [store, setStore] = createStore({
stack: [] as {
element: JSX.Element
onClose?: () => void
}[],
size: "medium" as "medium" | "large",
})
const allClosedEvent = createEventBus<void>()
useKeyboard((evt) => {
if (evt.name === "escape" && store.stack.length > 0) {
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
evt.preventDefault()
refocus()
}
})
const renderer = useRenderer()
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)
}
createEffect(() => {
if (store.stack.length === 0) {
allClosedEvent.emit()
}
})
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
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") {
setStore("size", size)
},
get allClosedEvent() {
return allClosedEvent
}
}
}
export type DialogContext = ReturnType<typeof init>
const ctx = createContext<DialogContext>()
export function DialogProvider(props: ParentProps) {
const value = init()
return (
<ctx.Provider value={value}>
{props.children}
<box position="absolute">
<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
}

View File

@@ -0,0 +1,56 @@
import { RGBA } from "@opentui/core"
import { useTimeline } from "@opentui/solid"
import { createMemo, createSignal } from "solid-js"
export type ShimmerProps = {
text: string
color: RGBA
}
const DURATION = 2_500
export function Shimmer(props: ShimmerProps) {
const timeline = useTimeline({
duration: DURATION,
loop: true,
})
const characters = props.text.split("")
const color = props.color
const shimmerSignals = characters.map((_, i) => {
const [shimmer, setShimmer] = createSignal(0.4)
const target = {
shimmer: shimmer(),
setShimmer,
}
timeline!.add(
target,
{
shimmer: 1,
duration: DURATION / (props.text.length + 1),
ease: "linear",
alternate: true,
loop: 2,
onUpdate: () => {
target.setShimmer(target.shimmer)
},
},
(i * (DURATION / (props.text.length + 1))) / 2,
)
return shimmer
})
return (
<text>
{(() => {
return characters.map((ch, i) => {
const shimmer = shimmerSignals[i]
const fg = RGBA.fromInts(color.r * 255, color.g * 255, color.b * 255, shimmer() * 255)
return <span style={{ fg }}>{ch}</span>
})
})()}
</text>
)
}

View File

@@ -0,0 +1,83 @@
import { createContext, useContext, type ParentProps, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "../component/border"
import { TextAttributes } from "@opentui/core"
import z from "zod"
import { TuiEvent } from "../event"
export type ToastOptions = z.infer<typeof TuiEvent.ToastShow.properties>
export function Toast() {
const toast = useToast()
const { theme } = useTheme()
return (
<Show when={toast.currentToast}>
{(current) => (
<box
position="absolute"
justifyContent="center"
alignItems="flex-start"
top={2}
right={2}
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}>
{current().title}
</text>
</Show>
<text>{current().message}</text>
</box>
)}
</Show>
)
}
function init() {
const [store, setStore] = createStore({
currentToast: null as ToastOptions | null,
})
let timeoutHandle: NodeJS.Timeout | null = null
return {
show(options: ToastOptions) {
const parsedOptions = TuiEvent.ToastShow.properties.parse(options)
const { duration, ...currentToast } = parsedOptions
setStore("currentToast", currentToast)
if (timeoutHandle) clearTimeout(timeoutHandle)
timeoutHandle = setTimeout(() => {
setStore("currentToast", null)
}, duration).unref()
},
get currentToast(): ToastOptions | null {
return store.currentToast
},
}
}
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
}