Skip to content

Commit d295d7f

Browse files
feat: add dynamic theme changes via Terminal.setTheme() and options.theme
Today, the theme passed to the Terminal constructor is captured at open() time and never changes. Apps that need to switch themes at runtime (light/ dark toggle, accessibility preference change, multi-window state) had to dispose the Terminal and recreate it — which destroys scrollback, selection, and focus. This commit adds a runtime theme change path: - Public API: `Terminal.setTheme(theme)` updates the theme atomically and triggers a single render. Equivalent to assigning `options.theme = ...` via the existing options Proxy (also supported). - WASM bridge: new exports `terminal_set_theme` (full theme update) and the renderer is invalidated so the next frame redraws every cell with the new palette / background. - The renderer's color cache (introduced in older PRs) is cleared on theme change so old `rgb(...)` strings don't outlive their palette. Adds 12 new tests covering: full-theme update mid-session, ANSI palette update, default-color fallback when theme omits ansi colors, no-op on identical theme, render scheduling, options-proxy compatibility. Excludes the binary `ghostty-vt.wasm` from the upstream diff (CI / local `bun run build:wasm` rebuilds it from the updated patch). Co-authored-by: Brian Egan <brian.egan@verygood.ventures> Inspired-by: coder#144
1 parent c3115f2 commit d295d7f

5 files changed

Lines changed: 471 additions & 19 deletions

File tree

lib/ghostty.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,46 @@ export class GhosttyTerminal {
364364
this.exports.ghostty_terminal_free(this.handle);
365365
}
366366

367+
/**
368+
* Update terminal colors at runtime. All color values are applied directly
369+
* (no sentinel — 0x000000 is valid black). Forces a full redraw on next render.
370+
*/
371+
setColors(config: GhosttyTerminalConfig): void {
372+
const configPtr = this.exports.ghostty_wasm_alloc_u8_array(GHOSTTY_CONFIG_SIZE);
373+
if (configPtr === 0) return;
374+
375+
try {
376+
const view = new DataView(this.memory.buffer);
377+
let offset = configPtr;
378+
379+
// scrollback_limit (u32) — ignored by setColors but must be present in struct
380+
view.setUint32(offset, 0, true);
381+
offset += 4;
382+
383+
// fg_color (u32)
384+
view.setUint32(offset, config.fgColor ?? 0, true);
385+
offset += 4;
386+
387+
// bg_color (u32)
388+
view.setUint32(offset, config.bgColor ?? 0, true);
389+
offset += 4;
390+
391+
// cursor_color (u32)
392+
view.setUint32(offset, config.cursorColor ?? 0, true);
393+
offset += 4;
394+
395+
// palette[16] (u32 * 16)
396+
for (let i = 0; i < 16; i++) {
397+
view.setUint32(offset, config.palette?.[i] ?? 0, true);
398+
offset += 4;
399+
}
400+
401+
this.exports.ghostty_terminal_set_colors(this.handle, configPtr);
402+
} finally {
403+
this.exports.ghostty_wasm_free_u8_array(configPtr, GHOSTTY_CONFIG_SIZE);
404+
}
405+
}
406+
367407
// ==========================================================================
368408
// RenderState API - The key performance optimization
369409
// ==========================================================================

