mirror of
https://github.com/anotherhadi/ilovetui.git
synced 2026-10-05 10:58:26 +02:00
rewrite bubbletea -> opentui
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { HelpBar, type HelpBarProps } from "./HelpBar.tsx";
|
||||
export { HelpModal, type HelpModalProps } from "./HelpModal.tsx";
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
const [helpOpen, setHelpOpen] = createSignal(false);
|
||||
export { helpOpen };
|
||||
|
||||
export function toggleHelp(): void {
|
||||
setHelpOpen((open) => !open);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
@@ -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";
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user