- theme.ts mixed symbol presets, JSON schema, color math, the Theme class, loading, global state, appearance handling and TUI adapters in 3171 lines. - Symbols, schema, color, theme-class, loader and tui-adapters are now siblings; theme.ts keeps global state, the watcher, appearance handling and HTML export at 745 lines, with all 44 exports intact. - Left appearance and export-colors in place: both read private mutable auto-theme state, so extracting them would have required new exported internals or DI rather than a straight move.
179 lines
6.0 KiB
TypeScript
179 lines
6.0 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { type } from "@oh-my-pi/omptype";
|
|
import { adjustHsv, getCustomThemesDir, isEnoent } from "@oh-my-pi/pi-utils";
|
|
import { detectColorMode, resolveThemeColors } from "./color";
|
|
import darkThemeJson from "./dark.json" with { type: "json" };
|
|
import { defaultThemes } from "./defaults";
|
|
import lightThemeJson from "./light.json" with { type: "json" };
|
|
import { type ColorMode, type ThemeBg, type ThemeColor, type ThemeJson, themeJsonSchema } from "./schema";
|
|
import { normalizeSpinnerFramesOverride, type SymbolPreset } from "./symbols";
|
|
import { Theme } from "./theme-class";
|
|
|
|
// ============================================================================
|
|
// Theme Loading
|
|
// ============================================================================
|
|
|
|
const BUILTIN_THEMES: Record<string, ThemeJson> = {
|
|
dark: darkThemeJson as ThemeJson,
|
|
light: lightThemeJson as ThemeJson,
|
|
...(defaultThemes as Record<string, ThemeJson>),
|
|
};
|
|
|
|
export function getBuiltinThemes(): Record<string, ThemeJson> {
|
|
return BUILTIN_THEMES;
|
|
}
|
|
|
|
export async function getAvailableThemes(): Promise<string[]> {
|
|
const themes = new Set<string>(Object.keys(getBuiltinThemes()));
|
|
const customThemesDir = getCustomThemesDir();
|
|
try {
|
|
const files = await fs.promises.readdir(customThemesDir);
|
|
for (const file of files) {
|
|
if (file.endsWith(".json")) {
|
|
themes.add(file.slice(0, -5));
|
|
}
|
|
}
|
|
} catch {
|
|
// Directory doesn't exist or isn't readable
|
|
}
|
|
return Array.from(themes).sort();
|
|
}
|
|
|
|
export interface ThemeInfo {
|
|
name: string;
|
|
path: string | undefined;
|
|
}
|
|
|
|
export async function getAvailableThemesWithPaths(): Promise<ThemeInfo[]> {
|
|
const result: ThemeInfo[] = [];
|
|
|
|
// Built-in themes (embedded, no file path)
|
|
for (const name of Object.keys(getBuiltinThemes())) {
|
|
result.push({ name, path: undefined });
|
|
}
|
|
|
|
// Custom themes
|
|
const customThemesDir = getCustomThemesDir();
|
|
try {
|
|
const files = await fs.promises.readdir(customThemesDir);
|
|
for (const file of files) {
|
|
if (file.endsWith(".json")) {
|
|
const name = file.slice(0, -5);
|
|
if (!result.some(themeInfo => themeInfo.name === name)) {
|
|
result.push({ name, path: path.join(customThemesDir, file) });
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Directory doesn't exist or isn't readable
|
|
}
|
|
|
|
return result.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
export async function loadThemeJson(name: string): Promise<ThemeJson> {
|
|
const builtinThemes = getBuiltinThemes();
|
|
if (name in builtinThemes) {
|
|
return builtinThemes[name];
|
|
}
|
|
const customThemesDir = getCustomThemesDir();
|
|
const themePath = path.join(customThemesDir, `${name}.json`);
|
|
let content: string;
|
|
try {
|
|
content = await Bun.file(themePath).text();
|
|
} catch (err) {
|
|
if (isEnoent(err)) throw new Error(`Theme not found: ${name}`);
|
|
throw err;
|
|
}
|
|
let json: unknown;
|
|
try {
|
|
json = JSON.parse(content);
|
|
} catch (error) {
|
|
throw new Error(`Failed to parse theme ${name}: ${error}`);
|
|
}
|
|
let parsed: ThemeJson;
|
|
try {
|
|
parsed = themeJsonSchema(json) as ThemeJson;
|
|
if (parsed instanceof type.errors) {
|
|
throw new Error(parsed.summary);
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
// Extract color key information if available
|
|
const missingColorMatch = errorMessage.match(/missing keys: (.+)/i);
|
|
const missingColors: string[] = missingColorMatch ? missingColorMatch[1].split(",").map(s => s.trim()) : [];
|
|
|
|
let fullErrorMessage = `Invalid theme "${name}":\n`;
|
|
if (missingColors.length > 0) {
|
|
fullErrorMessage += `\nMissing required color tokens:\n`;
|
|
fullErrorMessage += missingColors.map(c => ` - ${c}`).join("\n");
|
|
fullErrorMessage += `\n\nPlease add these colors to your theme's "colors" object.`;
|
|
fullErrorMessage += `\nSee the built-in themes (dark.json, light.json) for reference values.`;
|
|
}
|
|
fullErrorMessage += `\n\nValidation error:\n - ${errorMessage}`;
|
|
|
|
throw new Error(fullErrorMessage);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export interface CreateThemeOptions {
|
|
mode?: ColorMode;
|
|
symbolPresetOverride?: SymbolPreset;
|
|
colorBlindMode?: boolean;
|
|
}
|
|
|
|
/** HSV adjustment to shift green toward blue for colorblind mode (red-green colorblindness) */
|
|
const COLORBLIND_ADJUSTMENT = { h: 60, s: 0.71 };
|
|
|
|
export function createTheme(themeJson: ThemeJson, options: CreateThemeOptions = {}): Theme {
|
|
const { mode, symbolPresetOverride, colorBlindMode } = options;
|
|
const colorMode = mode ?? detectColorMode();
|
|
const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
|
|
|
|
if (colorBlindMode) {
|
|
const added = resolvedColors.toolDiffAdded;
|
|
if (typeof added === "string" && added.startsWith("#")) {
|
|
resolvedColors.toolDiffAdded = adjustHsv(added, COLORBLIND_ADJUSTMENT);
|
|
}
|
|
}
|
|
|
|
const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
|
|
const bgColors: Record<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;
|
|
const bgColorKeys: Set<string> = new Set([
|
|
"selectedBg",
|
|
"userMessageBg",
|
|
"customMessageBg",
|
|
"toolPendingBg",
|
|
"toolSuccessBg",
|
|
"toolErrorBg",
|
|
"statusLineBg",
|
|
]);
|
|
for (const [key, value] of Object.entries(resolvedColors)) {
|
|
if (bgColorKeys.has(key)) {
|
|
bgColors[key as ThemeBg] = value;
|
|
} else {
|
|
fgColors[key as ThemeColor] = value;
|
|
}
|
|
}
|
|
// Extract symbol configuration - settings override takes precedence over theme
|
|
const symbolPreset: SymbolPreset = symbolPresetOverride ?? themeJson.symbols?.preset ?? "unicode";
|
|
const symbolOverrides = themeJson.symbols?.overrides ?? {};
|
|
const spinnerFramesOverrides = normalizeSpinnerFramesOverride(themeJson.symbols?.spinnerFrames);
|
|
return new Theme(fgColors, bgColors, colorMode, symbolPreset, symbolOverrides, spinnerFramesOverrides);
|
|
}
|
|
|
|
export async function loadTheme(name: string, options: CreateThemeOptions = {}): Promise<Theme> {
|
|
const themeJson = await loadThemeJson(name);
|
|
return createTheme(themeJson, options);
|
|
}
|
|
|
|
export async function getThemeByName(name: string): Promise<Theme | undefined> {
|
|
try {
|
|
return await loadTheme(name);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|