Add customizable terminal colors (global theme + per-pane overrides)
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>
This commit is contained in:
parent
1febf2e096
commit
7e624a3f96
10 changed files with 938 additions and 5 deletions
160
src/lib/theme.ts
Normal file
160
src/lib/theme.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue