fix(core): support transparent background in embedded terminals (#1502)

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 <hello@simonklee.dk>
This commit is contained in:
Jensen
2026-09-17 18:47:10 +08:00
committed by GitHub
parent 871119e9e4
commit 9b8b4c853a
8 changed files with 106 additions and 4 deletions
@@ -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
@@ -13,6 +13,7 @@ export interface EmbeddedTerminalOptions extends RenderableOptions<EmbeddedTermi
cols?: number
rows?: number
maxScrollback?: number
transparentBackground?: boolean
onData?: (data: Uint8Array, source: EmbeddedTerminalDataSource) => 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)
+12
View File
@@ -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")),
@@ -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);
}
@@ -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 {
@@ -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;
}
@@ -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;
}
+6
View File
@@ -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);