diff --git a/ghostty-vt.wasm b/ghostty-vt.wasm new file mode 100755 index 0000000..7f70db4 Binary files /dev/null and b/ghostty-vt.wasm differ diff --git a/lib/ghostty.ts b/lib/ghostty.ts index f079885..dc4d3f1 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -364,6 +364,46 @@ export class GhosttyTerminal { this.exports.ghostty_terminal_free(this.handle); } + /** + * Update terminal colors at runtime. All color values are applied directly + * (no sentinel — 0x000000 is valid black). Forces a full redraw on next render. + */ + setColors(config: GhosttyTerminalConfig): void { + const configPtr = this.exports.ghostty_wasm_alloc_u8_array(GHOSTTY_CONFIG_SIZE); + if (configPtr === 0) return; + + try { + const view = new DataView(this.memory.buffer); + let offset = configPtr; + + // scrollback_limit (u32) — ignored by setColors but must be present in struct + view.setUint32(offset, 0, true); + offset += 4; + + // fg_color (u32) + view.setUint32(offset, config.fgColor ?? 0, true); + offset += 4; + + // bg_color (u32) + view.setUint32(offset, config.bgColor ?? 0, true); + offset += 4; + + // cursor_color (u32) + view.setUint32(offset, config.cursorColor ?? 0, true); + offset += 4; + + // palette[16] (u32 * 16) + for (let i = 0; i < 16; i++) { + view.setUint32(offset, config.palette?.[i] ?? 0, true); + offset += 4; + } + + this.exports.ghostty_terminal_set_colors(this.handle, configPtr); + } finally { + this.exports.ghostty_wasm_free_u8_array(configPtr, GHOSTTY_CONFIG_SIZE); + } + } + // ========================================================================== // RenderState API - The key performance optimization // ========================================================================== diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index d56011e..bfe499b 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -2990,3 +2990,311 @@ describe('Synchronous open()', () => { term.dispose(); }); }); + +// ============================================================================ +// Dynamic Theme Changes +// ============================================================================ + +describe('Dynamic Theme Changes', () => { + let container: HTMLElement | null = null; + + beforeEach(async () => { + if (typeof document !== 'undefined') { + container = document.createElement('div'); + document.body.appendChild(container); + } + }); + + afterEach(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + container = null; + } + }); + + test('full theme change updates renderer', async () => { + if (!container) return; + + const term = await createIsolatedTerminal({ + theme: { background: '#000000', foreground: '#ffffff' }, + }); + term.open(container); + + // Change to a completely different theme + term.options.theme = { + background: '#ff0000', + foreground: '#00ff00', + cursor: '#0000ff', + red: '#aa0000', + }; + + // @ts-ignore - accessing private for test + const renderer = term.renderer; + // @ts-ignore - accessing private for test + expect(renderer.theme.background).toBe('#ff0000'); + // @ts-ignore - accessing private for test + expect(renderer.theme.foreground).toBe('#00ff00'); + // @ts-ignore - accessing private for test + expect(renderer.theme.cursor).toBe('#0000ff'); + + term.dispose(); + }); + + test('full theme change updates WASM terminal colors', async () => { + if (!container) return; + + const term = await createIsolatedTerminal(); + term.open(container); + + term.options.theme = { + background: '#112233', + foreground: '#aabbcc', + }; + + // Force render state update to pick up new colors + term.wasmTerm!.update(); + const colors = term.wasmTerm!.getColors(); + + // Verify WASM terminal has the new colors + expect(colors.background.r).toBe(0x11); + expect(colors.background.g).toBe(0x22); + expect(colors.background.b).toBe(0x33); + expect(colors.foreground.r).toBe(0xaa); + expect(colors.foreground.g).toBe(0xbb); + expect(colors.foreground.b).toBe(0xcc); + + term.dispose(); + }); + + test('partial theme update preserves previous customizations', async () => { + if (!container) return; + + const term = await createIsolatedTerminal(); + term.open(container); + + // First: change background only + term.options.theme = { background: '#111111' }; + + // @ts-ignore - accessing private for test + expect(term.renderer.theme.background).toBe('#111111'); + + // Second: change foreground only — background should be preserved + term.options.theme = { foreground: '#222222' }; + + // @ts-ignore - accessing private for test + expect(term.renderer.theme.background).toBe('#111111'); + // @ts-ignore - accessing private for test + expect(term.renderer.theme.foreground).toBe('#222222'); + + term.dispose(); + }); + + test('successive partial updates accumulate correctly', async () => { + if (!container) return; + + const term = await createIsolatedTerminal(); + term.open(container); + + term.options.theme = { background: '#aaaaaa' }; + term.options.theme = { foreground: '#bbbbbb' }; + term.options.theme = { cursor: '#cccccc' }; + + // @ts-ignore - accessing private for test + const theme = term.renderer.theme; + expect(theme.background).toBe('#aaaaaa'); + expect(theme.foreground).toBe('#bbbbbb'); + expect(theme.cursor).toBe('#cccccc'); + + term.dispose(); + }); + + test('theme reset to empty object restores defaults', async () => { + if (!container) return; + + const term = await createIsolatedTerminal({ + theme: { background: '#ff0000', foreground: '#00ff00' }, + }); + term.open(container); + + // @ts-ignore - accessing private for test + expect(term.renderer.theme.background).toBe('#ff0000'); + + // Reset to empty — should restore defaults + term.options.theme = {}; + + // @ts-ignore - accessing private for test + expect(term.renderer.theme.background).toBe('#1e1e1e'); + // @ts-ignore - accessing private for test + expect(term.renderer.theme.foreground).toBe('#d4d4d4'); + + term.dispose(); + }); + + test('theme reset to null restores defaults', async () => { + if (!container) return; + + const term = await createIsolatedTerminal({ + theme: { background: '#ff0000' }, + }); + term.open(container); + + // @ts-ignore - accessing private for test + expect(term.renderer.theme.background).toBe('#ff0000'); + + // Reset to null + term.options.theme = null as any; + + // @ts-ignore - accessing private for test + expect(term.renderer.theme.background).toBe('#1e1e1e'); + + term.dispose(); + }); + + test('theme change before open() is applied correctly', async () => { + if (!container) return; + + const term = await createIsolatedTerminal({ + theme: { background: '#111111' }, + }); + + // Change theme before open + term.options.theme = { background: '#222222' }; + + // Open — should use the latest theme + term.open(container); + + // The buildWasmConfig reads from options.theme which is now #222222 + // @ts-ignore - accessing private for test + expect(term.renderer.theme.background).toBe('#222222'); + + term.dispose(); + }); + + test('ANSI palette color cells re-resolve after theme change', async () => { + if (!container) return; + + const term = await createIsolatedTerminal({ + theme: { red: '#cd3131' }, + }); + term.open(container); + + // Write text with ANSI red (color index 1) + term.write('\x1b[31mRed text\x1b[0m'); + + // Change theme — new red + term.options.theme = { red: '#ff0000' }; + + // Force render state update and read cells + term.wasmTerm!.update(); + const line = term.wasmTerm!.getLine(0); + expect(line).not.toBeNull(); + + // First cell ('R') should now have the new red color + const cell = line![0]; + expect(cell.fg_r).toBe(0xff); + expect(cell.fg_g).toBe(0x00); + expect(cell.fg_b).toBe(0x00); + + term.dispose(); + }); + + test('explicit RGB color cells remain unchanged after theme change', async () => { + if (!container) return; + + const term = await createIsolatedTerminal(); + term.open(container); + + // Write text with explicit RGB color + term.write('\x1b[38;2;100;200;50mRGB text\x1b[0m'); + + // Change theme + term.options.theme = { + foreground: '#ffffff', + background: '#000000', + red: '#ff0000', + }; + + // Force render state update and read cells + term.wasmTerm!.update(); + const line = term.wasmTerm!.getLine(0); + expect(line).not.toBeNull(); + + // First cell ('R') should still have the explicit RGB color + const cell = line![0]; + expect(cell.fg_r).toBe(100); + expect(cell.fg_g).toBe(200); + expect(cell.fg_b).toBe(50); + + term.dispose(); + }); + + test('theme change triggers full redraw', async () => { + if (!container) return; + + const term = await createIsolatedTerminal(); + term.open(container); + + // Clear any existing dirty state + term.wasmTerm!.clearDirty(); + expect(term.wasmTerm!.needsFullRedraw()).toBe(false); + + // Change theme + term.options.theme = { background: '#ff0000' }; + + // Should need a full redraw + expect(term.wasmTerm!.needsFullRedraw()).toBe(true); + + // After clearing, no longer dirty + term.wasmTerm!.clearDirty(); + expect(term.wasmTerm!.needsFullRedraw()).toBe(false); + + term.dispose(); + }); + + test('invalid color values do not crash', async () => { + if (!container) return; + + const term = await createIsolatedTerminal(); + term.open(container); + + // Should not throw + term.options.theme = { + background: 'not-a-color', + foreground: 'rgb(999,0,0)', + red: '', + }; + + // @ts-ignore - accessing private for test + expect(term.renderer.theme.background).toBe('not-a-color'); + + term.dispose(); + }); + + test('default fg/bg cells update after theme change', async () => { + if (!container) return; + + const term = await createIsolatedTerminal({ + theme: { foreground: '#aaaaaa', background: '#111111' }, + }); + term.open(container); + + // Write text with default colors (no SGR) + term.write('Hello'); + + // Change theme + term.options.theme = { foreground: '#ffffff', background: '#000000' }; + + // Force render state update and read cells + term.wasmTerm!.update(); + const line = term.wasmTerm!.getLine(0); + expect(line).not.toBeNull(); + + // First cell ('H') should have new default foreground + const cell = line![0]; + expect(cell.fg_r).toBe(0xff); + expect(cell.fg_g).toBe(0xff); + expect(cell.fg_b).toBe(0xff); + + term.dispose(); + }); +}); diff --git a/lib/terminal.ts b/lib/terminal.ts index eeb7acd..4a43adf 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -29,12 +29,13 @@ import type { ITerminalAddon, ITerminalCore, ITerminalOptions, + ITheme, IUnicodeVersionProvider, } from './interfaces'; import { LinkDetector } from './link-detector'; import { OSC8LinkProvider } from './providers/osc8-link-provider'; import { UrlRegexProvider } from './providers/url-regex-provider'; -import { CanvasRenderer } from './renderer'; +import { CanvasRenderer, DEFAULT_THEME } from './renderer'; import { SelectionManager } from './selection-manager'; import type { ILink, ILinkProvider } from './types'; @@ -112,6 +113,9 @@ export class Terminal implements ITerminalCore { // Phase 1: Title tracking private currentTitle: string = ''; + // Accumulated theme state for partial merge support + private currentTheme: Required = { ...DEFAULT_THEME }; + // Phase 2: Viewport and scrolling state public viewportY: number = 0; // Top line of viewport in scrollback buffer (0 = at bottom, can be fractional during smooth scroll) private targetViewportY: number = 0; // Target viewport position for smooth scrolling @@ -171,6 +175,9 @@ export class Terminal implements ITerminalCore { this.cols = this.options.cols; this.rows = this.options.rows; + // Initialize accumulated theme (merge user theme with defaults) + this.currentTheme = { ...DEFAULT_THEME, ...options.theme }; + // Initialize buffer API this.buffer = new BufferNamespace(this); } @@ -201,8 +208,20 @@ export class Terminal implements ITerminalCore { break; case 'theme': - if (this.renderer) { - console.warn('ghostty-web: theme changes after open() are not yet fully supported'); + if (this.renderer && this.wasmTerm) { + // Merge partial theme with current accumulated theme. + // Null/undefined/empty resets to defaults. + const incoming = newValue && typeof newValue === 'object' ? newValue : {}; + const hasProperties = Object.keys(incoming).length > 0; + this.currentTheme = hasProperties + ? { ...this.currentTheme, ...incoming } + : { ...DEFAULT_THEME }; + + // Update renderer (selection, cursor, palette colors) + this.renderer.setTheme(this.currentTheme); + + // Update WASM terminal colors (for cell color re-resolution) + this.wasmTerm.setColors(this.buildThemeColorsConfig(this.currentTheme)); } break; @@ -326,6 +345,36 @@ export class Terminal implements ITerminalCore { }; } + /** + * Build a WASM colors config from a fully-resolved theme. + * Unlike buildWasmConfig(), all color values are valid (no sentinel). + */ + private buildThemeColorsConfig(theme: Required): GhosttyTerminalConfig { + return { + fgColor: this.parseColorToHex(theme.foreground), + bgColor: this.parseColorToHex(theme.background), + cursorColor: this.parseColorToHex(theme.cursor), + palette: [ + this.parseColorToHex(theme.black), + this.parseColorToHex(theme.red), + this.parseColorToHex(theme.green), + this.parseColorToHex(theme.yellow), + this.parseColorToHex(theme.blue), + this.parseColorToHex(theme.magenta), + this.parseColorToHex(theme.cyan), + this.parseColorToHex(theme.white), + this.parseColorToHex(theme.brightBlack), + this.parseColorToHex(theme.brightRed), + this.parseColorToHex(theme.brightGreen), + this.parseColorToHex(theme.brightYellow), + this.parseColorToHex(theme.brightBlue), + this.parseColorToHex(theme.brightMagenta), + this.parseColorToHex(theme.brightCyan), + this.parseColorToHex(theme.brightWhite), + ], + }; + } + // ========================================================================== // Lifecycle Methods // ========================================================================== diff --git a/lib/types.ts b/lib/types.ts index 4d6eefa..5d07ed3 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -413,6 +413,7 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { ghostty_terminal_free(terminal: TerminalHandle): void; ghostty_terminal_resize(terminal: TerminalHandle, cols: number, rows: number): void; ghostty_terminal_write(terminal: TerminalHandle, dataPtr: number, dataLen: number): void; + ghostty_terminal_set_colors(terminal: TerminalHandle, configPtr: number): void; // RenderState API - high-performance rendering (ONE call gets ALL data) ghostty_render_state_update(terminal: TerminalHandle): number; // 0=none, 1=partial, 2=full diff --git a/patches/ghostty-wasm-api.patch b/patches/ghostty-wasm-api.patch index bd3feb9..6ba636c 100644 --- a/patches/ghostty-wasm-api.patch +++ b/patches/ghostty-wasm-api.patch @@ -29,10 +29,10 @@ index 4f8fef88e..ca9fb1d4d 100644 #include diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h new file mode 100644 -index 000000000..c467102c3 +index 000000000..c0a9c6604 --- /dev/null +++ b/include/ghostty/vt/terminal.h -@@ -0,0 +1,285 @@ +@@ -0,0 +1,292 @@ +/** + * @file terminal.h + * @@ -142,6 +142,13 @@ index 000000000..c467102c3 +/** Write data to terminal (parses VT sequences) */ +void ghostty_terminal_write(GhosttyTerminal term, const uint8_t* data, size_t len); + ++/** ++ * Update terminal colors at runtime. ++ * All color values in the config are applied directly (no sentinel). ++ * Forces a full redraw on the next render cycle. ++ */ ++void ghostty_terminal_set_colors(GhosttyTerminal term, const GhosttyTerminalConfig* config); ++ +/* ============================================================================ + * RenderState API - High-performance rendering + * ========================================================================= */ @@ -319,10 +326,10 @@ index 000000000..c467102c3 + +#endif /* GHOSTTY_VT_TERMINAL_H */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig -index 03a883e20..1336676d7 100644 +index 03a883e20..9d74a46dc 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig -@@ -140,6 +140,45 @@ comptime { +@@ -140,6 +140,46 @@ comptime { @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); @@ -332,6 +339,7 @@ index 03a883e20..1336676d7 100644 + @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); + @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); + @export(&c.terminal_write, .{ .name = "ghostty_terminal_write" }); ++ @export(&c.terminal_set_colors, .{ .name = "ghostty_terminal_set_colors" }); + + // RenderState API - high-performance rendering + @export(&c.render_state_update, .{ .name = "ghostty_render_state_update" }); @@ -369,7 +377,7 @@ index 03a883e20..1336676d7 100644 // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig -index bc92597f5..d0ee49c1b 100644 +index bc92597f5..5814a0559 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -4,6 +4,7 @@ pub const key_event = @import("key_event.zig"); @@ -380,7 +388,7 @@ index bc92597f5..d0ee49c1b 100644 // The full C API, unexported. pub const osc_new = osc.new; -@@ -52,6 +53,46 @@ pub const key_encoder_encode = key_encode.encode; +@@ -52,6 +53,47 @@ pub const key_encoder_encode = key_encode.encode; pub const paste_is_safe = paste.is_safe; @@ -390,6 +398,7 @@ index bc92597f5..d0ee49c1b 100644 +pub const terminal_free = terminal.free; +pub const terminal_resize = terminal.resize; +pub const terminal_write = terminal.write; ++pub const terminal_set_colors = terminal.setColors; + +// RenderState API - high-performance rendering +pub const render_state_update = terminal.renderStateUpdate; @@ -427,7 +436,7 @@ index bc92597f5..d0ee49c1b 100644 test { _ = color; _ = osc; -@@ -59,6 +100,7 @@ test { +@@ -59,6 +101,7 @@ test { _ = key_encode; _ = paste; _ = sgr; @@ -437,10 +446,10 @@ index bc92597f5..d0ee49c1b 100644 _ = @import("../../lib/allocator.zig"); diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig new file mode 100644 -index 000000000..73ae2e6fa +index 000000000..1ce4f1919 --- /dev/null +++ b/src/terminal/c/terminal.zig -@@ -0,0 +1,1123 @@ +@@ -0,0 +1,1168 @@ +//! C API wrapper for Terminal +//! +//! This provides a minimal, high-performance interface to Ghostty's Terminal @@ -767,6 +776,8 @@ index 000000000..73ae2e6fa + response_buffer: std.ArrayList(u8), + /// Track alternate screen state to detect screen switches + last_screen_is_alternate: bool = false, ++ /// Force a full redraw on next render (e.g., after color change) ++ force_full_redraw: bool = false, +}; + +/// C-compatible cell structure (16 bytes) @@ -927,6 +938,47 @@ index 000000000..73ae2e6fa + wrapper.stream.nextSlice(data[0..len]) catch return; +} + ++/// Update terminal colors at runtime. All color values in the config are ++/// applied directly (no sentinel — 0x000000 is treated as valid black). ++/// Forces a full redraw on the next render cycle. ++pub fn setColors(ptr: ?*anyopaque, config_ptr: ?*const GhosttyTerminalConfig) callconv(.c) void { ++ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return)); ++ const cfg = config_ptr orelse return; ++ ++ // Update foreground ++ wrapper.terminal.colors.foreground = color.DynamicRGB.init(.{ ++ .r = @truncate((cfg.fg_color >> 16) & 0xFF), ++ .g = @truncate((cfg.fg_color >> 8) & 0xFF), ++ .b = @truncate(cfg.fg_color & 0xFF), ++ }); ++ ++ // Update background ++ wrapper.terminal.colors.background = color.DynamicRGB.init(.{ ++ .r = @truncate((cfg.bg_color >> 16) & 0xFF), ++ .g = @truncate((cfg.bg_color >> 8) & 0xFF), ++ .b = @truncate(cfg.bg_color & 0xFF), ++ }); ++ ++ // Update cursor ++ wrapper.terminal.colors.cursor = color.DynamicRGB.init(.{ ++ .r = @truncate((cfg.cursor_color >> 16) & 0xFF), ++ .g = @truncate((cfg.cursor_color >> 8) & 0xFF), ++ .b = @truncate(cfg.cursor_color & 0xFF), ++ }); ++ ++ // Update palette (all 16 colors, no sentinel) ++ for (cfg.palette, 0..) |palette_color, i| { ++ wrapper.terminal.colors.palette.set(@intCast(i), .{ ++ .r = @truncate((palette_color >> 16) & 0xFF), ++ .g = @truncate((palette_color >> 8) & 0xFF), ++ .b = @truncate(palette_color & 0xFF), ++ }); ++ } ++ ++ // Force full redraw on next render ++ wrapper.force_full_redraw = true; ++} ++ +// ============================================================================ +// RenderState API - High-performance rendering +// ============================================================================ @@ -941,17 +993,19 @@ index 000000000..73ae2e6fa + const screen_switched = current_is_alternate != wrapper.last_screen_is_alternate; + wrapper.last_screen_is_alternate = current_is_alternate; + -+ // When screen switches, we must fully reset the render state to avoid -+ // stale cached cell data from the previous screen buffer. -+ if (screen_switched) { ++ // When screen switches or colors change, we must fully reset the render ++ // state to avoid stale cached cell data. ++ const needs_full_reset = screen_switched or wrapper.force_full_redraw; ++ if (needs_full_reset) { + wrapper.render_state.deinit(wrapper.alloc); + wrapper.render_state = RenderState.empty; ++ wrapper.force_full_redraw = false; + } -+ ++ + wrapper.render_state.update(wrapper.alloc, &wrapper.terminal) catch return .full; -+ -+ // If screen switched, always return full dirty to force complete redraw -+ if (screen_switched) { ++ ++ // If we did a full reset, always return full dirty to force complete redraw ++ if (needs_full_reset) { + return .full; + } +