mirror of
https://github.com/anomalyco/opentui.git
synced 2026-09-19 01:26:03 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user