From 9b8b4c853aba97f185efc9935519994e9675d84f Mon Sep 17 00:00:00 2001 From: Jensen Date: Thu, 17 Sep 2026 18:47:10 +0800 Subject: [PATCH] fix(core): support transparent background in embedded terminals (#1502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1501 Adds `transparentBackground` (default `false`) to `EmbeddedTerminalRenderable`. Enabled, cells with no explicit background compose with the `.default` intent (SGR 49), so the host terminal's background shows through; explicitly painted backgrounds stay opaque. Hot-switchable. Tests: native `tests.zig` (off → `.rgb`, on → `.default`, explicit unchanged) and `EmbeddedTerminal.test.ts` (off → on → off). Native suite 2,148 passed / 32 skipped; core file 32 passed; fmt and lint clean. --------- Co-authored-by: Simon Klee --- .../src/renderables/EmbeddedTerminal.test.ts | 15 ++++++++ .../core/src/renderables/EmbeddedTerminal.ts | 16 ++++++++ packages/core/src/zig.ts | 12 ++++++ .../src/embedded-terminal/compositor.zig | 15 ++++++-- .../native/src/embedded-terminal/main.zig | 8 +++- .../native/src/embedded-terminal/tests.zig | 37 +++++++++++++++++++ .../src/embedded-terminal/unavailable.zig | 1 + packages/native/src/lib.zig | 6 +++ 8 files changed, 106 insertions(+), 4 deletions(-) diff --git a/packages/core/src/renderables/EmbeddedTerminal.test.ts b/packages/core/src/renderables/EmbeddedTerminal.test.ts index 6075a6d3b..d4b96247e 100644 --- a/packages/core/src/renderables/EmbeddedTerminal.test.ts +++ b/packages/core/src/renderables/EmbeddedTerminal.test.ts @@ -50,6 +50,21 @@ describe("EmbeddedTerminalRenderable", () => { expect(setup.captureCharFrame()).toBe(first) }) + test("toggles the pane background between the configured color and the terminal default", async () => { + const terminal = new EmbeddedTerminalRenderable(setup.renderer, { width: 20, height: 4 }) + setup.renderer.root.add(terminal) + await setup.renderOnce() + expect(setup.captureSpans().lines[0]?.spans[0]?.bg.intent).toBe("rgb") + + terminal.transparentBackground = true + await setup.renderOnce() + expect(setup.captureSpans().lines[0]?.spans[0]?.bg.intent).toBe("default") + + terminal.transparentBackground = false + await setup.renderOnce() + expect(setup.captureSpans().lines[0]?.spans[0]?.bg.intent).toBe("rgb") + }) + test("preserves mouse callbacks and their renderable context", async () => { let mouseDowns = 0 let callbackThis: EmbeddedTerminalRenderable | undefined diff --git a/packages/core/src/renderables/EmbeddedTerminal.ts b/packages/core/src/renderables/EmbeddedTerminal.ts index a2941856b..8499deaf0 100644 --- a/packages/core/src/renderables/EmbeddedTerminal.ts +++ b/packages/core/src/renderables/EmbeddedTerminal.ts @@ -13,6 +13,7 @@ export interface EmbeddedTerminalOptions extends RenderableOptions void onTerminalResize?: (cols: number, rows: number) => void onScreenChange?: () => void @@ -48,6 +49,7 @@ export class EmbeddedTerminalRenderable extends Renderable { private keyreleaseHandler: ((key: KeyEvent) => void) | null = null private hadRenderHooks = false private selection = false + private _transparentBackground = false constructor(ctx: RenderContext, options: EmbeddedTerminalOptions) { const cols = options.cols ?? (typeof options.width === "number" ? options.width : 80) @@ -67,6 +69,7 @@ export class EmbeddedTerminalRenderable extends Renderable { try { this.handle = this.lib.createEmbeddedTerminal({ cols, rows, maxScrollback: options.maxScrollback }) + this.transparentBackground = options.transparentBackground ?? false this.setupMouse(options) } catch (error) { this.destroy() @@ -98,6 +101,19 @@ export class EmbeddedTerminalRenderable extends Renderable { this._onScreenChange = value } + public get transparentBackground(): boolean { + return this._transparentBackground + } + + public set transparentBackground(value: boolean) { + if (this._transparentBackground === value) return + this._transparentBackground = value + if (this.handle !== null) { + this.lib.embeddedTerminalSetTransparentBackground(this.handle, value) + this.requestRender() + } + } + public screen(): EmbeddedTerminalScreen { const cursor = this.handle ? this.lib.embeddedTerminalCursor(this.handle) diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index 60e537574..c712feecd 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -482,6 +482,10 @@ function getOpenTUILib(libPath?: string) { args: ["u32"], returns: "i32", }, + embeddedTerminalSetTransparentBackground: { + args: ["u32", "u8"], + returns: "i32", + }, embeddedTerminalScroll: { args: ["u32", "i32"], returns: "i32", @@ -3251,6 +3255,7 @@ export interface RenderLib extends AudioEngineLib { embeddedTerminalWrite: (handle: EmbeddedTerminalHandle, data: string | Uint8Array) => void embeddedTerminalResize: (handle: EmbeddedTerminalHandle, cols: number, rows: number) => void embeddedTerminalInvalidate: (handle: EmbeddedTerminalHandle) => void + embeddedTerminalSetTransparentBackground: (handle: EmbeddedTerminalHandle, transparent: boolean) => void embeddedTerminalScroll: (handle: EmbeddedTerminalHandle, delta: number) => void embeddedTerminalSetSelection: ( handle: EmbeddedTerminalHandle, @@ -3395,6 +3400,13 @@ class FFIRenderLib implements RenderLib { embeddedTerminalResult(this.opentui.symbols.embeddedTerminalInvalidate(handle), "invalidation") } + public embeddedTerminalSetTransparentBackground(handle: EmbeddedTerminalHandle, transparent: boolean): void { + embeddedTerminalResult( + this.opentui.symbols.embeddedTerminalSetTransparentBackground(handle, transparent ? 1 : 0), + "transparent background update", + ) + } + public embeddedTerminalScroll(handle: EmbeddedTerminalHandle, delta: number): void { embeddedTerminalResult( this.opentui.symbols.embeddedTerminalScroll(handle, embeddedTerminalI32(delta, "scroll delta")), diff --git a/packages/native/src/embedded-terminal/compositor.zig b/packages/native/src/embedded-terminal/compositor.zig index 56e991853..f0a575ff3 100644 --- a/packages/native/src/embedded-terminal/compositor.zig +++ b/packages/native/src/embedded-terminal/compositor.zig @@ -11,10 +11,16 @@ pub fn compose( target: *buffer.OptimizedBuffer, origin_x: i32, origin_y: i32, + transparent_background: bool, ) Error!void { const dirty = state.dirty; if (dirty == .false) return; + const default_bg = if (transparent_background) + ansi.defaultColor(state.colors.background.r, state.colors.background.g, state.colors.background.b, 255) + else + color(state.colors.background); + const rows = state.row_data.slice(); const row_dirty = rows.items(.dirty); const row_cells = rows.items(.cells); @@ -24,7 +30,7 @@ pub fn compose( const dest_y = origin_y + @as(i32, @intCast(y)); if (dest_y >= 0 and dest_y < target.getHeight()) { - clearRow(target, origin_x, @intCast(dest_y), state.cols, state.colors.foreground, state.colors.background); + clearRow(target, origin_x, @intCast(dest_y), state.cols, state.colors.foreground, default_bg); try composeRow( allocator, row_cells[y].slice(), @@ -33,6 +39,7 @@ pub fn compose( origin_x, @intCast(dest_y), &state.colors, + default_bg, ); } @@ -66,6 +73,7 @@ fn composeRow( origin_x: i32, dest_y: u32, colors: *const ghostty.RenderState.Colors, + default_bg: buffer.RGBA, ) Error!void { const raw_items = cells.items(.raw); const graphemes = cells.items(.grapheme); @@ -89,8 +97,8 @@ fn composeRow( const grapheme: []const u21 = if (raw.hasGrapheme()) graphemes[x] else &.{}; const style = if (raw.hasStyling()) styles[x] else @TypeOf(styles[x]){}; - var fg = style.fg(.{ .default = colors.foreground, .palette = &colors.palette }); - var bg = style.bg(&raw, &colors.palette) orelse colors.background; + var fg = color(style.fg(.{ .default = colors.foreground, .palette = &colors.palette })); + var bg = if (style.bg(&raw, &colors.palette)) |explicit| color(explicit) else default_bg; if (style.flags.inverse) std.mem.swap(@TypeOf(fg), &fg, &bg); if (selection) |range| { if (x < text_end and x + raw.gridWidth() > range[0] and x <= range[1]) std.mem.swap(@TypeOf(fg), &fg, &bg); @@ -151,6 +159,7 @@ fn draw(target: *buffer.OptimizedBuffer, text: []const u8, cell_width: u8, x: u3 } fn color(value: anytype) buffer.RGBA { + if (@TypeOf(value) == buffer.RGBA) return value; return ansi.rgbColor(value.r, value.g, value.b, 255); } diff --git a/packages/native/src/embedded-terminal/main.zig b/packages/native/src/embedded-terminal/main.zig index 10134e64a..ac557161e 100644 --- a/packages/native/src/embedded-terminal/main.zig +++ b/packages/native/src/embedded-terminal/main.zig @@ -39,6 +39,7 @@ pub const EmbeddedTerminal = struct { response_error: ?Error = null, mouse_last_cell: ?ghostty.Coordinate = null, force_redraw: bool = true, + transparent_background: bool = false, pub fn init(io: std.Io, allocator: std.mem.Allocator, options: Options) Error!*EmbeddedTerminal { if (options.cols == 0 or options.rows == 0) return error.InvalidValue; @@ -120,6 +121,11 @@ pub const EmbeddedTerminal = struct { self.force_redraw = true; } + pub fn setTransparentBackground(self: *EmbeddedTerminal, transparent: bool) void { + self.transparent_background = transparent; + self.force_redraw = true; + } + pub fn compose(self: *EmbeddedTerminal, target: *buffer.OptimizedBuffer, x: i32, y: i32) Error!void { self.render_state.update(self.allocator, &self.terminal) catch |err| { self.render_state.deinit(self.allocator); @@ -131,7 +137,7 @@ pub const EmbeddedTerminal = struct { self.render_state.dirty = .full; self.force_redraw = false; } - try compositor.compose(self.allocator, &self.render_state, target, x, y); + try compositor.compose(self.allocator, &self.render_state, target, x, y, self.transparent_background); } pub fn cursor(self: *EmbeddedTerminal) Cursor { diff --git a/packages/native/src/embedded-terminal/tests.zig b/packages/native/src/embedded-terminal/tests.zig index a2fce0460..cef1b683b 100644 --- a/packages/native/src/embedded-terminal/tests.zig +++ b/packages/native/src/embedded-terminal/tests.zig @@ -354,6 +354,43 @@ test "embedded terminal resets mouse motion deduplication after resize" { try std.testing.expect(after_resize.len > 0); } +test "embedded terminal composes a transparent default background as the terminal default" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + var target = try buffer.OptimizedBuffer.init(std.testing.allocator, 4, 1, .{ .pool = pool }); + defer target.deinit(); + + const terminal = try EmbeddedTerminal.init(std.testing.io, std.testing.allocator, .{ .cols = 4, .rows = 1 }); + defer terminal.deinit(); + try terminal.write("ab"); + + try terminal.compose(target, 0, 0); + try std.testing.expectEqual(ansi.ColorIntent.rgb, ansi.intent(target.get(0, 0).?.bg)); + + terminal.setTransparentBackground(true); + try terminal.compose(target, 0, 0); + // Text without an explicit background and the row tail cleared by clearRow both keep the intent. + try std.testing.expectEqual(ansi.ColorIntent.default, ansi.intent(target.get(0, 0).?.bg)); + try std.testing.expectEqual(ansi.ColorIntent.default, ansi.intent(target.get(3, 0).?.bg)); +} + +test "embedded terminal keeps explicit backgrounds opaque when transparent" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + var target = try buffer.OptimizedBuffer.init(std.testing.allocator, 4, 1, .{ .pool = pool }); + defer target.deinit(); + + const terminal = try EmbeddedTerminal.init(std.testing.io, std.testing.allocator, .{ .cols = 4, .rows = 1 }); + defer terminal.deinit(); + terminal.setTransparentBackground(true); + try terminal.write("\x1b[41mX"); + + try terminal.compose(target, 0, 0); + + try std.testing.expectEqual(@as(u32, 'X'), target.get(0, 0).?.char); + try std.testing.expect(ansi.intent(target.get(0, 0).?.bg) != ansi.ColorIntent.default); +} + comptime { _ = ghostty; } diff --git a/packages/native/src/embedded-terminal/unavailable.zig b/packages/native/src/embedded-terminal/unavailable.zig index c93bd654e..1ecfbba1b 100644 --- a/packages/native/src/embedded-terminal/unavailable.zig +++ b/packages/native/src/embedded-terminal/unavailable.zig @@ -35,6 +35,7 @@ pub const EmbeddedTerminal = struct { } pub fn freeSelectedText(_: *EmbeddedTerminal, _: [:0]const u8) void {} pub fn invalidate(_: *EmbeddedTerminal) void {} + pub fn setTransparentBackground(_: *EmbeddedTerminal, _: bool) void {} pub fn compose(_: *EmbeddedTerminal, _: *buffer.OptimizedBuffer, _: i32, _: i32) Error!void { return error.Unsupported; } diff --git a/packages/native/src/lib.zig b/packages/native/src/lib.zig index fa99d8b73..0c4392888 100644 --- a/packages/native/src/lib.zig +++ b/packages/native/src/lib.zig @@ -312,6 +312,12 @@ export fn embeddedTerminalInvalidate(handle: NativeHandle) i32 { return 0; } +export fn embeddedTerminalSetTransparentBackground(handle: NativeHandle, transparent: u8) i32 { + const terminal_value = acquireEmbeddedTerminal(handle) orelse return EmbeddedTerminalStatus.invalid; + terminal_value.setTransparentBackground(transparent != 0); + return 0; +} + export fn embeddedTerminalScroll(handle: NativeHandle, delta: i32) i32 { const terminal_value = acquireEmbeddedTerminal(handle) orelse return EmbeddedTerminalStatus.invalid; terminal_value.scroll(delta);