getting terminal colors working
This commit is contained in:
307
src/ui/command.tsx
Normal file
307
src/ui/command.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type ParentProps,
|
||||
For,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { useKeybind } from "../context/KeybindContext"
|
||||
import { useDialog } from "./dialog"
|
||||
import { useTheme } from "../context/ThemeContext"
|
||||
import type { KeybindsConfig } from "../utils/keybind"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { emit } from "../utils/event-bus"
|
||||
|
||||
/**
|
||||
* Command option for the command palette.
|
||||
*/
|
||||
export type CommandOption = {
|
||||
/** Display title */
|
||||
title: string
|
||||
/** Unique identifier */
|
||||
value: string
|
||||
/** Description shown below title */
|
||||
description?: string
|
||||
/** Category for grouping */
|
||||
category?: string
|
||||
/** Keybind reference */
|
||||
keybind?: keyof KeybindsConfig
|
||||
/** Whether this command is suggested */
|
||||
suggested?: boolean
|
||||
/** Slash command configuration */
|
||||
slash?: {
|
||||
name: string
|
||||
aliases?: string[]
|
||||
}
|
||||
/** Whether to hide from command list */
|
||||
hidden?: boolean
|
||||
/** Whether command is enabled */
|
||||
enabled?: boolean
|
||||
/** Footer text (usually keybind display) */
|
||||
footer?: string
|
||||
/** Handler when command is selected */
|
||||
onSelect?: (dialog: ReturnType<typeof useDialog>) => void
|
||||
}
|
||||
|
||||
type CommandContext = ReturnType<typeof init>
|
||||
const ctx = createContext<CommandContext>()
|
||||
|
||||
function init() {
|
||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
|
||||
const entries = createMemo(() => {
|
||||
const all = registrations().flatMap((x) => x())
|
||||
return all.map((x) => ({
|
||||
...x,
|
||||
footer: x.keybind ? keybind.print(x.keybind) : undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
const isEnabled = (option: CommandOption) => option.enabled !== false
|
||||
const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden
|
||||
|
||||
const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option)))
|
||||
const suggestedOptions = createMemo(() =>
|
||||
visibleOptions()
|
||||
.filter((option) => option.suggested)
|
||||
.map((option) => ({
|
||||
...option,
|
||||
value: `suggested:${option.value}`,
|
||||
category: "Suggested",
|
||||
})),
|
||||
)
|
||||
const suspended = () => suspendCount() > 0
|
||||
|
||||
// Handle keybind shortcuts
|
||||
useKeyboard((evt) => {
|
||||
if (suspended()) return
|
||||
if (dialog.isOpen) return
|
||||
for (const option of entries()) {
|
||||
if (!isEnabled(option)) continue
|
||||
if (option.keybind && keybind.match(option.keybind, evt)) {
|
||||
evt.preventDefault()
|
||||
option.onSelect?.(dialog)
|
||||
emit("command.execute", { command: option.value })
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = {
|
||||
/**
|
||||
* Trigger a command by its value.
|
||||
*/
|
||||
trigger(name: string) {
|
||||
for (const option of entries()) {
|
||||
if (option.value === name) {
|
||||
if (!isEnabled(option)) return
|
||||
option.onSelect?.(dialog)
|
||||
emit("command.execute", { command: name })
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get all slash commands.
|
||||
*/
|
||||
slashes() {
|
||||
return visibleOptions().flatMap((option) => {
|
||||
const slash = option.slash
|
||||
if (!slash) return []
|
||||
return {
|
||||
display: "/" + slash.name,
|
||||
description: option.description ?? option.title,
|
||||
aliases: slash.aliases?.map((alias) => "/" + alias),
|
||||
onSelect: () => result.trigger(option.value),
|
||||
}
|
||||
})
|
||||
},
|
||||
/**
|
||||
* Enable/disable keybinds temporarily.
|
||||
*/
|
||||
keybinds(enabled: boolean) {
|
||||
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
||||
},
|
||||
suspended,
|
||||
/**
|
||||
* Show the command palette dialog.
|
||||
*/
|
||||
show() {
|
||||
dialog.replace(() => <CommandDialog options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
|
||||
},
|
||||
/**
|
||||
* Register commands. Returns cleanup function.
|
||||
*/
|
||||
register(cb: () => CommandOption[]) {
|
||||
const results = createMemo(cb)
|
||||
setRegistrations((arr) => [results, ...arr])
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== results))
|
||||
})
|
||||
},
|
||||
/**
|
||||
* Get all visible options.
|
||||
*/
|
||||
get options() {
|
||||
return visibleOptions()
|
||||
},
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function useCommandDialog() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useCommandDialog must be used within a CommandProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function CommandProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
|
||||
// Open command palette on ctrl+p or command_list keybind
|
||||
useKeyboard((evt) => {
|
||||
if (value.suspended()) return
|
||||
if (dialog.isOpen) return
|
||||
if (evt.defaultPrevented) return
|
||||
if (keybind.match("command_list", evt)) {
|
||||
evt.preventDefault()
|
||||
value.show()
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Command palette dialog component.
|
||||
*/
|
||||
function CommandDialog(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) {
|
||||
const { theme } = useTheme()
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
|
||||
const filteredOptions = createMemo(() => {
|
||||
const query = filter().toLowerCase()
|
||||
if (!query) {
|
||||
return [...props.suggestedOptions, ...props.options]
|
||||
}
|
||||
return props.options.filter(
|
||||
(option) =>
|
||||
option.title.toLowerCase().includes(query) ||
|
||||
option.description?.toLowerCase().includes(query) ||
|
||||
option.category?.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
// Reset selection when filter changes
|
||||
createMemo(() => {
|
||||
filter()
|
||||
setSelectedIndex(0)
|
||||
})
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "escape") {
|
||||
dialog.clear()
|
||||
evt.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "return" || evt.name === "enter") {
|
||||
const option = filteredOptions()[selectedIndex()]
|
||||
if (option) {
|
||||
option.onSelect?.(dialog)
|
||||
dialog.clear()
|
||||
}
|
||||
evt.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1))
|
||||
evt.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
|
||||
setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1))
|
||||
evt.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// Handle text input
|
||||
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
setFilter((f) => f + evt.name)
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "backspace") {
|
||||
setFilter((f) => f.slice(0, -1))
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
const maxHeight = Math.floor(dimensions().height * 0.6)
|
||||
|
||||
return (
|
||||
<box flexDirection="column" padding={1}>
|
||||
{/* Search input */}
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
{"> "}
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
{filter() || "Type to search commands..."}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{/* Command list */}
|
||||
<box flexDirection="column" maxHeight={maxHeight}>
|
||||
<For each={filteredOptions().slice(0, 10)}>
|
||||
{(option, index) => (
|
||||
<box
|
||||
backgroundColor={index() === selectedIndex() ? theme.primary : undefined}
|
||||
padding={1}
|
||||
>
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text
|
||||
fg={index() === selectedIndex() ? theme.selectedListItemText : theme.text}
|
||||
attributes={index() === selectedIndex() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{option.title}
|
||||
</text>
|
||||
<Show when={option.footer}>
|
||||
<text fg={theme.textMuted}>{option.footer}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={option.description}>
|
||||
<text fg={theme.textMuted}>{option.description}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<Show when={filteredOptions().length === 0}>
|
||||
<text fg={theme.textMuted} style={{ padding: 1 }}>
|
||||
No commands found
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
224
src/ui/dialog.tsx
Normal file
224
src/ui/dialog.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
|
||||
import { useTheme } from "../context/ThemeContext"
|
||||
import { RGBA, Renderable } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Clipboard } from "../utils/clipboard"
|
||||
import { useToast } from "./toast"
|
||||
import { emit } from "../utils/event-bus"
|
||||
|
||||
export type DialogSize = "medium" | "large"
|
||||
|
||||
/**
|
||||
* Dialog component that renders a modal overlay with content.
|
||||
*/
|
||||
export function Dialog(
|
||||
props: ParentProps<{
|
||||
size?: DialogSize
|
||||
onClose: () => void
|
||||
}>,
|
||||
) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
|
||||
return (
|
||||
<box
|
||||
onMouseUp={async () => {
|
||||
if (renderer.getSelection()) return
|
||||
props.onClose?.()
|
||||
}}
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
alignItems="center"
|
||||
position="absolute"
|
||||
paddingTop={Math.floor(dimensions().height / 4)}
|
||||
left={0}
|
||||
top={0}
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
|
||||
>
|
||||
<box
|
||||
onMouseUp={async (e) => {
|
||||
if (renderer.getSelection()) return
|
||||
e.stopPropagation()
|
||||
}}
|
||||
width={props.size === "large" ? 80 : 60}
|
||||
maxWidth={dimensions().width - 2}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
paddingTop={1}
|
||||
>
|
||||
{props.children}
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
type DialogStackItem = {
|
||||
element: JSX.Element
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
function init() {
|
||||
const [store, setStore] = createStore({
|
||||
stack: [] as DialogStackItem[],
|
||||
size: "medium" as DialogSize,
|
||||
})
|
||||
|
||||
const renderer = useRenderer()
|
||||
let focus: Renderable | null = null
|
||||
|
||||
function refocus() {
|
||||
setTimeout(() => {
|
||||
if (!focus) return
|
||||
if (focus.isDestroyed) return
|
||||
function find(item: Renderable): boolean {
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
evt.stopPropagation()
|
||||
refocus()
|
||||
emit("dialog.close", {})
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
/**
|
||||
* Clear all dialogs from the stack.
|
||||
*/
|
||||
clear() {
|
||||
for (const item of store.stack) {
|
||||
if (item.onClose) item.onClose()
|
||||
}
|
||||
batch(() => {
|
||||
setStore("size", "medium")
|
||||
setStore("stack", [])
|
||||
})
|
||||
refocus()
|
||||
emit("dialog.close", {})
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace all dialogs with a new one.
|
||||
*/
|
||||
replace(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
|
||||
if (store.stack.length === 0) {
|
||||
focus = renderer.currentFocusedRenderable
|
||||
focus?.blur()
|
||||
}
|
||||
for (const item of store.stack) {
|
||||
if (item.onClose) item.onClose()
|
||||
}
|
||||
const element = typeof input === "function" ? input() : input
|
||||
setStore("size", "medium")
|
||||
setStore("stack", [{ element, onClose }])
|
||||
emit("dialog.open", { dialogId: "dialog" })
|
||||
},
|
||||
|
||||
/**
|
||||
* Push a new dialog onto the stack.
|
||||
*/
|
||||
push(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
|
||||
if (store.stack.length === 0) {
|
||||
focus = renderer.currentFocusedRenderable
|
||||
focus?.blur()
|
||||
}
|
||||
const element = typeof input === "function" ? input() : input
|
||||
setStore("stack", [...store.stack, { element, onClose }])
|
||||
emit("dialog.open", { dialogId: "dialog" })
|
||||
},
|
||||
|
||||
/**
|
||||
* Pop the top dialog from the stack.
|
||||
*/
|
||||
pop() {
|
||||
if (store.stack.length === 0) return
|
||||
const current = store.stack.at(-1)!
|
||||
current.onClose?.()
|
||||
setStore("stack", store.stack.slice(0, -1))
|
||||
if (store.stack.length === 0) {
|
||||
refocus()
|
||||
}
|
||||
emit("dialog.close", {})
|
||||
},
|
||||
|
||||
get stack() {
|
||||
return store.stack
|
||||
},
|
||||
|
||||
get size() {
|
||||
return store.size
|
||||
},
|
||||
|
||||
setSize(size: DialogSize) {
|
||||
setStore("size", size)
|
||||
},
|
||||
|
||||
get isOpen() {
|
||||
return store.stack.length > 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type DialogContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<DialogContext>()
|
||||
|
||||
/**
|
||||
* DialogProvider wraps the application and provides dialog functionality.
|
||||
* Also handles clipboard copy on text selection within dialogs.
|
||||
*/
|
||||
export function DialogProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
const renderer = useRenderer()
|
||||
const toast = useToast()
|
||||
|
||||
return (
|
||||
<ctx.Provider value={value}>
|
||||
{props.children}
|
||||
<box
|
||||
position="absolute"
|
||||
onMouseUp={async () => {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (text && text.length > 0) {
|
||||
await Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
renderer.clearSelection()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Show when={value.stack.length > 0}>
|
||||
<Dialog onClose={() => value.clear()} size={value.size}>
|
||||
{value.stack.at(-1)!.element}
|
||||
</Dialog>
|
||||
</Show>
|
||||
</box>
|
||||
</ctx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the dialog context.
|
||||
*/
|
||||
export function useDialog() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useDialog must be used within a DialogProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
153
src/ui/toast.tsx
Normal file
153
src/ui/toast.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { createContext, useContext, type ParentProps, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useTheme } from "../context/ThemeContext"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { emit } from "../utils/event-bus"
|
||||
|
||||
export type ToastVariant = "info" | "success" | "warning" | "error"
|
||||
|
||||
export type ToastOptions = {
|
||||
title?: string
|
||||
message: string
|
||||
variant: ToastVariant
|
||||
duration?: number
|
||||
}
|
||||
|
||||
const DEFAULT_DURATION = 5000
|
||||
|
||||
/**
|
||||
* Toast component that displays at the top-right of the screen.
|
||||
* NOTE: This component must be rendered INSIDE ThemeProvider since it uses useTheme().
|
||||
* The ToastProvider itself can be placed outside ThemeProvider if needed.
|
||||
*/
|
||||
export function Toast() {
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
const getVariantColor = (variant: ToastVariant) => {
|
||||
switch (variant) {
|
||||
case "success":
|
||||
return theme.success
|
||||
case "warning":
|
||||
return theme.warning
|
||||
case "error":
|
||||
return theme.error
|
||||
case "info":
|
||||
default:
|
||||
return theme.info
|
||||
}
|
||||
}
|
||||
|
||||
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={getVariantColor(current().variant)}
|
||||
border={["left", "right"]}
|
||||
>
|
||||
<box flexDirection="column">
|
||||
<Show when={current().title}>
|
||||
<text attributes={TextAttributes.BOLD} style={{ marginBottom: 1 }} fg={theme.text}>
|
||||
{current().title}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text} wrapMode="word" width="100%">
|
||||
{current().message}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function init() {
|
||||
const [store, setStore] = createStore({
|
||||
currentToast: null as ToastOptions | null,
|
||||
})
|
||||
|
||||
let timeoutHandle: NodeJS.Timeout | null = null
|
||||
|
||||
const toast = {
|
||||
show(options: ToastOptions) {
|
||||
const duration = options.duration ?? DEFAULT_DURATION
|
||||
setStore("currentToast", {
|
||||
title: options.title,
|
||||
message: options.message,
|
||||
variant: options.variant,
|
||||
})
|
||||
|
||||
// Emit event for other listeners
|
||||
emit("toast.show", options)
|
||||
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle)
|
||||
timeoutHandle = setTimeout(() => {
|
||||
setStore("currentToast", null)
|
||||
}, duration)
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
if (err instanceof Error) {
|
||||
return toast.show({
|
||||
variant: "error",
|
||||
message: err.message,
|
||||
})
|
||||
}
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message: "An unknown error has occurred",
|
||||
})
|
||||
},
|
||||
info: (message: string, title?: string) => {
|
||||
toast.show({ variant: "info", message, title })
|
||||
},
|
||||
success: (message: string, title?: string) => {
|
||||
toast.show({ variant: "success", message, title })
|
||||
},
|
||||
warning: (message: string, title?: string) => {
|
||||
toast.show({ variant: "warning", message, title })
|
||||
},
|
||||
clear: () => {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle)
|
||||
setStore("currentToast", null)
|
||||
},
|
||||
get currentToast(): ToastOptions | null {
|
||||
return store.currentToast
|
||||
},
|
||||
}
|
||||
return toast
|
||||
}
|
||||
|
||||
export type ToastContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<ToastContext>()
|
||||
|
||||
/**
|
||||
* ToastProvider provides toast functionality.
|
||||
* NOTE: The Toast UI component is NOT rendered here - you must render <Toast />
|
||||
* separately inside your component tree, after ThemeProvider.
|
||||
*/
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user