rewrite bubbletea -> opentui

This commit is contained in:
Hadi
2026-09-01 10:23:42 +02:00
parent 000f9997fd
commit 12305ba1f2
95 changed files with 1612 additions and 3711 deletions
+40
View File
@@ -0,0 +1,40 @@
import { type ColorInput, TextAttributes } from "@opentui/core";
import { Show } from "solid-js";
import { theme } from "../index.ts";
const CAP_LEFT = "";
const CAP_RIGHT = "";
export interface BadgeProps {
label: string;
color: ColorInput;
textColor?: ColorInput;
backgroundColor?: ColorInput;
bold?: boolean;
withNerdfont?: boolean;
}
export function Badge(props: BadgeProps) {
const textColor = () => props.textColor ?? theme.background;
const backgroundColor = () => props.backgroundColor ?? theme.background;
const attributes = () => (props.bold === false ? undefined : TextAttributes.BOLD);
const nerdFonts = () => props.withNerdfont ?? theme.nerdFonts;
return (
<box flexDirection="row">
<Show when={nerdFonts()}>
<text fg={props.color} bg={backgroundColor()}>
{CAP_LEFT}
</text>
</Show>
<text fg={textColor()} bg={props.color} attributes={attributes()}>
{nerdFonts() ? props.label : ` ${props.label} `}
</text>
<Show when={nerdFonts()}>
<text fg={props.color} bg={backgroundColor()}>
{CAP_RIGHT}
</text>
</Show>
</box>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { type ColorInput, CliRenderEvents, TextAttributes, type BoxRenderable } from "@opentui/core";
import { onResize, useRenderer } from "@opentui/solid";
import { formatCommandBindings } from "@opentui/keymap/extras";
import { useKeymapSelector } from "@opentui/keymap/solid";
import { createMemo, createSignal, For } from "solid-js";
import { theme } from "../index.ts";
const KEY_DISPLAY = { up: "↑", down: "↓", left: "←", right: "→", enter: "Enter", escape: "Esc" };
const SEPARATOR = " ";
const ELLIPSIS = "…";
export interface HelpBarProps {
accentColor?: ColorInput;
mutedColor?: ColorInput;
}
interface HelpEntry {
command: string;
keys: string;
label: string;
}
export function HelpBar(props: HelpBarProps = {}) {
const accent = () => props.accentColor ?? theme.primary;
const muted = () => props.mutedColor ?? theme.muted;
const renderer = useRenderer();
const [width, setWidth] = createSignal(0);
let container: BoxRenderable | undefined;
const measure = () => {
if (!container) return;
setWidth(container.width);
};
const remeasureNextFrame = () => renderer.once(CliRenderEvents.FRAME, measure);
onResize(remeasureNextFrame);
const entries = useKeymapSelector((keymap): HelpEntry[] =>
keymap
.getCommandEntries({ visibility: "active" })
.filter((entry) => entry.command.shortHelp === true)
.map((entry) => ({
command: entry.command.name,
keys: formatCommandBindings(entry.bindings, { keyNameAliases: KEY_DISPLAY }) ?? "",
label: typeof entry.command.desc === "string" ? entry.command.desc : entry.command.name,
})),
);
const visible = createMemo((): HelpEntry[] => {
const available = width() - 2;
const shown: HelpEntry[] = [];
let used = 0;
for (const entry of entries()) {
const sepWidth = shown.length > 0 ? SEPARATOR.length : 0;
const entryWidth = entry.keys.length + 1 + entry.label.length;
if (used + sepWidth + entryWidth <= available) {
used += sepWidth + entryWidth;
shown.push(entry);
continue;
}
const remaining = available - used - sepWidth;
const prefix = `${entry.keys} `;
if (remaining > prefix.length + ELLIPSIS.length) {
const labelBudget = remaining - prefix.length - ELLIPSIS.length;
shown.push({ ...entry, label: `${entry.label.slice(0, labelBudget)}${ELLIPSIS}` });
}
break;
}
return shown;
});
return (
<box
flexDirection="row"
paddingLeft={1}
paddingRight={1}
overflow="hidden"
ref={(el) => {
container = el;
remeasureNextFrame();
}}
>
<For each={visible()}>
{(entry, i) => (
<>
{i() > 0 && <text fg={muted()}>{SEPARATOR}</text>}
<text fg={accent()} attributes={TextAttributes.BOLD}>
{entry.keys}
</text>
<text fg={muted()}>{` ${entry.label}`}</text>
</>
)}
</For>
</box>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { type ColorInput, TextAttributes } from "@opentui/core";
import { formatCommandBindings } from "@opentui/keymap/extras";
import { useKeymapSelector } from "@opentui/keymap/solid";
import { helpOpen, toggleHelp } from "../context/help.ts";
import { Modal } from "./Modal.tsx";
const KEY_DISPLAY = { up: "↑", down: "↓", left: "←", right: "→", enter: "Enter", escape: "Esc" };
export interface HelpModalProps {
accentColor?: ColorInput;
backgroundColor?: ColorInput;
backdropColor?: ColorInput;
width?: number;
}
interface HelpEntry {
command: string;
keys: string;
label: string;
}
export function HelpModal(props: HelpModalProps = {}) {
const entries = useKeymapSelector((keymap): HelpEntry[] =>
keymap.getCommandEntries({ visibility: "active" }).map((entry) => ({
command: entry.command.name,
keys: formatCommandBindings(entry.bindings, { keyNameAliases: KEY_DISPLAY }) ?? "",
label: typeof entry.command.desc === "string" ? entry.command.desc : entry.command.name,
})),
);
return (
<Modal
open={helpOpen()}
onDismiss={toggleHelp}
accentColor={props.accentColor}
backgroundColor={props.backgroundColor}
backdropColor={props.backdropColor}
width={props.width}
>
<text attributes={TextAttributes.BOLD}>Keybindings</text>
<text> </text>
<text>{entries().map((entry) => `${entry.keys.padEnd(10)}${entry.label}`).join("\n")}</text>
</Modal>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { ColorInput } from "@opentui/core";
import { useTerminalDimensions } from "@opentui/solid";
import { Match, Switch, type ParentProps } from "solid-js";
import { theme } from "../index.ts";
export interface MinSizeGuardProps extends ParentProps {
minWidth?: number;
minHeight?: number;
backgroundColor?: ColorInput;
textColor?: ColorInput;
}
export function MinSizeGuard(props: MinSizeGuardProps) {
const dimensions = useTerminalDimensions();
const minWidth = () => props.minWidth ?? 80;
const minHeight = () => props.minHeight ?? 24;
const tooSmall = () => dimensions().width < minWidth() || dimensions().height < minHeight();
return (
<Switch>
<Match when={tooSmall()}>
<box
width={dimensions().width}
height={dimensions().height}
alignItems="center"
justifyContent="center"
backgroundColor={props.backgroundColor ?? theme.background}
>
<text fg={props.textColor ?? theme.muted}>
{`Terminal too small\nMinimum size: ${minWidth()}x${minHeight()} — current: ${dimensions().width}x${dimensions().height}`}
</text>
</box>
</Match>
<Match when={!tooSmall()}>{props.children}</Match>
</Switch>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { type ColorInput, RGBA } from "@opentui/core";
import { Portal, useTerminalDimensions } from "@opentui/solid";
import { Show, type ParentProps } from "solid-js";
import { theme } from "../index.ts";
export interface ModalProps extends ParentProps {
open: boolean;
onDismiss?: () => void;
accentColor?: ColorInput;
backgroundColor?: ColorInput;
backdropColor?: ColorInput;
width?: number;
}
export function Modal(props: ModalProps) {
const dimensions = useTerminalDimensions();
return (
<Portal>
<Show when={props.open}>
<box
width={dimensions().width}
height={dimensions().height}
position="absolute"
top={0}
left={0}
alignItems="center"
paddingTop={Math.floor(dimensions().height / 4)}
backgroundColor={props.backdropColor ?? RGBA.fromInts(0, 0, 0, 150)}
zIndex={2000}
onMouseDown={theme.mouse ? props.onDismiss : undefined}
>
<box
width={props.width ?? 50}
border
borderStyle={theme.borderStyle}
borderColor={props.accentColor ?? theme.primary}
backgroundColor={props.backgroundColor ?? theme.background}
flexDirection="column"
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
{props.children}
</box>
</box>
</Show>
</Portal>
);
}
+49
View File
@@ -0,0 +1,49 @@
import type { ColorInput } from "@opentui/core";
import { Portal } from "@opentui/solid";
import { For } from "solid-js";
import { dismiss, notifications, type NotificationKind } from "../context/notifications.ts";
import { theme } from "../index.ts";
const KIND_LABEL: Record<NotificationKind, string> = {
info: "Info",
success: "Success",
warning: "Warning",
error: "Error",
};
export interface NotificationHostProps {
width?: number;
colors?: Partial<Record<NotificationKind, ColorInput>>;
}
export function NotificationHost(props: NotificationHostProps = {}) {
const defaultColor = (): Record<NotificationKind, ColorInput> => ({
info: theme.primary,
success: theme.success,
warning: theme.warning,
error: theme.error,
});
const colorFor = (kind: NotificationKind) => props.colors?.[kind] ?? defaultColor()[kind];
return (
<Portal>
<box position="absolute" top={1} right={1} flexDirection="column" zIndex={1000}>
<For each={notifications()}>
{(toast) => (
<box
border
borderStyle={theme.borderStyle}
borderColor={colorFor(toast.kind)}
width={props.width ?? 32}
marginBottom={1}
onMouseDown={theme.mouse ? () => dismiss(toast.id) : undefined}
>
<text fg={colorFor(toast.kind)}>{toast.title ?? KIND_LABEL[toast.kind]}</text>
<text>{toast.message}</text>
</box>
)}
</For>
</box>
</Portal>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { type ColorInput, parseColor, RGBA } from "@opentui/core";
import { createMemo, For } from "solid-js";
import { theme } from "../index.ts";
function sampleGradient(stops: ColorInput[], t: number): ColorInput {
if (stops.length === 1) return stops[0]!;
const clamped = Math.min(Math.max(t, 0), 1);
const segment = clamped * (stops.length - 1);
const i = Math.min(Math.floor(segment), stops.length - 2);
const localT = segment - i;
const [ar, ag, ab, aa] = parseColor(stops[i]!).toInts();
const [br, bg, bb, ba] = parseColor(stops[i + 1]!).toInts();
return RGBA.fromInts(
Math.round(ar + (br - ar) * localT),
Math.round(ag + (bg - ag) * localT),
Math.round(ab + (bb - ab) * localT),
Math.round(aa + (ba - aa) * localT),
);
}
export interface ProgressBarProps {
value: number;
width?: number;
color?: ColorInput | ColorInput[];
trackColor?: ColorInput;
fillChar?: string;
trackChar?: string;
}
export function ProgressBar(props: ProgressBarProps) {
const width = () => props.width ?? 20;
const stops = createMemo(() => (Array.isArray(props.color) ? props.color : [props.color ?? theme.primary]));
const filled = createMemo(() => Math.round((width() * Math.min(Math.max(props.value, 0), 100)) / 100));
const cells = createMemo(() => Array.from({ length: width() }, (_, i) => i));
return (
<box flexDirection="row">
<For each={cells()}>
{(i) => {
const isFilled = () => i < filled();
const t = width() > 1 ? i / (width() - 1) : 0;
return (
<text fg={isFilled() ? sampleGradient(stops(), t) : (props.trackColor ?? theme.muted)}>
{isFilled() ? (props.fillChar ?? "█") : (props.trackChar ?? "░")}
</text>
);
}}
</For>
</box>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { type ColorInput, type SelectOption, TextAttributes } from "@opentui/core";
import { theme } from "../index.ts";
export interface SidebarHandle {
moveUp(): void;
moveDown(): void;
selectCurrent(): void;
}
export interface SidebarProps {
title: string;
items: SelectOption[];
width?: number;
focused?: boolean;
onSelect: (option: SelectOption) => void;
onConfirm?: (option: SelectOption) => void;
ref?: (handle: SidebarHandle | null) => void;
accentColor?: ColorInput;
mutedColor?: ColorInput;
backgroundColor?: ColorInput;
textColor?: ColorInput;
focusedBackgroundColor?: ColorInput;
focusedTextColor?: ColorInput;
selectedBackgroundColor?: ColorInput;
selectedTextColor?: ColorInput;
descriptionColor?: ColorInput;
selectedDescriptionColor?: ColorInput;
}
export function Sidebar(props: SidebarProps) {
return (
<box
width={props.width ?? 24}
border
borderStyle={theme.borderStyle}
borderColor={props.focused ? (props.accentColor ?? theme.primary) : (props.mutedColor ?? theme.muted)}
flexDirection="column"
>
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<select
ref={(el) => {
props.ref?.(
el && {
moveUp: () => el.moveUp(),
moveDown: () => el.moveDown(),
selectCurrent: () => el.selectCurrent(),
},
);
}}
flexGrow={1}
focused={props.focused}
showScrollIndicator
options={props.items}
backgroundColor={props.backgroundColor}
textColor={props.textColor}
focusedBackgroundColor={props.focusedBackgroundColor}
focusedTextColor={props.focusedTextColor}
selectedBackgroundColor={props.selectedBackgroundColor}
selectedTextColor={props.selectedTextColor}
descriptionColor={props.descriptionColor}
selectedDescriptionColor={props.selectedDescriptionColor}
onChange={(_index, option) => {
if (!option) return;
props.onSelect(option);
}}
onSelect={(_index, option) => {
if (!option) return;
props.onConfirm?.(option);
}}
/>
</box>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ColorInput } from "@opentui/core";
import { For } from "solid-js";
import { theme } from "../index.ts";
const BARS = ["▁", "▃", "▅", "▇"] as const;
export interface SignalBarsProps {
signal: number;
filledColor?: ColorInput;
emptyColor?: ColorInput;
}
export function SignalBars(props: SignalBarsProps) {
const level = () => Math.floor((props.signal * BARS.length) / 100);
return (
<box flexDirection="row">
<For each={BARS}>
{(bar, i) => <text fg={i() < level() ? props.filledColor : (props.emptyColor ?? theme.muted)}>{bar}</text>}
</For>
</box>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { ColorInput } from "@opentui/core";
import "opentui-spinner/solid";
import type { ColorGenerator, SpinnerOptions } from "opentui-spinner";
import { theme } from "../index.ts";
export interface SpinnerProps {
name?: SpinnerOptions["name"];
frames?: string[];
interval?: number;
autoplay?: boolean;
color?: ColorInput | ColorGenerator;
backgroundColor?: ColorInput;
}
export function Spinner(props: SpinnerProps) {
return (
<spinner
name={props.name}
frames={props.frames}
interval={props.interval}
autoplay={props.autoplay ?? !theme.reducedMotion}
color={props.color ?? theme.primary}
backgroundColor={props.backgroundColor}
/>
);
}
+150
View File
@@ -0,0 +1,150 @@
import { type ColorInput, CliRenderEvents, TextAttributes, type BoxRenderable } from "@opentui/core";
import { onResize, useRenderer } from "@opentui/solid";
import { createMemo, createSignal, For } from "solid-js";
import { theme } from "../index.ts";
export interface TabItem {
value: string;
label: string;
}
export interface TabsProps {
items: TabItem[];
value: string;
onChange: (value: string) => void;
focused?: boolean;
accentColor?: ColorInput;
mutedColor?: ColorInput;
}
export function stepTabValue(items: TabItem[], value: string, delta: number): string {
const index = items.findIndex((t) => t.value === value);
if (index === -1) return value;
return items[(index + delta + items.length) % items.length]!.value;
}
function innerWidth(label: string): number {
return label.length + 2;
}
function segmentWidth(label: string): number {
return innerWidth(label) + 2;
}
function topEdge(label: string): string {
return `╭${"─".repeat(innerWidth(label))}╮`;
}
function bottomEdge(label: string, isFirst: boolean, isLast: boolean, isActive: boolean): string {
const left = isFirst ? (isActive ? "│" : "├") : isActive ? "╯" : "┴";
const right = isLast ? (isActive ? "│" : "┤") : isActive ? "╰" : "┴";
const fill = isActive ? " " : "─";
return left + fill.repeat(innerWidth(label)) + right;
}
function buildBottomRow(segments: { label: string; isActive: boolean }[], available: number): string {
const tabsWidth = segments.reduce((sum, s) => sum + segmentWidth(s.label), 0);
const remainder = available - tabsWidth;
const extending = remainder > 0;
const tabs = segments
.map((s, i) => bottomEdge(s.label, i === 0, i === segments.length - 1 && !extending, s.isActive))
.join("");
return extending ? tabs + "─".repeat(remainder - 1) + "╮" : tabs;
}
function fitCount(items: TabItem[], start: number, available: number): number {
const hiddenBefore = start;
let used = 0;
let count = 0;
for (let i = start; i < items.length; i++) {
const width = segmentWidth(items[i]!.label);
const hiddenAfter = items.length - start - count - 1;
const totalHidden = hiddenBefore + hiddenAfter;
const reserve = totalHidden > 0 ? segmentWidth(`+${totalHidden}`) : 0;
if (used + width + reserve > available) break;
used += width;
count++;
}
return Math.max(count, 1);
}
export function Tabs(props: TabsProps) {
const renderer = useRenderer();
const [width, setWidth] = createSignal(0);
let container: BoxRenderable | undefined;
const measure = () => {
if (!container) return;
setWidth(container.width);
};
const remeasureNextFrame = () => renderer.once(CliRenderEvents.FRAME, measure);
onResize(remeasureNextFrame);
const segments = createMemo(() => {
const items = props.items;
const activeIndex = Math.max(
0,
items.findIndex((t) => t.value === props.value),
);
let start = 0;
let count = fitCount(items, start, width());
if (activeIndex >= start + count) {
start = activeIndex;
count = fitCount(items, start, width());
}
const shownItems = items.slice(start, start + count);
const shown = shownItems.map((item) => ({
value: item.value as string | undefined,
label: item.label,
isActive: item.value === props.value,
}));
const hidden = items.length - count;
if (hidden > 0) {
const used = shownItems.reduce((sum, item) => sum + segmentWidth(item.label), 0);
const tag = `+${hidden}`;
if (shown.length === 0 || used + segmentWidth(tag) <= width()) {
shown.push({ value: undefined, label: tag, isActive: false });
}
}
return shown;
});
const accent = () => props.accentColor ?? theme.primary;
const muted = () => props.mutedColor ?? theme.muted;
const borderColor = () => (props.focused ? accent() : muted());
return (
<box
flexDirection="column"
ref={(el) => {
container = el;
remeasureNextFrame();
}}
>
<text fg={borderColor()}>{segments().map((s) => topEdge(s.label)).join("")}</text>
<box flexDirection="row">
<For each={segments()}>
{(s) => (
<>
<text fg={borderColor()}>│</text>
<text
fg={s.isActive ? accent() : muted()}
attributes={s.isActive ? TextAttributes.BOLD : undefined}
onMouseDown={theme.mouse && s.value !== undefined ? () => props.onChange(s.value!) : undefined}
>
{` ${s.label} `}
</text>
<text fg={borderColor()}>│</text>
</>
)}
</For>
</box>
<text fg={borderColor()}>{buildBottomRow(segments(), width())}</text>
</box>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { type ColorInput, TextAttributes } from "@opentui/core";
import { Show } from "solid-js";
import { theme } from "../index.ts";
import { Badge } from "./Badge.tsx";
export interface ToggleProps {
on: boolean;
onToggle?: () => void;
onColor?: ColorInput;
offColor?: ColorInput;
withNerdfont?: boolean;
}
export function Toggle(props: ToggleProps) {
const onColor = () => props.onColor ?? theme.success;
const offColor = () => props.offColor ?? theme.muted;
const nerdFonts = () => props.withNerdfont ?? theme.nerdFonts;
return (
<box onMouseDown={theme.mouse ? props.onToggle : undefined}>
<Show
when={nerdFonts()}
fallback={
<text fg={props.on ? onColor() : offColor()} attributes={TextAttributes.BOLD}>
{props.on ? "on" : "off"}
</text>
}
>
<Badge label={props.on ? " ●" : "● "} color={props.on ? onColor() : offColor()} withNerdfont />
</Show>
</box>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { HelpBar, type HelpBarProps } from "./HelpBar.tsx";
export { HelpModal, type HelpModalProps } from "./HelpModal.tsx";
+9
View File
@@ -0,0 +1,9 @@
export { Badge, type BadgeProps } from "./Badge.tsx";
export { MinSizeGuard, type MinSizeGuardProps } from "./MinSizeGuard.tsx";
export { Modal, type ModalProps } from "./Modal.tsx";
export { NotificationHost, type NotificationHostProps } from "./NotificationHost.tsx";
export { ProgressBar, type ProgressBarProps } from "./ProgressBar.tsx";
export { Sidebar, type SidebarHandle, type SidebarProps } from "./Sidebar.tsx";
export { SignalBars, type SignalBarsProps } from "./SignalBars.tsx";
export { stepTabValue, Tabs, type TabItem, type TabsProps } from "./Tabs.tsx";
export { Toggle, type ToggleProps } from "./Toggle.tsx";
+109
View File
@@ -0,0 +1,109 @@
export interface ColorsYAML {
base00?: string;
base01?: string;
base02?: string;
base03?: string;
base04?: string;
base05?: string;
base06?: string;
base07?: string;
base08?: string;
base09?: string;
base0a?: string;
base0b?: string;
base0c?: string;
base0d?: string;
base0e?: string;
base0f?: string;
}
export interface ConfigYAML {
nerd_fonts?: boolean;
emoji_fonts?: boolean;
reduced_motion?: boolean;
mouse?: boolean;
border?: string;
colors?: ColorsYAML;
}
export const COLOR_KEYS = [
"base00",
"base01",
"base02",
"base03",
"base04",
"base05",
"base06",
"base07",
"base08",
"base09",
"base0a",
"base0b",
"base0c",
"base0d",
"base0e",
"base0f",
] as const;
export type ResolvedColors = Record<(typeof COLOR_KEYS)[number], string>;
export function mergeColors(
base: ColorsYAML,
user: ColorsYAML,
): ResolvedColors {
const merged = {} as ResolvedColors;
for (const key of COLOR_KEYS) {
merged[key] = user[key] || base[key] || "";
}
return merged;
}
export interface ResolvedConfig {
nerdFonts: boolean;
emojiFonts: boolean;
reducedMotion: boolean;
mouse: boolean;
border: string;
colors: ResolvedColors;
}
export function mergeConfig(
base: ConfigYAML,
user: ConfigYAML,
): ResolvedConfig {
return {
nerdFonts: user.nerd_fonts ?? base.nerd_fonts ?? false,
emojiFonts: user.emoji_fonts ?? base.emoji_fonts ?? false,
reducedMotion: user.reduced_motion ?? base.reduced_motion ?? false,
mouse: user.mouse ?? base.mouse ?? true,
border: user.border || base.border || "rounded",
colors: mergeColors(base.colors ?? {}, user.colors ?? {}),
};
}
export function normalizeColor(hex: string): string {
const trimmed = hex.trim();
if (trimmed && !trimmed.startsWith("#")) return `#${trimmed}`;
return trimmed;
}
export function normalizeColors(colors: ResolvedColors): ResolvedColors {
const out = {} as ResolvedColors;
for (const key of COLOR_KEYS) out[key] = normalizeColor(colors[key]);
return out;
}
export type OpenTUIBorderStyle = "single" | "double" | "rounded" | "heavy";
const OPENTUI_BORDER_STYLES: readonly OpenTUIBorderStyle[] = [
"single",
"double",
"rounded",
"heavy",
];
export function resolveBorderStyle(name: string): OpenTUIBorderStyle {
return (OPENTUI_BORDER_STYLES as readonly string[]).includes(name)
? (name as OpenTUIBorderStyle)
: "rounded";
}
+8
View File
@@ -0,0 +1,8 @@
import { createSignal } from "solid-js";
const [helpOpen, setHelpOpen] = createSignal(false);
export { helpOpen };
export function toggleHelp(): void {
setHelpOpen((open) => !open);
}
+20
View File
@@ -0,0 +1,20 @@
import { createContext, useContext, type ParentProps } from "solid-js";
export function createSimpleContext<T, Props extends Record<string, any> = {}>(input: {
name: string;
init: (props: Props) => T;
}) {
const ctx = createContext<T>();
return {
provider: (props: ParentProps<Props>) => {
const value = input.init(props);
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
},
use() {
const value = useContext(ctx);
if (!value) throw new Error(`${input.name} context must be used within its provider`);
return value;
},
};
}
+3
View File
@@ -0,0 +1,3 @@
export { createSimpleContext } from "./helper.tsx";
export { notify, dismiss, notifications, type Notification, type NotificationKind, type NotifyOptions } from "./notifications.ts";
export { helpOpen, toggleHelp } from "./help.ts";
+42
View File
@@ -0,0 +1,42 @@
import { createSignal } from "solid-js";
export type NotificationKind = "info" | "success" | "warning" | "error";
export interface Notification {
id: string;
title?: string;
message: string;
kind: NotificationKind;
}
export interface NotifyOptions {
id?: string;
title?: string;
kind?: NotificationKind;
duration?: number;
}
const DEFAULT_DURATION = 3000;
const [notifications, setNotifications] = createSignal<Notification[]>([]);
export { notifications };
let nextId = 0;
export function notify(message: string, options: NotifyOptions = {}): string {
const id = options.id ?? `toast-${nextId++}`;
const toast: Notification = { id, title: options.title, message, kind: options.kind ?? "info" };
setNotifications((prev) => [...prev.filter((n) => n.id !== id), toast]);
const duration = options.duration ?? DEFAULT_DURATION;
if (duration > 0) {
setTimeout(() => dismiss(id), duration);
}
return id;
}
export function dismiss(id: string): void {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}
+35
View File
@@ -0,0 +1,35 @@
# ilovetui default config
# Copy to ~/.config/ilovetui/config.yaml and edit to customize.
# Whether components may use Nerd Font glyphs (requires a patched font).
nerd_fonts: true
# Whether the terminal font can render emoji glyphs.
emoji_fonts: true
# Disable spinner animation and other motion across all ilovetui-based TUIs.
reduced_motion: false
# Whether components may register mouse handlers (e.g. click-to-dismiss).
mouse: true
# Border style used by panels across all ilovetui-based TUIs.
border: rounded
colors:
base00: "#110F12" # Background
base01: "#1C1920" # Lighter Background / Status Bars
base02: "#1D1A26" # Selection Background
base03: "#514D63" # Comments / Invisibles
base04: "#8E8AA0" # Dark Foreground / Status Bars
base05: "#C2BED6" # Default Foreground
base06: "#D8D5EA" # Light Foreground
base07: "#EAE7F7" # Light Background
base08: "#E07080" # Variables / Errors / Diff Deleted
base09: "#D49070" # Integers / Constants / Booleans
base0a: "#C4B060" # Classes / Warnings / Search Background
base0b: "#80B880" # Strings / Success / Diff Inserted
base0c: "#70B8C0" # Support / Regex / Escape Characters
base0d: "#9E97F8" # Functions / Methods / Headings / Accent
base0e: "#C090E8" # Keywords / Storage / Diff Changed
base0f: "#D080A0" # Embedded / Misc
+84
View File
@@ -0,0 +1,84 @@
import { join } from "node:path";
import {
type ConfigYAML,
type OpenTUIBorderStyle,
mergeConfig,
normalizeColors,
resolveBorderStyle,
} from "./config.ts";
import { buildPresets } from "./presets.ts";
import { configPath, readYamlFile } from "./yaml.ts";
export interface Theme {
base00: string;
base01: string;
base02: string;
base03: string;
base04: string;
base05: string;
base06: string;
base07: string;
base08: string;
base09: string;
base0a: string;
base0b: string;
base0c: string;
base0d: string;
base0e: string;
base0f: string;
background: string;
subtleBg: string;
selection: string;
subtle: string;
muted: string;
text: string;
primary: string;
success: string;
warning: string;
error: string;
nerdFonts: boolean;
emojiFonts: boolean;
reducedMotion: boolean;
mouse: boolean;
borderStyle: OpenTUIBorderStyle;
}
export { configPath } from "./yaml.ts";
function buildTheme(): Theme {
const defaultConfigPath = join(import.meta.dir, "default.yaml");
const base = readYamlFile<ConfigYAML>(defaultConfigPath) ?? {};
const user = readYamlFile<ConfigYAML>(configPath()) ?? {};
const merged = mergeConfig(base, user);
const colors = normalizeColors(merged.colors);
return {
...colors,
background: colors.base00,
subtleBg: colors.base01,
selection: colors.base02,
subtle: colors.base03,
muted: colors.base04,
text: colors.base05,
primary: colors.base0d,
success: colors.base0b,
warning: colors.base09,
error: colors.base08,
nerdFonts: merged.nerdFonts,
emojiFonts: merged.emojiFonts,
reducedMotion: merged.reducedMotion,
mouse: merged.mouse,
borderStyle: resolveBorderStyle(merged.border),
};
}
export const theme: Theme = buildTheme();
export const presets = buildPresets(theme);
export type { OpenTUIBorderStyle } from "./config.ts";
export type { Presets, SelectPreset, TabSelectPreset, InputPreset, BoxPreset } from "./presets.ts";
+15
View File
@@ -0,0 +1,15 @@
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui";
import { registerCommaBindings } from "@opentui/keymap/addons";
import { KeymapProvider as BaseKeymapProvider } from "@opentui/keymap/solid";
import { useRenderer } from "@opentui/solid";
import { onCleanup, type ParentProps } from "solid-js";
export { useKeymap, useBindings, useKeymapSelector, reactiveMatcherFromSignal } from "@opentui/keymap/solid";
export function KeymapProvider(props: ParentProps) {
const renderer = useRenderer();
const keymap = createDefaultOpenTuiKeymap(renderer);
onCleanup(registerCommaBindings(keymap));
return <BaseKeymapProvider keymap={keymap}>{props.children}</BaseKeymapProvider>;
}
+87
View File
@@ -0,0 +1,87 @@
import type { OpenTUIBorderStyle, Theme } from "./index.ts";
export interface SelectPreset {
backgroundColor: string;
textColor: string;
focusedBackgroundColor: string;
focusedTextColor: string;
selectedBackgroundColor: string;
selectedTextColor: string;
descriptionColor: string;
selectedDescriptionColor: string;
}
export interface TabSelectPreset {
backgroundColor: string;
textColor: string;
focusedBackgroundColor: string;
focusedTextColor: string;
selectedBackgroundColor: string;
selectedTextColor: string;
selectedDescriptionColor: string;
}
export interface InputPreset {
backgroundColor: string;
textColor: string;
focusedBackgroundColor: string;
focusedTextColor: string;
placeholderColor: string;
}
export interface BoxPreset {
border: true;
borderStyle: OpenTUIBorderStyle;
borderColor: string;
focusedBorderColor: string;
backgroundColor: string;
}
export interface Presets {
select: SelectPreset;
tabSelect: TabSelectPreset;
input: InputPreset;
textarea: InputPreset;
box: BoxPreset;
}
export function buildPresets(theme: Theme): Presets {
const select: SelectPreset = {
backgroundColor: theme.background,
textColor: theme.text,
focusedBackgroundColor: theme.subtleBg,
focusedTextColor: theme.text,
selectedBackgroundColor: theme.selection,
selectedTextColor: theme.primary,
descriptionColor: theme.muted,
selectedDescriptionColor: theme.text,
};
const tabSelect: TabSelectPreset = {
backgroundColor: theme.background,
textColor: theme.text,
focusedBackgroundColor: theme.subtleBg,
focusedTextColor: theme.text,
selectedBackgroundColor: theme.selection,
selectedTextColor: theme.primary,
selectedDescriptionColor: theme.text,
};
const input: InputPreset = {
backgroundColor: theme.background,
textColor: theme.text,
focusedBackgroundColor: theme.subtleBg,
focusedTextColor: theme.text,
placeholderColor: theme.muted,
};
const box: BoxPreset = {
border: true,
borderStyle: theme.borderStyle,
borderColor: theme.subtle,
focusedBorderColor: theme.primary,
backgroundColor: theme.background,
};
return { select, tabSelect, input, textarea: input, box };
}
+100
View File
@@ -0,0 +1,100 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { parse as parseYAML } from "yaml";
import { z } from "zod";
import { notify } from "./context/notifications.ts";
export interface ProjectConfigOptions<TDefault, TUser> {
projectName: string;
defaultConfigPath: string;
defaultSchema: z.ZodType<TDefault>;
userSchema: z.ZodType<TUser>;
}
export interface ProjectConfig<TDefault, TUser> {
defaults: TDefault;
user: TUser;
}
function userConfigPath(projectName: string): string {
const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
return join(configHome, projectName, "config.yaml");
}
function readYaml(path: string): unknown {
return parseYAML(readFileSync(path, "utf-8"));
}
function warn(projectName: string, path: string, message: string): void {
const text = `${path}: ${message}`;
console.error(`[${projectName} config] ${text}`);
notify(text, { kind: "warning", duration: 0 });
}
export function loadProjectConfig<TDefault, TUser>(
options: ProjectConfigOptions<TDefault, TUser>,
): ProjectConfig<TDefault, TUser> {
const { projectName, defaultConfigPath, defaultSchema, userSchema } = options;
let rawDefaults: unknown;
try {
rawDefaults = readYaml(defaultConfigPath);
} catch (error) {
throw new Error(`Failed to read ${defaultConfigPath}: ${error instanceof Error ? error.message : error}`);
}
const defaultsResult = defaultSchema.safeParse(rawDefaults);
if (!defaultsResult.success) {
throw new Error(`${defaultConfigPath} is invalid:\n${z.prettifyError(defaultsResult.error)}`);
}
const path = userConfigPath(projectName);
const user = ((): TUser => {
if (!existsSync(path)) return {} as TUser;
let raw: unknown;
try {
raw = readYaml(path);
} catch (error) {
warn(projectName, path, error instanceof Error ? error.message : String(error));
return {} as TUser;
}
const result = userSchema.safeParse(raw ?? {});
if (!result.success) {
warn(projectName, path, z.prettifyError(result.error));
return {} as TUser;
}
return result.data;
})();
return { defaults: defaultsResult.data, user };
}
export function keybindsSchema<TName extends string>(
names: readonly TName[],
required: true,
): z.ZodObject<Record<TName, z.ZodString>>;
export function keybindsSchema<TName extends string>(
names: readonly TName[],
required: false,
): z.ZodObject<Record<TName, z.ZodOptional<z.ZodString>>>;
export function keybindsSchema<TName extends string>(names: readonly TName[], required: boolean) {
const shape = {} as Record<TName, z.ZodTypeAny>;
for (const name of names) {
shape[name] = required ? z.string().min(1) : z.string().min(1).optional();
}
return z.object(shape).strict();
}
export function mergeKeybinds<TName extends string>(
names: readonly TName[],
defaults: Record<TName, string>,
user: Partial<Record<TName, string>> | undefined,
): Record<TName, string> {
const merged = {} as Record<TName, string>;
for (const name of names) {
merged[name] = user?.[name] ?? defaults[name];
}
return merged;
}
+39
View File
@@ -0,0 +1,39 @@
import { extend } from "@opentui/solid";
import {
ASCIIFontRenderable,
BoxRenderable,
InputRenderable,
SelectRenderable,
TabSelectRenderable,
TextareaRenderable,
TextRenderable,
} from "@opentui/core";
import { presets, theme } from "./index.ts";
type AnyCtor = new (...args: any[]) => any;
function withThemeDefaults<T extends AnyCtor>(Ctor: T, defaults: object, after?: (instance: any) => void): T {
class Themed extends Ctor {
constructor(...args: any[]) {
super(...args);
Object.assign(this, defaults);
after?.(this);
}
}
return Themed as T;
}
const { borderStyle, borderColor, focusedBorderColor } = presets.box;
const boxDefaults = { borderStyle, borderColor, focusedBorderColor };
extend({
box: withThemeDefaults(BoxRenderable, boxDefaults, (instance) => {
instance.border = false;
}),
select: withThemeDefaults(SelectRenderable, presets.select),
tab_select: withThemeDefaults(TabSelectRenderable, presets.tabSelect),
input: withThemeDefaults(InputRenderable, presets.input),
textarea: withThemeDefaults(TextareaRenderable, presets.textarea),
text: withThemeDefaults(TextRenderable, { fg: theme.text }),
ascii_font: withThemeDefaults(ASCIIFontRenderable, { color: theme.primary }),
});
+19
View File
@@ -0,0 +1,19 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { parse as parseYAML } from "yaml";
export function configPath(): string {
const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
return join(configDir, "ilovetui", "config.yaml");
}
export function readYamlFile<T>(path: string): T | undefined {
if (!existsSync(path)) return undefined;
try {
return (parseYAML(readFileSync(path, "utf8")) as T) ?? undefined;
} catch (err) {
console.error(`ilovetui: failed to parse ${path}, ignoring it.`, err);
return undefined;
}
}