From 8bd3a493be648aa7df12e45c531a7f30cffa6eb1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 21 Mar 2026 20:16:35 -0700 Subject: [PATCH] libghostty: add resolved bg_color and fg_color to cells API Fixes #11705 Add bg_color and fg_color options to GhosttyRenderStateRowCellsData that resolve the final RGB color for a cell, flattening the multiple possible sources. For background, this handles content-tag bg_color_rgb, content-tag bg_color_palette (looked up in the palette), and the style bg_color. For foreground, this resolves palette indices through the palette; bold color handling is not applied and is left to the caller. Both return GHOSTTY_INVALID_VALUE when no explicit color is set, in which case the caller should fall back to whatever default color it wants (e.g. the terminal background/foreground). --- include/ghostty/vt/render.h | 16 ++ src/config/Config.zig | 9 ++ src/renderer/generic.zig | 4 +- src/terminal/c/render.zig | 283 ++++++++++++++++++++++++++++++++++++ src/terminal/style.zig | 14 +- 5 files changed, 320 insertions(+), 6 deletions(-) diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index d8f6400a9..0a300dde0 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -514,6 +514,22 @@ typedef enum { * The buffer must be at least graphemes_len elements. The base codepoint * is written first, followed by any extra codepoints. */ GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF = 4, + + /** The resolved background color of the cell (GhosttyColorRgb). + * Flattens the three possible sources: content-tag bg_color_rgb, + * content-tag bg_color_palette (looked up in the palette), or the + * style's bg_color. Returns GHOSTTY_INVALID_VALUE if the cell has + * no background color, in which case the caller should use whatever + * default background color it wants (e.g. the terminal background). */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR = 5, + + /** The resolved foreground color of the cell (GhosttyColorRgb). + * Resolves palette indices through the palette. Bold color handling + * is not applied; the caller should handle bold styling separately. + * Returns GHOSTTY_INVALID_VALUE if the cell has no explicit foreground + * color, in which case the caller should use whatever default foreground + * color it wants (e.g. the terminal foreground). */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR = 6, } GhosttyRenderStateRowCellsData; /** diff --git a/src/config/Config.zig b/src/config/Config.zig index 675dbcde3..d32740783 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -48,6 +48,7 @@ const string = @import("string.zig"); const terminal = struct { const CursorStyle = @import("../terminal/cursor.zig").Style; const color = @import("../terminal/color.zig"); + const style = @import("../terminal/style.zig"); const x11_color = @import("../terminal/x11_color.zig"); }; @@ -5597,6 +5598,14 @@ pub const BoldColor = union(enum) { color: Color, bright, + /// Convert to the terminal-native BoldColor type. + pub fn toTerminal(self: BoldColor) terminal.style.Style.BoldColor { + return switch (self) { + .color => |col| .{ .color = col.toTerminalRGB() }, + .bright => .bright, + }; + } + pub fn parseCLI(input_: ?[]const u8) !BoldColor { const input = input_ orelse return error.ValueRequired; if (std.mem.eql(u8, input, "bright")) return .bright; diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index ff632f64a..0f4a294bc 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -554,7 +554,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { search_foreground: configpkg.Config.TerminalColor, search_selected_background: configpkg.Config.TerminalColor, search_selected_foreground: configpkg.Config.TerminalColor, - bold_color: ?configpkg.BoldColor, + bold_color: ?terminal.Style.BoldColor, faint_opacity: u8, min_contrast: f32, padding_color: configpkg.WindowPaddingColor, @@ -619,7 +619,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .background = config.background.toTerminalRGB(), .foreground = config.foreground.toTerminalRGB(), - .bold_color = config.@"bold-color", + .bold_color = if (config.@"bold-color") |b| b.toTerminal() else null, .faint_opacity = @intFromFloat(@ceil(config.@"faint-opacity" * 255)), .min_contrast = @floatCast(config.@"minimum-contrast"), diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index 0daf6abbe..f8a107979 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -33,6 +33,10 @@ const RowIteratorWrapper = struct { raws: []const page.Row, cells: []const std.MultiArrayList(renderpkg.RenderState.Cell), dirty: []bool, + + /// The color palette from the render state, needed to resolve + /// palette-indexed background colors on cells. + palette: *const colorpkg.Palette, }; const RowCellsWrapper = struct { @@ -41,6 +45,9 @@ const RowCellsWrapper = struct { raws: []const page.Cell, graphemes: []const []const u21, styles: []const Style, + + /// The color palette, needed to resolve palette-indexed background colors. + palette: *const colorpkg.Palette, }; /// C: GhosttyRenderState @@ -214,6 +221,7 @@ fn getTyped( .raws = row_data.items(.raw), .cells = row_data.items(.cells), .dirty = row_data.items(.dirty), + .palette = &state.state.colors.palette, }; }, .color_background => out.* = state.state.colors.background.cval(), @@ -361,6 +369,7 @@ pub fn row_iterator_new( .raws = undefined, .cells = undefined, .dirty = undefined, + .palette = undefined, }; result.* = ptr; return .success; @@ -395,6 +404,7 @@ pub fn row_cells_new( .raws = undefined, .graphemes = undefined, .styles = undefined, + .palette = undefined, }; result.* = ptr; return .success; @@ -428,6 +438,8 @@ pub const RowCellsData = enum(c_int) { style = 2, graphemes_len = 3, graphemes_buf = 4, + bg_color = 5, + fg_color = 6, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: RowCellsData) type { @@ -437,6 +449,7 @@ pub const RowCellsData = enum(c_int) { .style => style_c.Style, .graphemes_len => u32, .graphemes_buf => u32, + .bg_color, .fg_color => colorpkg.RGB.C, }; } }; @@ -494,6 +507,17 @@ fn rowCellsGetTyped( buf[i] = cp; } }, + .bg_color => { + const s: Style = if (cell.hasStyling()) cells.styles[x] else .{}; + const bg = s.bg(&cell, cells.palette) orelse return .invalid_value; + out.* = bg.cval(); + }, + .fg_color => { + const s: Style = if (cell.hasStyling()) cells.styles[x] else .{}; + if (s.fg_color == .none) return .invalid_value; + const fg = s.fg(.{ .default = .{}, .palette = cells.palette }); + out.* = fg.cval(); + }, } return .success; @@ -570,6 +594,7 @@ fn rowGetTyped( .raws = cell_data.items(.raw), .graphemes = cell_data.items(.grapheme), .styles = cell_data.items(.style), + .palette = it.palette, }; }, } @@ -1069,6 +1094,264 @@ test "render: colors get" { } } +test "render: row cells bg_color no background" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + // Write plain text (no background color set). + terminal_c.vt_write(terminal, "hello", 5); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib_alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib_alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + try testing.expect(row_cells_next(cells)); + + // No background set, should return invalid_value. + var bg: colorpkg.RGB.C = undefined; + try testing.expectEqual(Result.invalid_value, row_cells_get(cells, .bg_color, @ptrCast(&bg))); +} + +test "render: row cells bg_color from style" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + // Set an RGB background via SGR 48;2;R;G;B and write text. + terminal_c.vt_write(terminal, "\x1b[48;2;10;20;30mA", 18); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib_alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib_alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + try testing.expect(row_cells_next(cells)); + + var bg: colorpkg.RGB.C = undefined; + try testing.expectEqual(Result.success, row_cells_get(cells, .bg_color, @ptrCast(&bg))); + try testing.expectEqual(@as(u8, 10), bg.r); + try testing.expectEqual(@as(u8, 20), bg.g); + try testing.expectEqual(@as(u8, 30), bg.b); +} + +test "render: row cells bg_color from content tag" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + // Set an RGB background and then erase the line. The erased cells + // should carry the background color via the content tag (bg_color_rgb) + // rather than through the style. + terminal_c.vt_write(terminal, "\x1b[48;2;10;20;30m\x1b[2K", 21); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib_alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib_alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + try testing.expect(row_cells_next(cells)); + + var bg: colorpkg.RGB.C = undefined; + try testing.expectEqual(Result.success, row_cells_get(cells, .bg_color, @ptrCast(&bg))); + try testing.expectEqual(@as(u8, 10), bg.r); + try testing.expectEqual(@as(u8, 20), bg.g); + try testing.expectEqual(@as(u8, 30), bg.b); +} + +test "render: row cells fg_color no foreground" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + // Write plain text (no foreground color set). + terminal_c.vt_write(terminal, "hello", 5); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib_alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib_alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + try testing.expect(row_cells_next(cells)); + + // No foreground set, should return invalid_value. + var fg: colorpkg.RGB.C = undefined; + try testing.expectEqual(Result.invalid_value, row_cells_get(cells, .fg_color, @ptrCast(&fg))); +} + +test "render: row cells fg_color from style" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + // Set an RGB foreground via SGR 38;2;R;G;B and write text. + terminal_c.vt_write(terminal, "\x1b[38;2;10;20;30mA", 18); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib_alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib_alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + try testing.expect(row_cells_next(cells)); + + var fg: colorpkg.RGB.C = undefined; + try testing.expectEqual(Result.success, row_cells_get(cells, .fg_color, @ptrCast(&fg))); + try testing.expectEqual(@as(u8, 10), fg.r); + try testing.expectEqual(@as(u8, 20), fg.g); + try testing.expectEqual(@as(u8, 30), fg.b); +} + test "render: colors get supports truncated sized struct" { var terminal: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( diff --git a/src/terminal/style.zig b/src/terminal/style.zig index 7908beefa..ae9488af8 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -1,6 +1,5 @@ const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; -const configpkg = @import("../config.zig"); const color = @import("color.zig"); const sgr = @import("sgr.zig"); const page = @import("page.zig"); @@ -126,6 +125,13 @@ pub const Style = struct { }; } + /// The color to use for bold text. This avoids a dependency on the + /// config module by using terminal-native color types. + pub const BoldColor = union(enum) { + color: color.RGB, + bright, + }; + pub const Fg = struct { /// The default color to use if the style doesn't specify a /// foreground color and no configuration options override @@ -137,7 +143,7 @@ pub const Style = struct { palette: *const color.Palette, /// If specified, the color to use for bold text. - bold: ?configpkg.BoldColor = null, + bold: ?BoldColor = null, }; /// Returns the fg color for a cell with this style given the palette @@ -155,7 +161,7 @@ pub const Style = struct { if (self.flags.bold) { if (opts.bold) |bold| switch (bold) { .bright => {}, - .color => |v| break :default v.toTerminalRGB(), + .color => |v| break :default v, }; } @@ -178,7 +184,7 @@ pub const Style = struct { .rgb => |rgb| rgb: { if (self.flags.bold and rgb.eql(opts.default)) { if (opts.bold) |bold| switch (bold) { - .color => |v| break :rgb v.toTerminalRGB(), + .color => |v| break :rgb v, .bright => {}, }; }