fixes & update

Signed-off-by: Hadi <[email protected]>
This commit is contained in:
Hadi
2026-10-04 18:46:49 +02:00
parent 238d764b0a
commit 18668f4aea
80 changed files with 1682 additions and 754 deletions
+12 -2
View File
@@ -1,6 +1,7 @@
import { type ColorInput, TextAttributes } from "@opentui/core";
import { type ColorInput, type Renderable, TextAttributes } from "@opentui/core";
import { formatCommandBindings } from "@opentui/keymap/extras";
import { useKeymapSelector } from "@opentui/keymap/solid";
import { createSignal } from "solid-js";
import { helpOpen, toggleHelp } from "../context/help.ts";
import { Modal } from "./Modal.tsx";
@@ -11,6 +12,8 @@ export interface HelpModalProps {
backgroundColor?: ColorInput;
backdropColor?: ColorInput;
width?: number;
/** See ModalProps.dismissKey. */
dismissKey?: string | false;
}
interface HelpEntry {
@@ -20,8 +23,13 @@ interface HelpEntry {
}
export function HelpModal(props: HelpModalProps = {}) {
// The modal takes focus when it opens, which would make its own "close"
// the only active command. List what was active where focus was *before*
// it opened instead.
const [scope, setScope] = createSignal<Renderable | null>(null);
const entries = useKeymapSelector((keymap): HelpEntry[] =>
keymap.getCommandEntries({ visibility: "active" }).map((entry) => ({
keymap.getCommandEntries({ visibility: "active", focused: scope() }).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,
@@ -32,6 +40,8 @@ export function HelpModal(props: HelpModalProps = {}) {
<Modal
open={helpOpen()}
onDismiss={toggleHelp}
onOpen={(previous) => setScope(() => previous)}
dismissKey={props.dismissKey}
accentColor={props.accentColor}
backgroundColor={props.backgroundColor}
backdropColor={props.backdropColor}
+23
View File
@@ -0,0 +1,23 @@
import type { ColorInput } from "@opentui/core";
import { Show, type JSX } from "solid-js";
import { theme } from "../index.ts";
export interface KeyValueProps {
label: string;
/** Plain value; ignored when children are given. */
value?: string | number;
valueColor?: ColorInput;
children?: JSX.Element;
}
/** A "Label ........ value" line, as used in details panels and modals. */
export function KeyValue(props: KeyValueProps) {
return (
<box flexShrink={0} flexDirection="row" justifyContent="space-between">
<text fg={theme.muted}>{props.label}</text>
<Show when={props.children} fallback={<text fg={props.valueColor}>{props.value ?? ""}</text>}>
{props.children}
</Show>
</box>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { type ColorInput, TextAttributes } from "@opentui/core";
import { Show, type JSX } from "solid-js";
import { presets, theme } from "../index.ts";
/** Returns the selected-row text color when the row is selected, `fallback` otherwise. */
export type RowTone = (fallback?: ColorInput) => ColorInput | undefined;
export interface ListRowProps {
id?: string;
label: string;
selected: boolean;
bold?: boolean;
onClick?: () => void;
/** Right-aligned content; use `tone` for its colors so it follows the selection. */
right?: (tone: RowTone) => JSX.Element;
/** Extra lines under the main one. */
below?: (tone: RowTone) => JSX.Element;
}
/** One row of a keyboard-navigable list: truncated label on the left, details on the right. */
export function ListRow(props: ListRowProps) {
const tone: RowTone = (fallback) => (props.selected ? presets.select.selectedTextColor : fallback);
return (
<box
id={props.id}
flexShrink={0}
flexDirection="column"
paddingLeft={1}
paddingRight={1}
backgroundColor={props.selected ? presets.select.selectedBackgroundColor : "transparent"}
onMouseDown={theme.mouse && props.onClick ? () => props.onClick?.() : undefined}
>
<box flexDirection="row" justifyContent="space-between" alignItems="center" height={1}>
<box flexGrow={1} flexShrink={1} minWidth={0} marginRight={1} overflow="hidden">
<text fg={tone()} attributes={props.bold ? TextAttributes.BOLD : undefined} wrapMode="none">
{props.label}
</text>
</box>
<Show when={props.right}>
<box flexShrink={0} flexDirection="row" alignItems="center">
{props.right?.(tone)}
</box>
</Show>
</box>
{props.below?.(tone)}
</box>
);
}
+100 -46
View File
@@ -1,11 +1,26 @@
import { type ColorInput, type MouseEvent, RGBA } from "@opentui/core";
import { Portal, useTerminalDimensions } from "@opentui/solid";
import { Show, type ParentProps } from "solid-js";
import { type BoxRenderable, type ColorInput, type KeyEvent, type MouseEvent, type Renderable, RGBA } from "@opentui/core";
import type { Binding, Command } from "@opentui/keymap";
import { useBindings } from "@opentui/keymap/solid";
import { Portal, useRenderer, useTerminalDimensions } from "@opentui/solid";
import { createSignal, onCleanup, onMount, Show, type ParentProps } from "solid-js";
import { theme } from "../index.ts";
export type ModalCommand = Command<Renderable, KeyEvent>;
export type ModalBinding = Binding<Renderable, KeyEvent>;
export interface ModalProps extends ParentProps {
open: boolean;
onDismiss?: () => void;
/**
* Key that calls onDismiss while the modal has focus (default "escape").
* `false` disables it. Needs a KeymapProvider unless disabled.
*/
dismissKey?: string | false;
/** Extra keymap commands/bindings, active only while the modal has focus. */
commands?: ModalCommand[];
bindings?: ModalBinding[];
/** Called on open with whatever had focus before the modal took it. */
onOpen?: (previousFocus: Renderable | null) => void;
accentColor?: ColorInput;
backgroundColor?: ColorInput;
backdropColor?: ColorInput;
@@ -15,57 +30,96 @@ export interface ModalProps extends ParentProps {
}
export function Modal(props: ModalProps) {
return (
<Portal>
<Show when={props.open}>
<ModalPanel {...props} />
</Show>
</Portal>
);
}
// Mounted only while open: takes native focus on mount and hands it back to
// whatever had it on unmount, so keymap layers scoped to the page underneath
// go quiet while the modal is up and come back on their own afterwards.
function ModalPanel(props: ModalProps) {
const renderer = useRenderer();
const dimensions = useTerminalDimensions();
const [panel, setPanel] = createSignal<BoxRenderable>();
const screenCap = () => Math.max(5, dimensions().height - 4);
const maxPanelHeight = () => Math.min(props.maxHeight ?? screenCap(), screenCap());
const maxContentHeight = () => Math.max(1, maxPanelHeight() - 4);
const maxPanelWidth = () => Math.max(10, dimensions().width - 4);
const panelWidth = () => Math.min(props.width ?? 50, maxPanelWidth());
let previousFocus: Renderable | null = null;
onMount(() => {
previousFocus = renderer.currentFocusedRenderable;
props.onOpen?.(previousFocus);
panel()?.focus();
});
onCleanup(() => {
if (previousFocus && !previousFocus.isDestroyed) previousFocus.focus();
});
const dismissKey = props.dismissKey ?? "escape";
if (dismissKey !== false || props.commands || props.bindings) {
useBindings(() => ({
target: panel,
commands: [
...(dismissKey !== false
? [{ name: "modal_dismiss", desc: "close", shortHelp: true, run: () => props.onDismiss?.() }]
: []),
...(props.commands ?? []),
],
bindings: [...(dismissKey !== false ? [{ key: dismissKey, cmd: "modal_dismiss" }] : []), ...(props.bindings ?? [])],
}));
}
return (
<Portal>
<Show when={props.open}>
<box
width={dimensions().width}
height={dimensions().height}
position="absolute"
top={0}
left={0}
alignItems="center"
justifyContent="center"
backgroundColor={props.backdropColor ?? RGBA.fromInts(0, 0, 0, 150)}
zIndex={2000}
onMouseDown={theme.mouse ? props.onDismiss : undefined}
<box
width={dimensions().width}
height={dimensions().height}
position="absolute"
top={0}
left={0}
alignItems="center"
justifyContent="center"
backgroundColor={props.backdropColor ?? RGBA.fromInts(0, 0, 0, 150)}
zIndex={2000}
onMouseDown={theme.mouse ? props.onDismiss : undefined}
>
<box
ref={setPanel}
focusable
width={panelWidth()}
maxHeight={maxPanelHeight()}
border
borderStyle={theme.borderStyle}
borderColor={props.accentColor ?? theme.primary}
focusedBorderColor={props.accentColor ?? theme.primary}
backgroundColor={props.backgroundColor ?? theme.background}
flexDirection="column"
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
onMouseDown={theme.mouse ? (event: MouseEvent) => event.stopPropagation() : undefined}
>
<Show
when={props.scrollable}
fallback={
<box flexDirection="column" maxHeight={maxContentHeight()} overflow="hidden">
{props.children}
</box>
}
>
<box
width={panelWidth()}
maxHeight={maxPanelHeight()}
border
borderStyle={theme.borderStyle}
borderColor={props.accentColor ?? theme.primary}
backgroundColor={props.backgroundColor ?? theme.background}
flexDirection="column"
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
onMouseDown={theme.mouse ? (event: MouseEvent) => event.stopPropagation() : undefined}
>
<Show
when={props.scrollable}
fallback={
<box flexDirection="column" maxHeight={maxContentHeight()} overflow="hidden">
{props.children}
</box>
}
>
<scrollbox maxHeight={maxContentHeight()} flexShrink={1} scrollY scrollX={false}>
{props.children}
</scrollbox>
</Show>
</box>
</box>
</Show>
</Portal>
<scrollbox maxHeight={maxContentHeight()} flexShrink={1} scrollY scrollX={false} focusable={false}>
{props.children}
</scrollbox>
</Show>
</box>
</box>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { BoxRenderable } from "@opentui/core";
import type { BoxProps } from "@opentui/solid";
import { type Accessor, createContext, createSignal, splitProps, useContext } from "solid-js";
import { useFocusWithin } from "../context/focus.ts";
export interface PaneHandle {
/** The pane's root renderable — use it as a keymap layer `target`. */
target: Accessor<BoxRenderable | undefined>;
/** True while native focus is anywhere inside the pane. */
focused: Accessor<boolean>;
focus(): void;
}
const PaneContext = createContext<PaneHandle>();
export interface PaneProps extends Omit<BoxProps, "ref"> {
/** Receives the pane's handle, e.g. to focus it or target it from outside. */
handle?: (handle: PaneHandle) => void;
}
/**
* A focus region. Wrap each independently focusable area (sidebar, content,
* …) in a Pane; anything inside can call usePane() to scope keymap layers to
* it (`useBindings(() => ({ target: pane.target, … }))`) and to style itself
* from `pane.focused()`. Pass `focusable` when the pane itself should be the
* focus target (rather than e.g. a <select> inside it).
*/
export function Pane(props: PaneProps) {
const [local, rest] = splitProps(props, ["handle", "children"]);
const [el, setEl] = createSignal<BoxRenderable>();
const focused = useFocusWithin(el);
const handle: PaneHandle = { target: el, focused, focus: () => el()?.focus() };
local.handle?.(handle);
return (
<PaneContext.Provider value={handle}>
<box {...rest} ref={setEl}>
{local.children}
</box>
</PaneContext.Provider>
);
}
export function usePane(): PaneHandle {
const pane = useContext(PaneContext);
if (!pane) throw new Error("usePane() must be used inside a <Pane>");
return pane;
}
+40
View File
@@ -0,0 +1,40 @@
import { TextAttributes } from "@opentui/core";
import { Show } from "solid-js";
import { theme } from "../index.ts";
import { Spinner } from "./Spinner.tsx";
export interface SectionHeaderProps {
title?: string;
loading?: boolean;
/** Shown next to the spinner while loading (default "Loading…"). */
loadingLabel?: string;
/** Shown on the right when not loading, e.g. "r refresh". */
hint?: string;
}
// Lives in its own entry point (ilovetui/components/section-header) because
// it pulls in the optional opentui-spinner peer dependency.
export function SectionHeader(props: SectionHeaderProps) {
return (
<box flexShrink={0} flexDirection="row" justifyContent="space-between" alignItems="center" height={1}>
<box flexGrow={1} flexShrink={1} minWidth={0} marginRight={1} overflow="hidden">
<text fg={theme.muted} attributes={TextAttributes.BOLD} wrapMode="none">
{props.title ?? ""}
</text>
</box>
<box flexShrink={0}>
<Show
when={!props.loading}
fallback={
<box flexDirection="row" alignItems="center">
<Spinner />
<text fg={theme.muted}> {props.loadingLabel ?? "Loading…"}</text>
</box>
}
>
<text fg={theme.muted}>{props.hint ?? ""}</text>
</Show>
</box>
</box>
);
}
+9 -1
View File
@@ -1,5 +1,5 @@
import { type ColorInput, type MouseEvent, type SelectOption, type SelectRenderable, TextAttributes } from "@opentui/core";
import { Show } from "solid-js";
import { createEffect, Show } from "solid-js";
import { presets, theme } from "../index.ts";
export interface SidebarHandle {
@@ -27,6 +27,8 @@ function itemIndexAtScreenY(el: SelectRenderable, screenY: number): number | nul
export interface SidebarProps {
title?: string;
items: SelectOption[];
/** Value of the item to keep highlighted (e.g. the current route), if controlled. */
value?: string;
width?: number;
focused?: boolean;
onSelect: (option: SelectOption) => void;
@@ -83,6 +85,12 @@ export function Sidebar(props: SidebarProps) {
else if (event.scroll.direction === "up") select.moveUp(steps);
};
// A user effect, so it runs after the `options` prop has been applied.
createEffect(() => {
const index = props.items.findIndex((item) => item.value === props.value);
if (select && index !== -1 && select.getSelectedIndex() !== index) select.setSelectedIndex(index);
});
return (
<box
width={props.width ?? 24}
+28
View File
@@ -0,0 +1,28 @@
import type { ParentProps } from "solid-js";
import { theme } from "../index.ts";
import { Box } from "./Box.tsx";
import { type TabItem, Tabs } from "./Tabs.tsx";
export interface TabbedPanelProps extends ParentProps {
items: TabItem[];
value: string;
onChange: (value: string) => void;
focused?: boolean;
}
/** <Tabs/> on top of a bordered panel whose top edge is drawn by the tabs. */
export function TabbedPanel(props: TabbedPanelProps) {
return (
<box flexDirection="column" flexGrow={1}>
<Tabs items={props.items} value={props.value} onChange={props.onChange} focused={props.focused} />
<Box
flexGrow={1}
border={["left", "right", "bottom"]}
borderColor={props.focused ? theme.primary : theme.muted}
flexDirection="column"
>
{props.children}
</Box>
</box>
);
}
+5 -1
View File
@@ -1,10 +1,14 @@
export { Badge, type BadgeProps } from "./Badge.tsx";
export { Box, type BoxProps } from "./Box.tsx";
export { KeyValue, type KeyValueProps } from "./KeyValue.tsx";
export { ListRow, type ListRowProps, type RowTone } from "./ListRow.tsx";
export { MinSizeGuard, type MinSizeGuardProps } from "./MinSizeGuard.tsx";
export { Modal, type ModalProps } from "./Modal.tsx";
export { Modal, type ModalBinding, type ModalCommand, type ModalProps } from "./Modal.tsx";
export { NotificationHost, type NotificationHostProps } from "./NotificationHost.tsx";
export { Pane, usePane, type PaneHandle, type PaneProps } from "./Pane.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 { TabbedPanel, type TabbedPanelProps } from "./TabbedPanel.tsx";
export { stepTabValue, Tabs, type TabItem, type TabsProps } from "./Tabs.tsx";
export { Toggle, type ToggleProps } from "./Toggle.tsx";
+48 -70
View File
@@ -1,30 +1,4 @@
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;
}
import { z } from "zod";
export const COLOR_KEYS = [
"base00",
@@ -45,39 +19,64 @@ export const COLOR_KEYS = [
"base0f",
] as const;
export type ResolvedColors = Record<(typeof COLOR_KEYS)[number], string>;
export type ColorKey = (typeof COLOR_KEYS)[number];
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 const OPENTUI_BORDER_STYLES = ["single", "double", "rounded", "heavy"] as const;
export type OpenTUIBorderStyle = (typeof OPENTUI_BORDER_STYLES)[number];
const hexColor = z.string().regex(/^#?[0-9a-fA-F]{6}$/, 'expected a hex color like "#1e1e2e"');
const colorsShape = {} as Record<ColorKey, z.ZodOptional<z.ZodString>>;
for (const key of COLOR_KEYS) colorsShape[key] = hexColor.optional();
// Not .strict(): ~/.config/ilovetui/config.yaml is shared by every
// ilovetui-based TUI, and some of them read extra keys of their own from it
// (e.g. `layout_border`). Unknown keys are stripped, known ones validated.
export const ThemeConfigSchema = z.object({
nerd_fonts: z.boolean().optional(),
emoji_fonts: z.boolean().optional(),
reduced_motion: z.boolean().optional(),
mouse: z.boolean().optional(),
border: z.enum(OPENTUI_BORDER_STYLES).optional(),
colors: z.object(colorsShape).optional(),
});
export type ConfigYAML = z.infer<typeof ThemeConfigSchema>;
export type ColorsYAML = NonNullable<ConfigYAML["colors"]>;
export type ResolvedColors = Record<ColorKey, string>;
export interface ResolvedConfig {
nerdFonts: boolean;
emojiFonts: boolean;
reducedMotion: boolean;
mouse: boolean;
border: string;
border: OpenTUIBorderStyle;
colors: ResolvedColors;
}
export function mergeConfig(
base: ConfigYAML,
user: ConfigYAML,
): ResolvedConfig {
// Later layers win, key by key (colors included).
export function layerConfigs(layers: ConfigYAML[]): ConfigYAML {
const out: ConfigYAML = {};
for (const layer of layers) {
const { colors, ...rest } = layer;
Object.assign(out, rest);
if (colors) out.colors = { ...out.colors, ...colors };
}
return out;
}
export function resolveConfig(config: ConfigYAML): ResolvedConfig {
const colors = {} as ResolvedColors;
for (const key of COLOR_KEYS) colors[key] = normalizeColor(config.colors?.[key] ?? "");
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 ?? {}),
nerdFonts: config.nerd_fonts ?? false,
emojiFonts: config.emoji_fonts ?? false,
reducedMotion: config.reduced_motion ?? false,
mouse: config.mouse ?? true,
border: config.border ?? "rounded",
colors,
};
}
@@ -86,24 +85,3 @@ export function normalizeColor(hex: string): string {
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";
}
+30
View File
@@ -0,0 +1,30 @@
import { CliRenderEvents, type Renderable } from "@opentui/core";
import { useRenderer } from "@opentui/solid";
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js";
export function isInside(node: Renderable | null, root: Renderable | null): boolean {
if (!root) return false;
for (let current = node; current; current = current.parent) {
if (current === root) return true;
}
return false;
}
/**
* True while the renderer's native focus is `target` or one of its
* descendants. Mouse clicks move native focus on their own (the renderer
* focuses the nearest focusable ancestor of what was clicked), so this
* follows both keyboard and mouse focus changes.
*/
export function useFocusWithin(target: Accessor<Renderable | null | undefined>): Accessor<boolean> {
const renderer = useRenderer();
const [focused, setFocused] = createSignal(false);
const sync = () => setFocused(isInside(renderer.currentFocusedRenderable, target() ?? null));
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, sync);
onCleanup(() => renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, sync));
createEffect(sync);
return focused;
}
+1
View File
@@ -1,3 +1,4 @@
export { createSimpleContext } from "./helper.tsx";
export { notify, dismiss, notifications, type Notification, type NotificationKind, type NotifyOptions } from "./notifications.ts";
export { helpOpen, toggleHelp } from "./help.ts";
export { isInside, useFocusWithin } from "./focus.ts";
+67 -19
View File
@@ -1,13 +1,17 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { parse as parseYAML } from "yaml";
import { z } from "zod";
import {
type ConfigYAML,
type OpenTUIBorderStyle,
mergeConfig,
normalizeColors,
resolveBorderStyle,
layerConfigs,
resolveConfig,
ThemeConfigSchema,
} from "./config.ts";
import { buildPresets } from "./presets.ts";
import { configPath, readYamlFile } from "./yaml.ts";
import { notify } from "./context/notifications.ts";
import { buildPresets, type Presets } from "./presets.ts";
import { configPath } from "./yaml.ts";
export interface Theme {
base00: string;
@@ -47,12 +51,39 @@ export interface Theme {
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);
function reportThemeError(path: string, message: string): void {
const text = `${path}: ${message}`;
console.error(`[ilovetui theme] ${text}`);
notify(text, { kind: "warning", duration: 0 });
}
// A broken file is reported (as a sticky warning toast, since console output
// is hidden behind the TUI) and skipped as a whole, falling back to the
// layers below it.
function readThemeFile(path: string): ConfigYAML {
if (!existsSync(path)) return {};
let raw: unknown;
try {
raw = parseYAML(readFileSync(path, "utf8"));
} catch (error) {
reportThemeError(path, error instanceof Error ? error.message : String(error));
return {};
}
const result = ThemeConfigSchema.safeParse(raw ?? {});
if (!result.success) {
reportThemeError(path, z.prettifyError(result.error));
return {};
}
return result.data;
}
// Read once: the shipped defaults, then the user's shared
// ~/.config/ilovetui/config.yaml on top.
const fileLayers: ConfigYAML[] = [readThemeFile(join(import.meta.dir, "default.yaml")), readThemeFile(configPath())];
function buildTheme(overrides: ConfigYAML): Theme {
const resolved = resolveConfig(layerConfigs([...fileLayers, overrides]));
const colors = resolved.colors;
return {
...colors,
@@ -68,17 +99,34 @@ function buildTheme(): Theme {
warning: colors.base09,
error: colors.base08,
nerdFonts: merged.nerdFonts,
emojiFonts: merged.emojiFonts,
reducedMotion: merged.reducedMotion,
mouse: merged.mouse,
borderStyle: resolveBorderStyle(merged.border),
nerdFonts: resolved.nerdFonts,
emojiFonts: resolved.emojiFonts,
reducedMotion: resolved.reducedMotion,
mouse: resolved.mouse,
borderStyle: resolved.border,
};
}
export const theme: Theme = buildTheme();
export const theme: Theme = buildTheme({});
export const presets = buildPresets(theme);
export const presets: Presets = buildPresets(theme);
export type { OpenTUIBorderStyle } from "./config.ts";
/**
* Layers app-specific overrides (typically a `theme:` section of the app's own
* config, validated with `ThemeConfigSchema`) on top of the shared ilovetui
* config. `theme` and `presets` are updated in place, so call this once at
* startup, before the first render.
*/
export function configureTheme(overrides: ConfigYAML = {}): void {
Object.assign(theme, buildTheme(overrides));
const next = buildPresets(theme);
for (const key of Object.keys(next) as (keyof Presets)[]) Object.assign(presets[key], next[key]);
}
/** A Nerd Font glyph followed by a space, or "" when `nerd_fonts` is off. */
export function icon(codepoint: string): string {
return theme.nerdFonts ? `${codepoint} ` : "";
}
export { ThemeConfigSchema, type ConfigYAML as ThemeConfig, type OpenTUIBorderStyle } from "./config.ts";
export type { Presets, SelectPreset, TabSelectPreset, InputPreset, BoxPreset } from "./presets.ts";
+60
View File
@@ -71,6 +71,66 @@ export function loadProjectConfig<TDefault, TUser>(
return { defaults: defaultsResult.data, user };
}
export interface MergedProjectConfigOptions<T> {
projectName: string;
defaultConfigPath: string;
/** Schema for the complete config; the defaults file must satisfy it on its own. */
schema: z.ZodType<T>;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
// Objects merge key by key; anything else (scalars, arrays) from `over` replaces `base`.
function deepMerge(base: unknown, over: unknown): unknown {
if (!isPlainObject(base) || !isPlainObject(over)) return over === undefined ? base : over;
const out: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(over)) out[key] = deepMerge(base[key], value);
return out;
}
/**
* Single-schema alternative to loadProjectConfig(): the user's
* ~/.config/<projectName>/config.yaml is deep-merged over the defaults file
* and the result is validated once against `schema`, so the returned value is
* fully typed and complete — no per-field `user.x ?? defaults.x` merging.
* An unreadable or invalid user file is reported (warning toast + stderr) and
* ignored as a whole; an invalid defaults file throws.
*/
export function loadMergedProjectConfig<T>(options: MergedProjectConfigOptions<T>): T {
const { projectName, defaultConfigPath, schema } = options;
let rawDefaults: unknown;
try {
rawDefaults = readYaml(defaultConfigPath);
} catch (error) {
throw new Error(`Failed to read ${defaultConfigPath}: ${error instanceof Error ? error.message : error}`);
}
const defaults = schema.safeParse(rawDefaults);
if (!defaults.success) {
throw new Error(`${defaultConfigPath} is invalid:\n${z.prettifyError(defaults.error)}`);
}
const path = userConfigPath(projectName);
if (!existsSync(path)) return defaults.data;
let rawUser: unknown;
try {
rawUser = readYaml(path);
} catch (error) {
warn(projectName, path, error instanceof Error ? error.message : String(error));
return defaults.data;
}
const merged = schema.safeParse(deepMerge(rawDefaults, rawUser ?? {}));
if (!merged.success) {
warn(projectName, path, z.prettifyError(merged.error));
return defaults.data;
}
return merged.data;
}
export function keybindsSchema<TName extends string>(
names: readonly TName[],
required: true,
+15 -11
View File
@@ -21,31 +21,35 @@ type AnyCtor = new (...args: any[]) => any;
function withThemeDefaults<T extends AnyCtor>(
Ctor: T,
defaults: object,
defaults: () => object,
after?: (instance: any) => void,
): T {
class Themed extends Ctor {
constructor(...args: any[]) {
super(...args);
Object.assign(this, defaults);
Object.assign(this, defaults());
after?.(this);
}
}
return Themed as T;
}
const { borderStyle, borderColor, focusedBorderColor } = presets.box;
const boxDefaults = { borderStyle, borderColor, focusedBorderColor };
// Defaults are read per instance (not captured at import) so that
// configureTheme() overrides applied at startup are picked up.
const boxDefaults = () => {
const { borderStyle, borderColor, focusedBorderColor } = presets.box;
return { 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),
slider: withThemeDefaults(SliderRenderable, presets.slider),
text: withThemeDefaults(TextRenderable, { fg: theme.text }),
ascii_font: withThemeDefaults(ASCIIFontRenderable, { color: theme.primary }),
select: withThemeDefaults(SelectRenderable, () => presets.select),
tab_select: withThemeDefaults(TabSelectRenderable, () => presets.tabSelect),
input: withThemeDefaults(InputRenderable, () => presets.input),
textarea: withThemeDefaults(TextareaRenderable, () => presets.textarea),
slider: withThemeDefaults(SliderRenderable, () => presets.slider),
text: withThemeDefaults(TextRenderable, () => ({ fg: theme.text })),
ascii_font: withThemeDefaults(ASCIIFontRenderable, () => ({ color: theme.primary })),
});
-12
View File
@@ -1,19 +1,7 @@
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;
}
}