Four editable colors (background/foreground/cursor/selection) via a new ColorPanel modal with built-in presets and live preview. Global default persists to localStorage and syncs across windows; per-pane overrides ride on LeafNode.colorOverride in the workspace tree. Titlebar 🎨 button edits the global theme; per-pane 🎨 chip overrides a single pane. Subsumes the prior uncommitted softened-foreground tweak into lib/theme.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
160 lines
5.9 KiB
TypeScript
160 lines
5.9 KiB
TypeScript
//! Terminal colour theming.
|
|
//!
|
|
//! tiletopia ships one hard-coded dark palette historically baked into
|
|
//! XtermPane. This module turns that into a customisable model:
|
|
//!
|
|
//! - a GLOBAL default theme (persisted to localStorage, app-wide), and
|
|
//! - optional PER-PANE overrides (stored on the LeafNode, persisted with the
|
|
//! workspace tree).
|
|
//!
|
|
//! Only four colours are user-editable — background, foreground, cursor, and
|
|
//! selection — the ones that actually move the needle on readability. The
|
|
//! rest of xterm's ITheme (the 16-colour ANSI palette, etc.) stays fixed in
|
|
//! {@link BASE_XTERM_THEME}: notably `white`/`brightWhite` keep the softened
|
|
//! values that tame the Claude TUI's emphasis slots (see XtermPane history).
|
|
|
|
import type { ITheme } from "@xterm/xterm";
|
|
|
|
/** The four user-editable colours. All optional: an undefined field on a
|
|
* per-pane override falls through to the global default; an undefined field
|
|
* on the global default falls through to {@link DEFAULT_PANE_COLORS}. */
|
|
export interface PaneColors {
|
|
/** Terminal background. */
|
|
background?: string;
|
|
/** Default text colour. */
|
|
foreground?: string;
|
|
/** Cursor block colour. */
|
|
cursor?: string;
|
|
/** Selection highlight background. */
|
|
selection?: string;
|
|
}
|
|
|
|
/** Fixed slice of the xterm theme that is NOT user-editable. The softened
|
|
* white/brightWhite values date back to the original hard-coded theme — they
|
|
* keep the Claude TUI's emphasis text from hitting glaring pure white. */
|
|
const BASE_XTERM_THEME: ITheme = {
|
|
white: "#c5c8c6",
|
|
brightWhite: "#e0e0e0",
|
|
};
|
|
|
|
/** Ground-truth defaults — the historical tiletopia palette. Every editable
|
|
* field resolves to one of these when nothing overrides it. Also exposed as
|
|
* the first preset ("Tiletopia Dark"). */
|
|
export const DEFAULT_PANE_COLORS: Required<PaneColors> = {
|
|
background: "#0c0c0c",
|
|
foreground: "#c5c8c6",
|
|
cursor: "#ffffff",
|
|
selection: "#3a3a3a",
|
|
};
|
|
|
|
/** A named, ready-to-apply colour set shown as a one-click starting point in
|
|
* the colour panel. */
|
|
export interface ColorPreset {
|
|
name: string;
|
|
colors: Required<PaneColors>;
|
|
}
|
|
|
|
/** Built-in presets. The first is the tiletopia default; the rest are
|
|
* well-known community palettes (background/foreground/cursor/selection
|
|
* only — the ANSI ramp is left to {@link BASE_XTERM_THEME}). */
|
|
export const COLOR_PRESETS: ColorPreset[] = [
|
|
{ name: "Tiletopia Dark", colors: DEFAULT_PANE_COLORS },
|
|
{
|
|
name: "Solarized Dark",
|
|
colors: { background: "#002b36", foreground: "#839496", cursor: "#93a1a1", selection: "#073642" },
|
|
},
|
|
{
|
|
name: "Gruvbox Dark",
|
|
colors: { background: "#282828", foreground: "#ebdbb2", cursor: "#ebdbb2", selection: "#504945" },
|
|
},
|
|
{
|
|
name: "Dracula",
|
|
colors: { background: "#282a36", foreground: "#f8f8f2", cursor: "#f8f8f2", selection: "#44475a" },
|
|
},
|
|
{
|
|
name: "Nord",
|
|
colors: { background: "#2e3440", foreground: "#d8dee9", cursor: "#d8dee9", selection: "#434c5e" },
|
|
},
|
|
{
|
|
name: "Light",
|
|
colors: { background: "#fafafa", foreground: "#1c1c1c", cursor: "#1c1c1c", selection: "#cfe0ff" },
|
|
},
|
|
];
|
|
|
|
/** Merge a per-pane override on top of the global default, then fill any
|
|
* still-missing field from {@link DEFAULT_PANE_COLORS}. The result always
|
|
* has all four fields defined. */
|
|
export function resolvePaneColors(
|
|
global: PaneColors | undefined,
|
|
override: PaneColors | undefined,
|
|
): Required<PaneColors> {
|
|
return {
|
|
background:
|
|
override?.background ?? global?.background ?? DEFAULT_PANE_COLORS.background,
|
|
foreground:
|
|
override?.foreground ?? global?.foreground ?? DEFAULT_PANE_COLORS.foreground,
|
|
cursor: override?.cursor ?? global?.cursor ?? DEFAULT_PANE_COLORS.cursor,
|
|
selection:
|
|
override?.selection ?? global?.selection ?? DEFAULT_PANE_COLORS.selection,
|
|
};
|
|
}
|
|
|
|
/** Build a full xterm ITheme from resolved colours. cursorAccent is pinned to
|
|
* the background so a block cursor's glyph stays readable. */
|
|
export function toXtermTheme(colors: Required<PaneColors>): ITheme {
|
|
return {
|
|
...BASE_XTERM_THEME,
|
|
background: colors.background,
|
|
foreground: colors.foreground,
|
|
cursor: colors.cursor,
|
|
cursorAccent: colors.background,
|
|
selectionBackground: colors.selection,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Global-default persistence (localStorage; frontend-only, no backend hop).
|
|
// localStorage is shared across all windows of the same origin, so a new
|
|
// window picks up the saved theme at startup, and the `storage` event lets
|
|
// open windows react live (see App's listener).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const GLOBAL_COLORS_STORAGE_KEY = "tiletopia.globalColors.v1";
|
|
|
|
/** #rgb / #rrggbb hex validator — what `<input type="color">` emits and what
|
|
* xterm accepts. We reject anything else so a corrupt localStorage value
|
|
* can't poison the theme. */
|
|
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
|
|
function sanitizeColors(raw: unknown): PaneColors {
|
|
if (typeof raw !== "object" || raw === null) return {};
|
|
const o = raw as Record<string, unknown>;
|
|
const out: PaneColors = {};
|
|
for (const key of ["background", "foreground", "cursor", "selection"] as const) {
|
|
const v = o[key];
|
|
if (typeof v === "string" && HEX_RE.test(v)) out[key] = v;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Read the saved global theme. Returns {} (→ all defaults) when absent or
|
|
* unparseable. */
|
|
export function loadGlobalColors(): PaneColors {
|
|
try {
|
|
const raw = localStorage.getItem(GLOBAL_COLORS_STORAGE_KEY);
|
|
if (!raw) return {};
|
|
return sanitizeColors(JSON.parse(raw));
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/** Persist the global theme. Empty object is stored as-is (means "all
|
|
* defaults"), keeping the round-trip lossless. */
|
|
export function saveGlobalColors(colors: PaneColors): void {
|
|
try {
|
|
localStorage.setItem(GLOBAL_COLORS_STORAGE_KEY, JSON.stringify(colors));
|
|
} catch (e) {
|
|
console.warn("saveGlobalColors failed:", e);
|
|
}
|
|
}
|