Skip to content

Commit 2b2446d

Browse files
loeckbrianeganclaude
authored
feat: add dynamic theme changes (#3)
* feat: add dynamic theme changes Enable runtime theme changes via `terminal.options.theme = newTheme` without restarting the terminal. All existing content re-renders instantly with the new colors. - Add `ghostty_terminal_set_colors` WASM export that updates terminal colors and forces a full redraw - Wire up `handleOptionChange('theme')` to merge partial themes, update the renderer, and sync WASM terminal colors - Support partial theme updates that accumulate (e.g. setting only background preserves all other colors) - Cells with ANSI palette/default colors re-resolve; explicit RGB cells remain unchanged Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: address code review suggestions from PR #3 - Add struct layout comment in ghostty.ts to document GhosttyTerminalConfig field offsets and warn about Zig/TS drift - Expose renderer.theme as a public getter on CanvasRenderer to eliminate @ts-ignore in Dynamic Theme Changes tests - Remove trailing whitespace in renderStateUpdate section of ghostty-wasm-api.patch * refactor: eliminate duplication in theme config building - Extract buildThemePalette() helper in terminal.ts; used by both buildWasmConfig() and buildThemeColorsConfig() - Extract writeConfigToPtr() helper in ghostty.ts; used by constructor and setColors() - Remove double initialization of currentTheme (class field default was immediately overwritten in constructor) - setTheme() now accepts Required<ITheme> and assigns directly, removing redundant spread against DEFAULT_THEME * fix: correct terminal.zig hunk line count in patch The @@ -0,0 +1,1168 @@ header was off by 5 — actual content is 1163 lines. Rebuilt ghostty-vt.wasm against updated submodule. --------- Co-authored-by: Brian Egan <brian.egan@verygood.ventures> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1386570 commit 2b2446d

7 files changed

Lines changed: 507 additions & 103 deletions

File tree

ghostty-vt.wasm

452 KB
Binary file not shown.

lib/ghostty.ts

Lines changed: 47 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -288,35 +288,9 @@ export class GhosttyTerminal {
288288
}
289289

290290
try {
291-
// Write config to WASM memory
292-
const view = new DataView(this.memory.buffer);
293-
let offset = configPtr;
294-
295-
// scrollback_limit (u32)
296-
view.setUint32(offset, config.scrollbackLimit ?? 10000, true);
297-
offset += 4;
298-
299-
// fg_color (u32)
300-
view.setUint32(offset, config.fgColor ?? 0, true);
301-
offset += 4;
302-
303-
// bg_color (u32)
304-
view.setUint32(offset, config.bgColor ?? 0, true);
305-
offset += 4;
306-
307-
// cursor_color (u32)
308-
view.setUint32(offset, config.cursorColor ?? 0, true);
309-
offset += 4;
310-
311-
// palette[16] (u32 * 16)
312-
for (let i = 0; i < 16; i++) {
313-
view.setUint32(offset, config.palette?.[i] ?? 0, true);
314-
offset += 4;
315-
}
316-
291+
this.writeConfigToPtr(configPtr, config);
317292
this.handle = this.exports.ghostty_terminal_new_with_config(cols, rows, configPtr);
318293
} finally {
319-
// Free the config memory
320294
this.exports.ghostty_wasm_free_u8_array(configPtr, GHOSTTY_CONFIG_SIZE);
321295
}
322296
} else {
@@ -364,6 +338,52 @@ export class GhosttyTerminal {
364338
this.exports.ghostty_terminal_free(this.handle);
365339
}
366340

341+
/**
342+
* Update terminal colors at runtime. All color values are applied directly
343+
* (no sentinel — 0x000000 is valid black). Forces a full redraw on next render.
344+
*/
345+
setColors(config: GhosttyTerminalConfig): void {
346+
const configPtr = this.exports.ghostty_wasm_alloc_u8_array(GHOSTTY_CONFIG_SIZE);
347+
if (configPtr === 0) return;
348+
349+
try {
350+
this.writeConfigToPtr(configPtr, config);
351+
this.exports.ghostty_terminal_set_colors(this.handle, configPtr);
352+
} finally {
353+
this.exports.ghostty_wasm_free_u8_array(configPtr, GHOSTTY_CONFIG_SIZE);
354+
}
355+
}
356+
357+
/**
358+
* Write a GhosttyTerminalConfig into WASM memory at configPtr.
359+
*
360+
* Layout must match GhosttyTerminalConfig in src/terminal/c/terminal.zig:
361+
* scrollback_limit: u32 (+0)
362+
* fg_color: u32 (+4)
363+
* bg_color: u32 (+8)
364+
* cursor_color: u32 (+12)
365+
* palette: [16]u32 (+16..+79)
366+
* Total: 80 bytes. Any struct change in Zig must be mirrored here.
367+
*/
368+
private writeConfigToPtr(configPtr: number, config: GhosttyTerminalConfig): void {
369+
const view = new DataView(this.memory.buffer);
370+
let offset = configPtr;
371+
372+
view.setUint32(offset, config.scrollbackLimit ?? 0, true);
373+
offset += 4;
374+
view.setUint32(offset, config.fgColor ?? 0, true);
375+
offset += 4;
376+
view.setUint32(offset, config.bgColor ?? 0, true);
377+
offset += 4;
378+
view.setUint32(offset, config.cursorColor ?? 0, true);
379+
offset += 4;
380+
381+
for (let i = 0; i < 16; i++) {
382+
view.setUint32(offset, config.palette?.[i] ?? 0, true);
383+
offset += 4;
384+
}
385+
}
386+
367387
// ==========================================================================
368388
// RenderState API - The key performance optimization
369389
// ==========================================================================

lib/renderer.ts

Lines changed: 50 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ export class CanvasRenderer {
9898
private fontFamily: string;
9999
private cursorStyle: 'block' | 'underline' | 'bar';
100100
private cursorBlink: boolean;
101-
private theme: Required<ITheme>;
101+
// Exposed as a getter for test access (see _theme getter below)
102+
private _theme: Required<ITheme>;
102103
private devicePixelRatio: number;
103104
private metrics: FontMetrics;
104105
private palette: string[];
@@ -138,6 +139,11 @@ export class CanvasRenderer {
138139
endY: number;
139140
} | null = null;
140141

142+
/** Read-only access to the resolved theme — for use in tests only. */
143+
get theme(): Readonly<Required<ITheme>> {
144+
return this._theme;
145+
}
146+
141147
constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) {
142148
this.canvas = canvas;
143149
const ctx = canvas.getContext('2d', { alpha: true });
@@ -151,27 +157,27 @@ export class CanvasRenderer {
151157
this.fontFamily = options.fontFamily ?? 'monospace';
152158
this.cursorStyle = options.cursorStyle ?? 'block';
153159
this.cursorBlink = options.cursorBlink ?? false;
154-
this.theme = { ...DEFAULT_THEME, ...options.theme };
160+
this._theme = { ...DEFAULT_THEME, ...options.theme };
155161
this.devicePixelRatio = options.devicePixelRatio ?? window.devicePixelRatio ?? 1;
156162

157163
// Build color palette (16 ANSI colors)
158164
this.palette = [
159-
this.theme.black,
160-
this.theme.red,
161-
this.theme.green,
162-
this.theme.yellow,
163-
this.theme.blue,
164-
this.theme.magenta,
165-
this.theme.cyan,
166-
this.theme.white,
167-
this.theme.brightBlack,
168-
this.theme.brightRed,
169-
this.theme.brightGreen,
170-
this.theme.brightYellow,
171-
this.theme.brightBlue,
172-
this.theme.brightMagenta,
173-
this.theme.brightCyan,
174-
this.theme.brightWhite,
165+
this._theme.black,
166+
this._theme.red,
167+
this._theme.green,
168+
this._theme.yellow,
169+
this._theme.blue,
170+
this._theme.magenta,
171+
this._theme.cyan,
172+
this._theme.white,
173+
this._theme.brightBlack,
174+
this._theme.brightRed,
175+
this._theme.brightGreen,
176+
this._theme.brightYellow,
177+
this._theme.brightBlue,
178+
this._theme.brightMagenta,
179+
this._theme.brightCyan,
180+
this._theme.brightWhite,
175181
];
176182

177183
// Measure font metrics
@@ -253,7 +259,7 @@ export class CanvasRenderer {
253259
this.ctx.textAlign = 'left';
254260

255261
// Fill background after resize
256-
this.ctx.fillStyle = this.theme.background;
262+
this.ctx.fillStyle = this._theme.background;
257263
this.ctx.fillRect(0, 0, cssWidth, cssHeight);
258264
}
259265

@@ -523,7 +529,7 @@ export class CanvasRenderer {
523529
// clearRect is needed because fillRect composites rather than replaces,
524530
// so transparent/translucent backgrounds wouldn't clear previous content.
525531
this.ctx.clearRect(0, lineY, lineWidth, this.metrics.height);
526-
this.ctx.fillStyle = this.theme.background;
532+
this.ctx.fillStyle = this._theme.background;
527533
this.ctx.fillRect(0, lineY, lineWidth, this.metrics.height);
528534

529535
// PASS 1: Draw all cell backgrounds first
@@ -559,7 +565,7 @@ export class CanvasRenderer {
559565

560566
if (isSelected) {
561567
// Draw selection background (solid color, not overlay)
562-
this.ctx.fillStyle = this.theme.selectionBackground;
568+
this.ctx.fillStyle = this._theme.selectionBackground;
563569
this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height);
564570
return; // Selection background replaces cell background
565571
}
@@ -612,7 +618,7 @@ export class CanvasRenderer {
612618
if (colorOverride) {
613619
this.ctx.fillStyle = colorOverride;
614620
} else if (isSelected) {
615-
this.ctx.fillStyle = this.theme.selectionForeground;
621+
this.ctx.fillStyle = this._theme.selectionForeground;
616622
} else {
617623
// Extract colors and handle inverse
618624
let fg_r = cell.fg_r,
@@ -720,7 +726,7 @@ export class CanvasRenderer {
720726
const cursorX = x * this.metrics.width;
721727
const cursorY = y * this.metrics.height;
722728

723-
this.ctx.fillStyle = this.theme.cursor;
729+
this.ctx.fillStyle = this._theme.cursor;
724730

725731
switch (this.cursorStyle) {
726732
case 'block':
@@ -734,7 +740,7 @@ export class CanvasRenderer {
734740
this.ctx.beginPath();
735741
this.ctx.rect(cursorX, cursorY, this.metrics.width, this.metrics.height);
736742
this.ctx.clip();
737-
this.renderCellText(line[x], x, y, this.theme.cursorAccent);
743+
this.renderCellText(line[x], x, y, this._theme.cursorAccent);
738744
this.ctx.restore();
739745
}
740746
}
@@ -786,27 +792,27 @@ export class CanvasRenderer {
786792
/**
787793
* Update theme colors
788794
*/
789-
public setTheme(theme: ITheme): void {
790-
this.theme = { ...DEFAULT_THEME, ...theme };
795+
public setTheme(theme: Required<ITheme>): void {
796+
this._theme = theme;
791797

792798
// Rebuild palette
793799
this.palette = [
794-
this.theme.black,
795-
this.theme.red,
796-
this.theme.green,
797-
this.theme.yellow,
798-
this.theme.blue,
799-
this.theme.magenta,
800-
this.theme.cyan,
801-
this.theme.white,
802-
this.theme.brightBlack,
803-
this.theme.brightRed,
804-
this.theme.brightGreen,
805-
this.theme.brightYellow,
806-
this.theme.brightBlue,
807-
this.theme.brightMagenta,
808-
this.theme.brightCyan,
809-
this.theme.brightWhite,
800+
this._theme.black,
801+
this._theme.red,
802+
this._theme.green,
803+
this._theme.yellow,
804+
this._theme.blue,
805+
this._theme.magenta,
806+
this._theme.cyan,
807+
this._theme.white,
808+
this._theme.brightBlack,
809+
this._theme.brightRed,
810+
this._theme.brightGreen,
811+
this._theme.brightYellow,
812+
this._theme.brightBlue,
813+
this._theme.brightMagenta,
814+
this._theme.brightCyan,
815+
this._theme.brightWhite,
810816
];
811817
}
812818

@@ -873,7 +879,7 @@ export class CanvasRenderer {
873879

874880
// Always clear the scrollbar area first (fixes ghosting when fading out)
875881
ctx.clearRect(scrollbarX - 2, 0, scrollbarWidth + 6, canvasHeight);
876-
ctx.fillStyle = this.theme.background;
882+
ctx.fillStyle = this._theme.background;
877883
ctx.fillRect(scrollbarX - 2, 0, scrollbarWidth + 6, canvasHeight);
878884

879885
// Don't draw scrollbar if fully transparent or no scrollback
@@ -988,7 +994,7 @@ export class CanvasRenderer {
988994
// clearRect first because fillRect composites rather than replaces,
989995
// so transparent/translucent backgrounds wouldn't clear previous content.
990996
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
991-
this.ctx.fillStyle = this.theme.background;
997+
this.ctx.fillStyle = this._theme.background;
992998
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
993999
}
9941000

0 commit comments

Comments
 (0)