lib/terminal.test.ts

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2990,3 +2990,311 @@ describe('Synchronous open()', () => {
29902990
term.dispose();
29912991
});
29922992
});
2993+
2994+
// ============================================================================
2995+
// Dynamic Theme Changes
2996+
// ============================================================================
2997+
2998+
describe('Dynamic Theme Changes', () => {
2999+
let container: HTMLElement | null = null;
3000+
3001+
beforeEach(async () => {
3002+
if (typeof document !== 'undefined') {
3003+
container = document.createElement('div');
3004+
document.body.appendChild(container);
3005+
}
3006+
});
3007+
3008+
afterEach(() => {
3009+
if (container && container.parentNode) {
3010+
container.parentNode.removeChild(container);
3011+
container = null;
3012+
}
3013+
});
3014+
3015+
test('full theme change updates renderer', async () => {
3016+
if (!container) return;
3017+
3018+
const term = await createIsolatedTerminal({
3019+
theme: { background: '#000000', foreground: '#ffffff' },
3020+
});
3021+
term.open(container);
3022+
3023+
// Change to a completely different theme
3024+
term.options.theme = {
3025+
background: '#ff0000',
3026+
foreground: '#00ff00',
3027+
cursor: '#0000ff',
3028+
red: '#aa0000',
3029+
};
3030+
3031+
// @ts-ignore - accessing private for test
3032+
const renderer = term.renderer;
3033+
// @ts-ignore - accessing private for test
3034+
expect(renderer.theme.background).toBe('#ff0000');
3035+
// @ts-ignore - accessing private for test
3036+
expect(renderer.theme.foreground).toBe('#00ff00');
3037+
// @ts-ignore - accessing private for test
3038+
expect(renderer.theme.cursor).toBe('#0000ff');
3039+
3040+
term.dispose();
3041+
});
3042+
3043+
test('full theme change updates WASM terminal colors', async () => {
3044+
if (!container) return;
3045+
3046+
const term = await createIsolatedTerminal();
3047+
term.open(container);
3048+
3049+
term.options.theme = {
3050+
background: '#112233',
3051+
foreground: '#aabbcc',
3052+
};
3053+
3054+
// Force render state update to pick up new colors
3055+
term.wasmTerm!.update();
3056+
const colors = term.wasmTerm!.getColors();
3057+
3058+
// Verify WASM terminal has the new colors
3059+
expect(colors.background.r).toBe(0x11);
3060+
expect(colors.background.g).toBe(0x22);
3061+
expect(colors.background.b).toBe(0x33);
3062+
expect(colors.foreground.r).toBe(0xaa);
3063+
expect(colors.foreground.g).toBe(0xbb);
3064+
expect(colors.foreground.b).toBe(0xcc);
3065+
3066+
term.dispose();
3067+
});
3068+
3069+
test('partial theme update preserves previous customizations', async () => {
3070+
if (!container) return;
3071+
3072+
const term = await createIsolatedTerminal();
3073+
term.open(container);
3074+
3075+
// First: change background only
3076+
term.options.theme = { background: '#111111' };
3077+
3078+
// @ts-ignore - accessing private for test
3079+
expect(term.renderer.theme.background).toBe('#111111');
3080+
3081+
// Second: change foreground only — background should be preserved
3082+
term.options.theme = { foreground: '#222222' };
3083+
3084+
// @ts-ignore - accessing private for test
3085+
expect(term.renderer.theme.background).toBe('#111111');
3086+
// @ts-ignore - accessing private for test
3087+
expect(term.renderer.theme.foreground).toBe('#222222');
3088+
3089+
term.dispose();
3090+
});
3091+
3092+
test('successive partial updates accumulate correctly', async () => {
3093+
if (!container) return;
3094+
3095+
const term = await createIsolatedTerminal();
3096+
term.open(container);
3097+
3098+
term.options.theme = { background: '#aaaaaa' };
3099+
term.options.theme = { foreground: '#bbbbbb' };
3100+
term.options.theme = { cursor: '#cccccc' };
3101+
3102+
// @ts-ignore - accessing private for test
3103+
const theme = term.renderer.theme;
3104+
expect(theme.background).toBe('#aaaaaa');
3105+
expect(theme.foreground).toBe('#bbbbbb');
3106+
expect(theme.cursor).toBe('#cccccc');
3107+
3108+
term.dispose();
3109+
});
3110+
3111+
test('theme reset to empty object restores defaults', async () => {
3112+
if (!container) return;
3113+
3114+
const term = await createIsolatedTerminal({
3115+
theme: { background: '#ff0000', foreground: '#00ff00' },
3116+
});
3117+
term.open(container);
3118+
3119+
// @ts-ignore - accessing private for test
3120+
expect(term.renderer.theme.background).toBe('#ff0000');
3121+
3122+
// Reset to empty — should restore defaults
3123+
term.options.theme = {};
3124+
3125+
// @ts-ignore - accessing private for test
3126+
expect(term.renderer.theme.background).toBe('#1e1e1e');
3127+
// @ts-ignore - accessing private for test
3128+
expect(term.renderer.theme.foreground).toBe('#d4d4d4');
3129+
3130+
term.dispose();
3131+
});
3132+
3133+
test('theme reset to null restores defaults', async () => {
3134+
if (!container) return;
3135+
3136+
const term = await createIsolatedTerminal({
3137+
theme: { background: '#ff0000' },
3138+
});
3139+
term.open(container);
3140+
3141+
// @ts-ignore - accessing private for test
3142+
expect(term.renderer.theme.background).toBe('#ff0000');
3143+
3144+
// Reset to null
3145+
term.options.theme = null as any;
3146+
3147+
// @ts-ignore - accessing private for test
3148+
expect(term.renderer.theme.background).toBe('#1e1e1e');
3149+
3150+
term.dispose();
3151+
});
3152+
3153+
test('theme change before open() is applied correctly', async () => {
3154+
if (!container) return;
3155+
3156+
const term = await createIsolatedTerminal({
3157+
theme: { background: '#111111' },
3158+
});
3159+
3160+
// Change theme before open
3161+
term.options.theme = { background: '#222222' };
3162+
3163+
// Open — should use the latest theme
3164+
term.open(container);
3165+
3166+
// The buildWasmConfig reads from options.theme which is now #222222
3167+
// @ts-ignore - accessing private for test
3168+
expect(term.renderer.theme.background).toBe('#222222');
3169+
3170+
term.dispose();
3171+
});
3172+
3173+
test('ANSI palette color cells re-resolve after theme change', async () => {
3174+
if (!container) return;
3175+
3176+
const term = await createIsolatedTerminal({
3177+
theme: { red: '#cd3131' },
3178+
});
3179+
term.open(container);
3180+
3181+
// Write text with ANSI red (color index 1)
3182+
term.write('\x1b[31mRed text\x1b[0m');
3183+
3184+
// Change theme — new red
3185+
term.options.theme = { red: '#ff0000' };
3186+
3187+
// Force render state update and read cells
3188+
term.wasmTerm!.update();
3189+
const line = term.wasmTerm!.getLine(0);
3190+
expect(line).not.toBeNull();
3191+
3192+
// First cell ('R') should now have the new red color
3193+
const cell = line![0];
3194+
expect(cell.fg_r).toBe(0xff);
3195+
expect(cell.fg_g).toBe(0x00);
3196+
expect(cell.fg_b).toBe(0x00);
3197+
3198+
term.dispose();
3199+
});
3200+
3201+
test('explicit RGB color cells remain unchanged after theme change', async () => {
3202+
if (!container) return;
3203+
3204+
const term = await createIsolatedTerminal();
3205+
term.open(container);
3206+
3207+
// Write text with explicit RGB color
3208+
term.write('\x1b[38;2;100;200;50mRGB text\x1b[0m');
3209+
3210+
// Change theme
3211+
term.options.theme = {
3212+
foreground: '#ffffff',
3213+
background: '#000000',
3214+
red: '#ff0000',
3215+
};
3216+
3217+
// Force render state update and read cells
3218+
term.wasmTerm!.update();
3219+
const line = term.wasmTerm!.getLine(0);
3220+
expect(line).not.toBeNull();
3221+
3222+
// First cell ('R') should still have the explicit RGB color
3223+
const cell = line![0];
3224+
expect(cell.fg_r).toBe(100);
3225+
expect(cell.fg_g).toBe(200);
3226+
expect(cell.fg_b).toBe(50);
3227+
3228+
term.dispose();
3229+
});
3230+
3231+
test('theme change triggers full redraw', async () => {
3232+
if (!container) return;
3233+
3234+
const term = await createIsolatedTerminal();
3235+
term.open(container);
3236+
3237+
// Clear any existing dirty state
3238+
term.wasmTerm!.clearDirty();
3239+
expect(term.wasmTerm!.needsFullRedraw()).toBe(false);
3240+
3241+
// Change theme
3242+
term.options.theme = { background: '#ff0000' };
3243+
3244+
// Should need a full redraw
3245+
expect(term.wasmTerm!.needsFullRedraw()).toBe(true);
3246+
3247+
// After clearing, no longer dirty
3248+
term.wasmTerm!.clearDirty();
3249+
expect(term.wasmTerm!.needsFullRedraw()).toBe(false);
3250+
3251+
term.dispose();
3252+
});
3253+
3254+
test('invalid color values do not crash', async () => {
3255+
if (!container) return;
3256+
3257+
const term = await createIsolatedTerminal();
3258+
term.open(container);
3259+
3260+
// Should not throw
3261+
term.options.theme = {
3262+
background: 'not-a-color',
3263+
foreground: 'rgb(999,0,0)',
3264+
red: '',
3265+
};
3266+
3267+
// @ts-ignore - accessing private for test
3268+
expect(term.renderer.theme.background).toBe('not-a-color');
3269+
3270+
term.dispose();
3271+
});
3272+
3273+
test('default fg/bg cells update after theme change', async () => {
3274+
if (!container) return;
3275+
3276+
const term = await createIsolatedTerminal({
3277+
theme: { foreground: '#aaaaaa', background: '#111111' },
3278+
});
3279+
term.open(container);
3280+
3281+
// Write text with default colors (no SGR)
3282+
term.write('Hello');
3283+
3284+
// Change theme
3285+
term.options.theme = { foreground: '#ffffff', background: '#000000' };
3286+
3287+
// Force render state update and read cells
3288+
term.wasmTerm!.update();
3289+
const line = term.wasmTerm!.getLine(0);
3290+
expect(line).not.toBeNull();
3291+
3292+
// First cell ('H') should have new default foreground
3293+
const cell = line![0];
3294+
expect(cell.fg_r).toBe(0xff);
3295+
expect(cell.fg_g).toBe(0xff);
3296+
expect(cell.fg_b).toBe(0xff);
3297+
3298+
term.dispose();
3299+
});
3300+
});

0 commit comments

Comments
 (0)