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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user