Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 12 additions & 23 deletions desktop/ui/components/Terminal/terminal-palettes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,14 @@
*
* Provenance is recorded per palette. Where Ghostty's vendored copy disagreed
* with the theme's own upstream, the note says which one won and why.
*
* These are the bundled themes' ramps. A theme resolved from VS Code publishes
* its own sixteen colors and carries them on `UiTheme.ansi`, which wins over
* anything here — see `lib/vscode-theme-resolver.ts`.
*/

import { contrast, mixColors } from "../../lib/color";

export interface AnsiPalette {
black: string;
red: string;
green: string;
yellow: string;
blue: string;
magenta: string;
cyan: string;
white: string;
brightBlack: string;
brightRed: string;
brightGreen: string;
brightYellow: string;
brightBlue: string;
brightMagenta: string;
brightCyan: string;
brightWhite: string;
}
import type { AnsiPalette, UiTheme } from "../../lib/ui-themes";

/** Tomorrow Night — Ghostty's own compiled-in default (`terminal/color.zig`). */
const TOMORROW_NIGHT: AnsiPalette = {
Expand Down Expand Up @@ -254,8 +240,10 @@ function nudgeUntilVisible(
}

/**
* The palette for a theme, or a shared ramp when the theme has no published
* one (Review's own light theme, and anything derived from a VS Code theme).
* The palette for a theme: the ramp the theme carries itself (VS Code themes
* publish their sixteen `terminal.ansi*` colors), else the one transcribed
* above for it, else a shared ramp for themes with no published palette at all
* (Review's own light theme, and VS Code themes that set no terminal colors).
*
* Both schemes get one correction, for the same defect at opposite ends.
*
Expand All @@ -271,12 +259,13 @@ function nudgeUntilVisible(
* fine is passed through untouched.
*/
export function ansiPaletteFor(
themeId: string,
theme: UiTheme | null,
colorScheme: "light" | "dark",
background: string,
): AnsiPalette {
const base =
PALETTES[themeId] ??
theme?.ansi ??
(theme ? PALETTES[theme.id] : undefined) ??
(colorScheme === "light" ? GITHUB_LIGHT : TOMORROW_NIGHT);
// 1.6:1 is well below a text-legibility threshold — the goal is only to stop
// a color being *identical* to the background, not to restyle the theme.
Expand Down
84 changes: 78 additions & 6 deletions desktop/ui/components/Terminal/xterm-theme.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,67 @@
import { describe, it, expect, afterEach } from "vitest";
import { buildXtermTheme } from "./xterm-theme";
import { ansiPaletteFor } from "./terminal-palettes";
import { setActiveUiTheme } from "../../lib/active-theme";
import type { AnsiPalette, UiTheme, UiThemeTokens } from "../../lib/ui-themes";

/** A theme's own sixteen colors, the way a VS Code theme publishes them. */
const PUBLISHED_RAMP: AnsiPalette = {
black: "#45475a",
red: "#f38ba8",
green: "#a6e3a1",
yellow: "#f9e2af",
blue: "#89b4fa",
magenta: "#f5c2e7",
cyan: "#94e2d5",
white: "#bac2de",
brightBlack: "#585b70",
brightRed: "#f37799",
brightGreen: "#89d88b",
brightYellow: "#ebd391",
brightBlue: "#74a8fc",
brightMagenta: "#f2aede",
brightCyan: "#6bd7ca",
brightWhite: "#a6adc8",
};

/** Set a CSS custom property on <html>, mirroring applyUiTheme's inline-style approach. */
function setVar(name: string, value: string): void {
document.documentElement.style.setProperty(name, value);
}

/**
* A stand-in for an applied theme. The palette lookup reads only `id`,
* `colorScheme` and `ansi`; the tokens reach the terminal as CSS variables,
* which these tests set directly.
*/
function uiTheme(
id: string,
colorScheme: "light" | "dark",
ansi?: AnsiPalette,
): UiTheme {
return {
id,
label: id,
colorScheme,
preview: ["#000000", "#ffffff", "#ffffff"],
codeTheme: "github-dark",
tokens: {} as UiThemeTokens,
ansi,
};
}

/** Select a theme the way applyUiTheme does. */
function setTheme(id: string, scheme: "light" | "dark"): void {
document.documentElement.dataset.uiTheme = id;
function setTheme(
id: string,
scheme: "light" | "dark",
ansi?: AnsiPalette,
): void {
setActiveUiTheme(uiTheme(id, scheme, ansi));
document.documentElement.style.setProperty("color-scheme", scheme);
}

afterEach(() => {
document.documentElement.removeAttribute("style");
delete document.documentElement.dataset.uiTheme;
});

describe("buildXtermTheme", () => {
Expand Down Expand Up @@ -74,6 +120,20 @@ describe("buildXtermTheme", () => {
}
});

/**
* A theme resolved from VS Code carries its own ramp. Substituting the
* shared one there puts two themes in one window — the editor's chrome
* around another theme's terminal output.
*/
it("prefers a ramp the active theme carries over the shared fallback", () => {
setTheme("vscode-something-custom", "dark", PUBLISHED_RAMP);

const theme = buildXtermTheme();

expect(theme.red).toBe("#f38ba8");
expect(theme.brightRed).toBe("#f37799");
});

it("falls back to a shared ramp for a theme with no published palette", () => {
setTheme("vscode-something-custom", "dark");
const theme = buildXtermTheme();
Expand Down Expand Up @@ -102,16 +162,28 @@ describe("ansiPaletteFor", () => {
* invisible text. Only the colliding slots are corrected.
*/
it("rescues light-theme whites that match the background", () => {
const solarized = ansiPaletteFor("solarized-light", "light", "#eee8d5");
const solarized = ansiPaletteFor(
uiTheme("solarized-light", "light"),
"light",
"#eee8d5",
);
expect(solarized.white).not.toBe("#eee8d5");

const flexoki = ansiPaletteFor("flexoki-light", "light", "#F2F0E5");
const flexoki = ansiPaletteFor(
uiTheme("flexoki-light", "light"),
"light",
"#F2F0E5",
);
expect(flexoki.brightWhite).not.toBe("#F2F0E5");
});

it("leaves a palette alone when nothing collides", () => {
const dark = ansiPaletteFor("dracula", "dark", "#21222c");
const dark = ansiPaletteFor(uiTheme("dracula", "dark"), "dark", "#21222c");
expect(dark.white).toBe("#f8f8f2");
expect(dark.brightWhite).toBe("#ffffff");
});

it("uses the shared ramp when no theme has been applied yet", () => {
expect(ansiPaletteFor(null, "dark", "#1c1917").red).toBe("#CC6666");
});
});
3 changes: 2 additions & 1 deletion desktop/ui/components/Terminal/xterm-theme.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ITheme } from "@xterm/xterm";
import { getActiveUiTheme } from "../../lib/active-theme";
import { ansiPaletteFor } from "./terminal-palettes";

/**
Expand Down Expand Up @@ -26,7 +27,7 @@ export function buildXtermTheme(): ITheme {

const scheme =
cs.getPropertyValue("color-scheme").trim() === "light" ? "light" : "dark";
const ansi = ansiPaletteFor(el.dataset.uiTheme ?? "", scheme, background);
const ansi = ansiPaletteFor(getActiveUiTheme(), scheme, background);

return {
background,
Expand Down
25 changes: 25 additions & 0 deletions desktop/ui/lib/active-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* The UI theme currently applied to `<html>`.
*
* `applyUiTheme` writes the theme's colors out as CSS variables, which is all
* the UI needs. The terminal needs the theme *object*: its 16-color ANSI ramp
* is per-theme published data that no semantic token carries. A theme resolved
* from VS Code is built on the fly from the editor's JSON and never enters
* `getAllUiThemes()`, so an id alone is not enough to find it again.
*
* It lives in its own module so the terminal can read the active theme without
* pulling in the whole theme catalog (and Shiki behind it).
*/

import type { UiTheme } from "./ui-themes";

let activeUiTheme: UiTheme | null = null;

export function setActiveUiTheme(theme: UiTheme): void {
activeUiTheme = theme;
}

/** The applied theme, or null before the first `applyUiTheme` call. */
export function getActiveUiTheme(): UiTheme | null {
return activeUiTheme;
}
40 changes: 36 additions & 4 deletions desktop/ui/lib/ui-themes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/

import { registerCustomTheme } from "@pierre/diffs";
import { setActiveUiTheme } from "./active-theme";

export interface UiTheme {
id: string;
Expand All @@ -20,6 +21,37 @@ export interface UiTheme {
/** Recommended code (Shiki) theme to pair with this UI theme */
codeTheme: string;
tokens: UiThemeTokens;
/**
* The theme's own 16-color ANSI ramp, when it carries one. Themes resolved
* from VS Code JSON fill this from their `terminal.ansi*` colors; the
* bundled themes' ramps are transcribed in `terminal-palettes.ts`, which is
* also where the shared fallback for themes that publish nothing lives.
*/
ansi?: AnsiPalette;
}

/**
* The 16 ANSI colors a terminal addresses by index. Declared here because it
* is part of a theme's shape; the values and their provenance live in
* `components/Terminal/terminal-palettes.ts`.
*/
export interface AnsiPalette {
black: string;
red: string;
green: string;
yellow: string;
blue: string;
magenta: string;
cyan: string;
white: string;
brightBlack: string;
brightRed: string;
brightGreen: string;
brightYellow: string;
brightBlue: string;
brightMagenta: string;
brightCyan: string;
brightWhite: string;
}

export interface UiThemeTokens {
Expand Down Expand Up @@ -987,10 +1019,10 @@ const TOKEN_TO_CSS_VAR: Record<keyof UiThemeTokens, string> = {
export function applyUiTheme(theme: UiTheme): void {
const el = document.documentElement;
el.style.setProperty("color-scheme", theme.colorScheme);
// The terminal needs to know *which* theme is active, not just its colors:
// its 16-color ANSI ramp is per-theme published data that cannot be derived
// from the handful of semantic tokens below.
el.dataset.uiTheme = theme.id;
// The terminal needs the theme itself, not just its colors: its 16-color
// ANSI ramp is per-theme published data that cannot be derived from the
// handful of semantic tokens below.
setActiveUiTheme(theme);

for (const [token, value] of Object.entries(theme.tokens)) {
const cssVar = TOKEN_TO_CSS_VAR[token as keyof UiThemeTokens];
Expand Down
82 changes: 82 additions & 0 deletions desktop/ui/lib/vscode-theme-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect } from "vitest";
import {
resolveVscodeTheme,
type VscodeThemeDetection,
} from "./vscode-theme-resolver";

/** The sixteen keys VS Code themes use for the terminal palette. */
const ANSI_COLORS: Record<string, string> = {
"terminal.ansiBlack": "#45475a",
"terminal.ansiRed": "#f38ba8",
"terminal.ansiGreen": "#a6e3a1",
"terminal.ansiYellow": "#f9e2af",
"terminal.ansiBlue": "#89b4fa",
"terminal.ansiMagenta": "#f5c2e7",
"terminal.ansiCyan": "#94e2d5",
"terminal.ansiWhite": "#bac2de",
"terminal.ansiBrightBlack": "#585b70",
"terminal.ansiBrightRed": "#f37799",
"terminal.ansiBrightGreen": "#89d88b",
"terminal.ansiBrightYellow": "#ebd391",
"terminal.ansiBrightBlue": "#74a8fc",
"terminal.ansiBrightMagenta": "#f2aede",
"terminal.ansiBrightCyan": "#6bd7ca",
"terminal.ansiBrightWhite": "#a6adc8",
};

function detection(colors: Record<string, string>): VscodeThemeDetection {
return {
name: "Something Custom",
themeType: "dark",
colors: { "editor.background": "#1e1e2e", ...colors },
// Empty so no Shiki theme is registered — this file is about the colors.
tokenColors: [],
};
}

describe("resolveVscodeTheme", () => {
/**
* Without this the terminal falls back to a generic ramp, so a Catppuccin
* user gets Catppuccin chrome around Tomorrow Night output.
*/
it("carries the theme's own terminal palette", () => {
const theme = resolveVscodeTheme(detection(ANSI_COLORS));

expect(theme.ansi).toEqual({
black: "#45475a",
red: "#f38ba8",
green: "#a6e3a1",
yellow: "#f9e2af",
blue: "#89b4fa",
magenta: "#f5c2e7",
cyan: "#94e2d5",
white: "#bac2de",
brightBlack: "#585b70",
brightRed: "#f37799",
brightGreen: "#89d88b",
brightYellow: "#ebd391",
brightBlue: "#74a8fc",
brightMagenta: "#f2aede",
brightCyan: "#6bd7ca",
brightWhite: "#a6adc8",
});
});

it("leaves the palette unset for a theme that publishes no terminal colors", () => {
expect(resolveVscodeTheme(detection({})).ansi).toBeUndefined();
});

/**
* A partial ramp topped up from the shared fallback would pair one theme's
* normals with another's brights — the mismatch this avoids, in miniature.
*/
it("leaves the palette unset when the ramp is incomplete", () => {
const partial = Object.fromEntries(
Object.entries(ANSI_COLORS).filter(
([key]) => key !== "terminal.ansiBrightCyan",
),
);

expect(resolveVscodeTheme(detection(partial)).ansi).toBeUndefined();
});
});
Loading