summaryrefslogtreecommitdiff
path: root/src/term_pane.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-18 17:53:18 -0300
committerGabriel Schneider <[email protected]>2026-08-18 23:45:58 -0300
commitdba1ab69e5f38d8daf9312ab2d343b36fa078162 (patch)
tree463e64302f3f287d4d963c0d32f9b8a880696a94 /src/term_pane.zig
parent58aae9a1f7aca8e64814c8775b0c17bf93f4da0f (diff)
downloadpardes-dba1ab69e5f38d8daf9312ab2d343b36fa078162.tar.gz
pardes-dba1ab69e5f38d8daf9312ab2d343b36fa078162.zip
term_pane + builtins: terminal pane work, builtins/config additions, snapshots
Diffstat (limited to 'src/term_pane.zig')
-rw-r--r--src/term_pane.zig416
1 files changed, 408 insertions, 8 deletions
diff --git a/src/term_pane.zig b/src/term_pane.zig
index adba1cd6..cc0cc24e 100644
--- a/src/term_pane.zig
+++ b/src/term_pane.zig
@@ -22,6 +22,75 @@ const dump = @import("dump.zig");
pub const history_max = 64; // snapshots copy the whole edit buffer; keep this tighter than files
+const GColor = ghostty_vt.color;
+
+/// The inputs which completely determine a filtered terminal palette. Theme
+/// names and indices are intentionally absent: ThemeFile may replace a theme
+/// in place under the same name, while equal colour values need no rebuild.
+const FilterPaletteKey = struct {
+ bg: GColor.RGB,
+ fg: GColor.RGB,
+ base: [16]GColor.RGB,
+};
+
+/// Cached 256-colour projection of the current Pardes theme. Ghostty owns the
+/// interpolation: its CIELAB cube and greyscale ramp give every xterm key a
+/// theme-derived RGB value while retaining the conventional dark-to-light
+/// index orientation on light themes (`harmonious = false`).
+pub const FilterPalette = struct {
+ key: ?FilterPaletteKey = null,
+ colors: GColor.Palette = GColor.default,
+
+ fn get(self: *FilterPalette, theme: *const pardes.Theme) *const GColor.Palette {
+ const bg = asGhostRgb(theme.bg orelse theme.tag_bg);
+ const fg = asGhostRgb(theme.fg orelse theme.tag_fg);
+ var base: [16]GColor.RGB = undefined;
+ if (theme.palette) |palette| {
+ for (&base, palette) |*dst, src| dst.* = asGhostRgb(src);
+ } else {
+ // A theme without an ANSI table still supplies every key. The
+ // first eight keep the usual semantic families; their bright
+ // partners use the same accents plus the theme's lighter chrome.
+ const synthesized = [16][3]u8{
+ theme.bg orelse theme.tag_bg,
+ theme.kw,
+ theme.str,
+ theme.num,
+ theme.box,
+ theme.sel_bg,
+ theme.comment,
+ theme.fg orelse theme.tag_fg,
+ theme.lineno,
+ theme.kw,
+ theme.str,
+ theme.num,
+ theme.scroll_thumb,
+ theme.sel_fg,
+ theme.tag_fg,
+ theme.fg orelse theme.tag_fg,
+ };
+ for (&base, synthesized) |*dst, src| dst.* = asGhostRgb(src);
+ }
+
+ const key: FilterPaletteKey = .{ .bg = bg, .fg = fg, .base = base };
+ if (self.key) |old| if (std.meta.eql(old, key)) return &self.colors;
+
+ var seed = GColor.default;
+ for (base, 0..) |rgb, i| seed[i] = rgb;
+ self.colors = GColor.generate256Color(seed, .initEmpty(), bg, fg, false);
+ self.key = key;
+ return &self.colors;
+ }
+};
+
+fn asGhostRgb(rgb: [3]u8) GColor.RGB {
+ return .{ .r = rgb[0], .g = rgb[1], .b = rgb[2] };
+}
+
+fn asPardesColor(rgb: GColor.RGB) pardes.Color {
+ return .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
+}
+
/// The owned text standing in for `rows` live terminal rows, beginning at
/// absolute surface row `row`. The emulator grid remains untouched underneath.
pub const EditBuffer = struct {
@@ -152,6 +221,9 @@ pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane {
.gpa = gpa,
.cols = cols,
.rows = rows,
+ // Real terminals start theme-keyed. Document panes use the separate
+ // 1x1 stub constructor and retain Pane's inert false default.
+ .tty_filter = true,
};
pane.stream = pane.vt.vtStream();
// Answer terminal queries (DSR/DA/kitty) back into the pty, else
@@ -891,6 +963,11 @@ pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bo
const s = &p.surface;
const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset);
const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ var filtered_storage: FilteredColors = undefined;
+ const filtered: ?*FilteredColors = if (pane.tty_filter) blk: {
+ filtered_storage = FilteredColors.init(p, pane);
+ break :blk &filtered_storage;
+ } else null;
var vr: u16 = 0;
while (vr < body_h and vr < pane.rows) : (vr += 1) {
const grid = pane.gridRow(off + @as(i32, vr)) - goff;
@@ -901,19 +978,29 @@ pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bo
var c: u16 = 0;
while (c < tw) : (c += 1) {
const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(grid) } }) orelse continue;
- if (ci.cell.wide == .spacer_tail) continue;
+ // bodyText already gave a wide glyph's empty surface tail the
+ // head's body style. Ordinary ANSI projection historically leaves
+ // that alone; Filter must replace it too or one half of the cell
+ // can retain a host/default colour (Ghostty gives the tail the
+ // same style id as its wide head).
+ if (ci.cell.wide == .spacer_tail and filtered == null) continue;
const cell = s.at(tx + c, body_y + vr);
- if (cell.default) continue;
- cell.style = cellStyle(p, ci);
+ // In ordinary ANSI mode retain the old sparse projection: bodyText
+ // already painted every visible glyph. A filter paints the whole
+ // VT viewport so even empty/default and background-only cells are
+ // RGB-keyed by the theme rather than leaking the host defaults.
+ if (cell.default and filtered == null) continue;
+ cell.default = false;
+ cell.style = cellStyle(p, ci, filtered);
}
}
}
-pub fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell) pardes.CellStyle {
+fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors) pardes.CellStyle {
const style = ci.style();
var cs: pardes.CellStyle = .{
- .fg = ghostColor(p, style.fg_color, false),
- .bg = ghostColor(p, style.bg_color, true),
+ .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, false),
+ .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, true),
.bold = style.flags.bold,
.dim = style.flags.faint,
.italic = style.flags.italic,
@@ -930,17 +1017,330 @@ pub fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell) pardes.CellStyle {
.dashed => .dashed,
},
};
- switch (ci.cell.content_tag) {
+ // Style.bg above already resolves Ghostty's color-only cell variants for
+ // the filtered path. Preserve the established direct translation outside
+ // it, where indexed colours are intentionally allowed to reach the host.
+ if (filtered == null) switch (ci.cell.content_tag) {
.bg_color_palette => cs.bg = palColor(p, ci.cell.content.color_palette.data),
.bg_color_rgb => {
const rgb = ci.cell.content.color_rgb;
cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
},
else => {},
- }
+ };
return cs;
}
+/// Per-render resolver. The source palette is materialized through Ghostty's
+/// public xterm API, so OSC 4 changes participate without reaching into the
+/// emulator's private state. Truecolour and visually overridden entries are
+/// reduced to the nearest canonical Ghostty palette key; the key then indexes
+/// the theme palette. Repeated RGBs pay that search only once per frame.
+const FilteredColors = struct {
+ source: GColor.Palette,
+ target: *const GColor.Palette,
+ theme_bg: GColor.RGB,
+ theme_fg: GColor.RGB,
+ dynamic_bg: ?GColor.RGB,
+ dynamic_fg: ?GColor.RGB,
+ // Direct-mapped rather than append-only: a frame which encounters more
+ // than the cache's capacity must not strand every later (and repeated)
+ // colour on the 256-entry nearest-key scan. The RGB hash spreads the
+ // common 6x6x6 cube values instead of keying on their low bits.
+ cache_rgb: [256]GColor.RGB = undefined,
+ cache_key: [256]u8 = undefined,
+ cache_valid: [256]bool = @splat(false),
+
+ fn init(p: *Pardes, pane: *const Pane) FilteredColors {
+ var source = GColor.default;
+ for (&source, 0..) |*rgb, i|
+ rgb.* = pane.vt.colorForXterm(.{ .palette = @intCast(i) }) orelse rgb.*;
+ const theme = p.theme();
+ var theme_bg = asGhostRgb(theme.bg orelse theme.tag_bg);
+ var theme_fg = asGhostRgb(theme.fg orelse theme.tag_fg);
+ var dynamic_bg = pane.vt.colorForXterm(.{ .dynamic = .background });
+ var dynamic_fg = pane.vt.colorForXterm(.{ .dynamic = .foreground });
+ // DECSCNM swaps only the terminal's default color roles; explicit SGR
+ // colors stay explicit. Reuse Ghostty's parsed mode instead of trying
+ // to infer the escape from cells, just as its renderer does.
+ if (pane.vt.modes.get(.reverse_colors)) {
+ std.mem.swap(GColor.RGB, &theme_bg, &theme_fg);
+ std.mem.swap(?GColor.RGB, &dynamic_bg, &dynamic_fg);
+ }
+ return .{
+ .source = source,
+ .target = p.tty_filter_palette.get(theme),
+ .theme_bg = theme_bg,
+ .theme_fg = theme_fg,
+ .dynamic_bg = dynamic_bg,
+ .dynamic_fg = dynamic_fg,
+ };
+ }
+
+ fn fg(self: *FilteredColors, style: ghostty_vt.Style) pardes.Color {
+ const resolved = style.fg(.{
+ .default = self.dynamic_fg orelse self.theme_fg,
+ .palette = &self.source,
+ .bold = null,
+ });
+ return switch (style.fg_color) {
+ .none => if (self.dynamic_fg) |rgb| self.keyed(rgb) else asPardesColor(self.theme_fg),
+ .palette => |idx| self.palette(idx, resolved),
+ .rgb => self.keyed(resolved),
+ };
+ }
+
+ fn bg(self: *FilteredColors, style: ghostty_vt.Style, cell: *const ghostty_vt.Cell) pardes.Color {
+ const resolved = style.bg(cell, &self.source);
+ return switch (cell.content_tag) {
+ .bg_color_palette => self.palette(cell.content.color_palette.data, resolved.?),
+ .bg_color_rgb => self.keyed(resolved.?),
+ else => switch (style.bg_color) {
+ .none => if (self.dynamic_bg) |rgb| self.keyed(rgb) else asPardesColor(self.theme_bg),
+ .palette => |idx| self.palette(idx, resolved.?),
+ .rgb => self.keyed(resolved.?),
+ },
+ };
+ }
+
+ /// Preserve an ordinary indexed colour's semantic key. A value changed by
+ /// OSC 4 instead carries arbitrary RGB intent, so key that RGB the same way
+ /// as truecolour. Setting an entry to its exact original value is visually
+ /// indistinguishable and correctly takes this fast path.
+ fn palette(self: *FilteredColors, idx: u8, current: GColor.RGB) pardes.Color {
+ if (current.eql(GColor.default[idx])) return asPardesColor(self.target[idx]);
+ return self.keyed(current);
+ }
+
+ fn keyed(self: *FilteredColors, rgb: GColor.RGB) pardes.Color {
+ const key = self.nearestKey(rgb);
+ return asPardesColor(self.target[key]);
+ }
+
+ fn nearestKey(self: *FilteredColors, rgb: GColor.RGB) u8 {
+ const rgb24 = (@as(u32, rgb.r) << 16) | (@as(u32, rgb.g) << 8) | rgb.b;
+ const slot: u8 = @truncate((rgb24 *% 0x9e3779b1) >> 24);
+ if (self.cache_valid[slot] and self.cache_rgb[slot].eql(rgb))
+ return self.cache_key[slot];
+
+ var best: u8 = 0;
+ var best_distance: u32 = std.math.maxInt(u32);
+ for (GColor.default, 0..) |candidate, i| {
+ const distance = colorDistance(rgb, candidate);
+ // Strict comparison makes duplicate-colour ties stable at the
+ // lowest canonical xterm key.
+ if (distance < best_distance) {
+ best_distance = distance;
+ best = @intCast(i);
+ }
+ }
+ self.cache_rgb[slot] = rgb;
+ self.cache_key[slot] = best;
+ self.cache_valid[slot] = true;
+ return best;
+ }
+};
+
+fn colorDistance(a: GColor.RGB, b: GColor.RGB) u32 {
+ const dr = @as(i32, a.r) - @as(i32, b.r);
+ const dg = @as(i32, a.g) - @as(i32, b.g);
+ const db = @as(i32, a.b) - @as(i32, b.b);
+ return @intCast(dr * dr + dg * dg + db * db);
+}
+
+test "terminal Filter keys indexed truecolor OSC and background-only cells through the theme" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ try testing.expect(pane.tty_filter);
+ pane.tty_filter = false;
+
+ // 1: ANSI base key; 196: extended key; true red exactly matches canonical
+ // key 196. The two backgrounds repeat key 25 as indexed and truecolour.
+ // Erase-to-EOL under that background makes Ghostty color-only cells.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mA" ++
+ "\x1b[38;5;196mB" ++
+ "\x1b[38;2;255;0;0mC" ++
+ "\x1b[0;48;5;25mD" ++
+ "\x1b[0;48;2;0;95;175mE" ++
+ "\x1b[0;1;2;3;4;5;7;8;9mF" ++
+ "\x1b[0;48;5;25m\x1b[K" ++
+ "\r\n\x1b[0;38;5;2m界" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ const raw = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .index = 1 }, raw.at(tx, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .index = 196 }, raw.at(tx + 1, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .rgb = .{ 255, 0, 0 } }, raw.at(tx + 2, body_y).style.fg);
+
+ pane.tty_filter = true;
+ _ = frame.reset(.retain_capacity);
+ const filtered = try p.render(frame.allocator());
+ var expected_cache: FilterPalette = .{};
+ const expected = expected_cache.get(p.theme());
+ try testing.expectEqual(asPardesColor(expected[1]), filtered.at(tx, body_y).style.fg);
+ try testing.expectEqual(asPardesColor(expected[196]), filtered.at(tx + 1, body_y).style.fg);
+ try testing.expectEqual(filtered.at(tx + 1, body_y).style.fg, filtered.at(tx + 2, body_y).style.fg);
+ try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 3, body_y).style.bg);
+ try testing.expectEqual(filtered.at(tx + 3, body_y).style.bg, filtered.at(tx + 4, body_y).style.bg);
+
+ const attrs = filtered.at(tx + 5, body_y).style;
+ try testing.expect(attrs.bold);
+ try testing.expect(attrs.dim);
+ try testing.expect(attrs.italic);
+ try testing.expect(attrs.blink);
+ try testing.expect(attrs.reverse);
+ try testing.expect(attrs.invisible);
+ try testing.expect(attrs.strikethrough);
+ try testing.expectEqual(.single, attrs.ul);
+
+ const erased = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = 6, .y = 0 } }).?;
+ try testing.expectEqual(.bg_color_palette, erased.cell.content_tag);
+ try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 6, body_y).style.bg);
+ try testing.expectEqual(asPardesColor(expected[2]), filtered.at(tx, body_y + 1).style.fg);
+ try testing.expectEqual(filtered.at(tx, body_y + 1).style, filtered.at(tx + 1, body_y + 1).style);
+ // A filtered terminal never delegates either colour to a backend palette,
+ // including cells which were empty/default before the pass.
+ for (0..r.w - config.GUTTER) |col| {
+ const cell = filtered.at(tx + @as(u16, @intCast(col)), body_y);
+ try testing.expect(!cell.default);
+ switch (cell.style.fg) {
+ .rgb => {},
+ else => return error.FilteredForegroundWasNotRgb,
+ }
+ switch (cell.style.bg) {
+ .rgb => {},
+ else => return error.FilteredBackgroundWasNotRgb,
+ }
+ }
+
+ // Colors remains the global master gate. The pane remembers Filter while
+ // ANSI projection is dormant, and resumes it without replaying VT bytes.
+ p.settings.colors = false;
+ _ = frame.reset(.retain_capacity);
+ const plain = try p.render(frame.allocator());
+ try testing.expect(pane.tty_filter);
+ try testing.expectEqual(asPardesColor(asGhostRgb(p.theme().fg.?)), plain.at(tx, body_y).style.fg);
+ p.settings.colors = true;
+
+ // OSC 4 changes the value behind an existing indexed cell. Filter treats
+ // that arbitrary value like truecolour, while toggling remains purely a
+ // presentation operation and cannot alter Ghostty's query answer.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]4;1;#ff0000\x1b\\" } });
+ const osc_red = pane.vt.colorForXterm(.{ .palette = 1 }).?;
+ try testing.expect(osc_red.eql(.{ .r = 255, .g = 0, .b = 0 }));
+ pane.tty_filter = false;
+ pane.tty_filter = true;
+ try testing.expect(osc_red.eql(pane.vt.colorForXterm(.{ .palette = 1 }).?));
+ _ = frame.reset(.retain_capacity);
+ const osc_palette = try p.render(frame.allocator());
+ try testing.expectEqual(asPardesColor(expected[196]), osc_palette.at(tx, body_y).style.fg);
+
+ // Dynamic default foreground/background colours key every default cell,
+ // including the otherwise blank end of the row.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]10;#ff0000\x1b\\" ++
+ "\x1b]11;#5f5f5f\x1b\\" } });
+ const dyn_fg = pane.vt.colorForXterm(.{ .dynamic = .foreground }).?;
+ const dyn_bg = pane.vt.colorForXterm(.{ .dynamic = .background }).?;
+ try testing.expect(dyn_fg.eql(.{ .r = 255, .g = 0, .b = 0 }));
+ try testing.expect(dyn_bg.eql(.{ .r = 95, .g = 95, .b = 95 }));
+ _ = frame.reset(.retain_capacity);
+ const dynamic = try p.render(frame.allocator());
+ const blank = dynamic.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
+ try testing.expectEqual(asPardesColor(expected[196]), blank.fg);
+ try testing.expectEqual(asPardesColor(expected[59]), blank.bg);
+
+ // Ghostty owns DEC reverse-screen parsing. Filter follows that mode for
+ // the dynamic/default roles while leaving an explicit ANSI foreground on
+ // an existing cell bound to the same semantic palette key.
+ const explicit_before_reverse = dynamic.at(tx, body_y).style.fg;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } });
+ _ = frame.reset(.retain_capacity);
+ const reversed = try p.render(frame.allocator());
+ const reversed_blank = reversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
+ try testing.expectEqual(asPardesColor(expected[59]), reversed_blank.fg);
+ try testing.expectEqual(asPardesColor(expected[196]), reversed_blank.bg);
+ try testing.expectEqual(explicit_before_reverse, reversed.at(tx, body_y).style.fg);
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5l" } });
+ _ = frame.reset(.retain_capacity);
+ const unreversed = try p.render(frame.allocator());
+ const unreversed_blank = unreversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
+ try testing.expectEqual(asPardesColor(expected[196]), unreversed_blank.fg);
+ try testing.expectEqual(asPardesColor(expected[59]), unreversed_blank.bg);
+
+ // The cache is keyed by values, not a theme name. Replacing a custom
+ // theme in place immediately recolours already-rendered indexed cells.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]104;1\x1b\\" } });
+ var custom = p.theme().*;
+ custom.name = try p.gpa.dupe(u8, "same-name");
+ custom.palette = null;
+ custom.kw = .{ 1, 2, 3 };
+ p.custom_theme = custom;
+ p.custom_theme_active = true;
+ _ = frame.reset(.retain_capacity);
+ const custom_first = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .rgb = .{ 1, 2, 3 } }, custom_first.at(tx, body_y).style.fg);
+ if (p.custom_theme) |*theme| theme.kw = .{ 4, 5, 6 };
+ _ = frame.reset(.retain_capacity);
+ const custom_second = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .rgb = .{ 4, 5, 6 } }, custom_second.at(tx, body_y).style.fg);
+}
+
+test "terminal Filter keeps extended keys dark-to-light on a light theme" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ // Curated order is a public theme contract: helix, dark, acme.
+ p.settings.theme = 2;
+ try std.testing.expectEqualStrings("acme", p.theme().name);
+ var cache: FilterPalette = .{};
+ const palette = cache.get(p.theme());
+ try std.testing.expect(palette[16].eql(asGhostRgb(p.theme().fg.?)));
+ try std.testing.expect(palette[231].eql(asGhostRgb(p.theme().bg.?)));
+}
+
+test "terminal Filter preserves exact palette-null light theme default roles" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 5 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ var light = p.theme().*;
+ light.name = try p.gpa.dupe(u8, "filter-light-defaults");
+ light.bg = .{ 0xf8, 0xf8, 0xf8 };
+ light.fg = .{ 0x38, 0x38, 0x38 };
+ light.palette = null;
+ p.custom_theme = light;
+ p.custom_theme_active = true;
+
+ const pane = p.panes[0].?;
+ try testing.expectEqual(@as(?GColor.RGB, null), pane.vt.colorForXterm(.{ .dynamic = .foreground }));
+ try testing.expectEqual(@as(?GColor.RGB, null), pane.vt.colorForXterm(.{ .dynamic = .background }));
+ pane.tty_filter = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const ordinary = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, ordinary.at(tx, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, ordinary.at(tx, body_y).style.bg);
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } });
+ _ = frame.reset(.retain_capacity);
+ const reversed = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, reversed.at(tx, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, reversed.at(tx, body_y).style.bg);
+}
+
pub fn palColor(p: *Pardes, idx: u8) pardes.Color {
if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] };
return .{ .index = idx };