summaryrefslogtreecommitdiff
path: root/src/term_pane.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/term_pane.zig')
-rw-r--r--src/term_pane.zig361
1 files changed, 331 insertions, 30 deletions
diff --git a/src/term_pane.zig b/src/term_pane.zig
index 75229e4e..17e50c88 100644
--- a/src/term_pane.zig
+++ b/src/term_pane.zig
@@ -28,6 +28,7 @@ const EditText = pardes.EditText;
const modal = @import("modal.zig");
const config = @import("config.zig");
const dump = @import("dump.zig");
+const tracy = @import("tracy.zig"); // no-op unless -Dtracy names a checkout
/// `pardes.terminal_panes`, re-exported so every gate in this file reads one
/// local name. When false the import below is a DEAD comptime branch, so
@@ -1478,6 +1479,13 @@ pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bo
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: {
+ // Enumerating the indexed answers is the whole cost of the filter that
+ // is NOT per cell, so it gets to be visible on its own: this is the
+ // number that says whether the tables should be cached across frames
+ // rather than rebuilt per pass. Measured at 2.9 us warm against a
+ // 117 us `paneRecolor`, which is why they are not.
+ const tz_filter = tracy.zone(@src(), "filterInit");
+ defer tz_filter.end();
filtered_storage = FilteredColors.init(p, pane);
break :blk &filtered_storage;
} else null;
@@ -1720,11 +1728,22 @@ fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColor
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
+/// Per-render resolver, in two stages. The source palette is materialized
+/// through Ghostty's public xterm API, so OSC 4 changes participate without
+/// reaching into the emulator's private state.
+///
+/// STAGE ONE is the default foreground and background roles, because they are
+/// the anchors: Ghostty generates the whole 256-colour projection from them,
+/// and `default_bg` below is the page every other colour is judged against.
+///
+/// STAGE TWO is everything else. 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.
+/// the theme palette. Repeated RGBs pay that search only once per frame. A
+/// FOREGROUND additionally has to clear `config.tty_filter_min_contrast`
+/// against `default_bg` — the reduction is an RGB distance and knows nothing
+/// about the page, and the projection's cube corners are the anchors
+/// themselves, so without the gate the nearest key to a truecolour extreme is
+/// the background and the text is painted in the colour of the page.
const FilteredColors = struct {
source: GColor.Palette,
target: *const GColor.Palette,
@@ -1732,6 +1751,35 @@ const FilteredColors = struct {
theme_fg: GColor.RGB,
dynamic_bg: ?GColor.RGB,
dynamic_fg: ?GColor.RGB,
+ /// Stage one's background, mapped: exactly what `bg` answers for a cell
+ /// that names no colour of its own, and therefore the page a foreground
+ /// has to stay legible against.
+ default_bg: GColor.RGB,
+ /// What a foreground too near `default_bg` becomes instead.
+ fallback_fg: GColor.RGB,
+ /// `default_bg`'s luminance, computed once. `legible` runs per CELL and
+ /// asks for the contrast ratio against this same colour every time; the
+ /// half of the ratio that belongs to the background never changes.
+ default_bg_luminance: f64,
+ /// Every answer the INDEXED path can give, resolved before the first
+ /// cell is read.
+ ///
+ /// A cell that names a palette colour has 256 possible inputs, and this
+ /// filter is a pure function of them: the OSC 4 comparison, the theme
+ /// projection and the contrast gate all depend only on the index and on
+ /// state that is fixed for the whole pass. So the per-cell chain
+ /// collapses to one array read, and `legible` - six libm `pow` calls
+ /// through `RGB.contrast`, which profiling put at 12 of 43 draw samples
+ /// - stops being a per-cell cost entirely.
+ ///
+ /// Only TRUECOLOUR still searches: it carries arbitrary RGB, so its
+ /// answers cannot be enumerated and the direct-mapped cache below is
+ /// what keeps it cheap.
+ fg_for_palette: [256]pardes.Color = undefined,
+ bg_for_palette: [256]pardes.Color = undefined,
+ /// The two answers for a cell that names no colour of its own.
+ fg_default: pardes.Color = undefined,
+ bg_default: pardes.Color = undefined,
// 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
@@ -1756,54 +1804,96 @@ const FilteredColors = struct {
std.mem.swap(GColor.RGB, &theme_bg, &theme_fg);
std.mem.swap(?GColor.RGB, &dynamic_bg, &dynamic_fg);
}
- return .{
+ var self: FilteredColors = .{
.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,
+ .default_bg = theme_bg,
+ .fallback_fg = theme_fg,
+ .default_bg_luminance = luminanceOf(theme_bg),
};
+ // Stage one, finished before a single other colour is mapped. OSC 11
+ // moves the page, so the floor moves with it; the anchor that survives
+ // as the fallback is then whichever of the theme's own pair can still
+ // be seen on it, which on an untouched terminal is always the theme's
+ // foreground (a background has no contrast with itself).
+ if (dynamic_bg) |rgb| self.default_bg = self.keyedRgb(rgb);
+ self.default_bg_luminance = luminanceOf(self.default_bg);
+ if (self.theme_bg.contrast(self.default_bg) > self.theme_fg.contrast(self.default_bg))
+ self.fallback_fg = self.theme_bg;
+
+ // Stage two, ENUMERATED rather than answered per cell. Everything the
+ // indexed path needs is now fixed, and its input is a u8, so every
+ // answer it can ever give is computed here - once for the pass, not
+ // once for each of the tens of thousands of cells that will ask.
+ self.fg_default = asPardesColor(self.legible(
+ if (self.dynamic_fg) |rgb| self.keyedRgb(rgb) else self.theme_fg,
+ ));
+ self.bg_default = asPardesColor(self.default_bg);
+ for (&self.source, 0..) |current, i| {
+ const idx: u8 = @intCast(i);
+ const mapped = self.paletteRgb(idx, current);
+ self.fg_for_palette[idx] = asPardesColor(self.legible(mapped));
+ self.bg_for_palette[idx] = asPardesColor(mapped);
+ }
+ return self;
}
+ /// One array read for every colour a cell can NAME. Only truecolour,
+ /// whose 16.7M inputs cannot be enumerated, reaches the reduction - and
+ /// `style.fg` is now asked only on that path, because the other two
+ /// answers no longer depend on it.
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),
+ .none => self.fg_default,
+ .palette => |idx| self.fg_for_palette[idx],
+ .rgb => asPardesColor(self.legible(self.keyedRgb(style.fg(.{
+ .default = self.dynamic_fg orelse self.theme_fg,
+ .palette = &self.source,
+ .bold = null,
+ })))),
};
}
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.?),
+ switch (cell.content_tag) {
+ .bg_color_palette => return self.bg_for_palette[cell.content.color_palette.data],
+ .bg_color_rgb => {},
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.?),
+ .none => return self.bg_default,
+ .palette => |idx| return self.bg_for_palette[idx],
+ .rgb => {},
},
- };
+ }
+ // Truecolour, from either the cell or its style.
+ return asPardesColor(self.keyedRgb(style.bg(cell, &self.source).?));
+ }
+
+ /// Stage two's only rule, and a FOREGROUND rule: a background IS the page
+ /// for whatever is drawn over it, so holding one away from itself would be
+ /// meaningless. An ANSI black on a dark theme and a truecolour white on a
+ /// light one both reduce to the key whose projected value is the page —
+ /// ratio 1.000, invisible text — and both land here instead.
+ fn legible(self: *const FilteredColors, rgb: GColor.RGB) GColor.RGB {
+ if (contrastOf(luminanceOf(rgb), self.default_bg_luminance) >=
+ config.tty_filter_min_contrast) return rgb;
+ return self.fallback_fg;
}
/// 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 paletteRgb(self: *FilteredColors, idx: u8, current: GColor.RGB) GColor.RGB {
+ if (current.eql(GColor.default[idx])) return self.target[idx];
+ return self.keyedRgb(current);
}
- fn keyed(self: *FilteredColors, rgb: GColor.RGB) pardes.Color {
- const key = self.nearestKey(rgb);
- return asPardesColor(self.target[key]);
+ fn keyedRgb(self: *FilteredColors, rgb: GColor.RGB) GColor.RGB {
+ return self.target[self.nearestKey(rgb)];
}
fn nearestKey(self: *FilteredColors, rgb: GColor.RGB) u8 {
@@ -1830,6 +1920,46 @@ const FilteredColors = struct {
}
};
+/// W3C relative luminance per 8-bit channel, precomputed.
+///
+/// ghostty's `RGB.componentLuminance` ends in `std.math.pow(f64, x, 2.4)`
+/// (color.zig:474), `luminance` calls it three times, and `contrast` calls
+/// `luminance` for BOTH colours — so `legible`'s single `rgb.contrast(bg)` is
+/// up to six libm `pow` calls, per cell, per frame. Profiling the AppKit shell
+/// put `cellStyle -> FilteredColors.legible -> RGB.contrast` at 12 of 43 draw
+/// samples; the whole rest of `recolorAnsi` was 3.
+///
+/// The input is a `u8`. There are 256 possible answers. This is the table.
+///
+/// Bit-identical to ghostty's function by construction — same expression,
+/// evaluated at comptime — so the filter's decisions do not move. The
+/// equivalence test below pins that.
+const channel_luminance: [256]f64 = blk: {
+ @setEvalBranchQuota(20000);
+ var table: [256]f64 = undefined;
+ for (&table, 0..) |*slot, c| {
+ const normalized: f64 = @as(f64, @floatFromInt(c)) / 255;
+ slot.* = if (normalized <= 0.03928)
+ normalized / 12.92
+ else
+ std.math.pow(f64, (normalized + 0.055) / 1.055, 2.4);
+ }
+ break :blk table;
+};
+
+fn luminanceOf(rgb: GColor.RGB) f64 {
+ return 0.2126 * channel_luminance[rgb.r] +
+ 0.7152 * channel_luminance[rgb.g] +
+ 0.0722 * channel_luminance[rgb.b];
+}
+
+/// ghostty's `RGB.contrast` with both luminances already in hand.
+fn contrastOf(a_luminance: f64, b_luminance: f64) f64 {
+ const lighter = @max(a_luminance, b_luminance);
+ const darker = @min(a_luminance, b_luminance);
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
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);
@@ -1837,6 +1967,29 @@ fn colorDistance(a: GColor.RGB, b: GColor.RGB) u32 {
return @intCast(dr * dr + dg * dg + db * db);
}
+test "the luminance table answers exactly what ghostty computes" {
+ // The filter's decisions are a threshold comparison on these numbers, so
+ // "close enough" is not enough: one ULP either side of
+ // `tty_filter_min_contrast` is a different colour on screen. Every
+ // channel value, and the pairs a real pass actually asks about.
+ for (0..256) |i| {
+ const c: u8 = @intCast(i);
+ const grey: GColor.RGB = .{ .r = c, .g = c, .b = c };
+ try std.testing.expectEqual(grey.luminance(), luminanceOf(grey));
+ }
+ // Channel weights are asymmetric, so a grey ramp alone would not catch a
+ // transposed coefficient. The palette is what the tables enumerate.
+ for (GColor.default) |candidate| {
+ try std.testing.expectEqual(candidate.luminance(), luminanceOf(candidate));
+ for (GColor.default) |page| {
+ try std.testing.expectEqual(
+ candidate.contrast(page),
+ contrastOf(luminanceOf(candidate), luminanceOf(page)),
+ );
+ }
+ }
+}
+
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 });
@@ -1947,16 +2100,24 @@ test "terminal Filter keys indexed truecolor OSC and background-only cells throu
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.
+ // the dynamic/default roles, and here the swap turns this cell into a
+ // COLLISION: its explicit ANSI foreground is the OSC 4 red keyed to 196,
+ // and reverse video has just made that same red the page. Stage two
+ // refuses the mapping rather than painting red on red, so the ink becomes
+ // the anchor still visible on it — under the swap, the theme's own
+ // background colour. Unreversed, the very same cell keeps key 196.
const explicit_before_reverse = dynamic.at(tx, body_y).style.fg;
+ try testing.expectEqual(asPardesColor(expected[196]), explicit_before_reverse);
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);
+ const reversed_explicit = reversed.at(tx, body_y).style;
+ try testing.expectEqual(asPardesColor(expected[196]), reversed_explicit.bg);
+ try testing.expectEqual(pardes.Color{ .rgb = p.theme().bg.? }, reversed_explicit.fg);
+ try testing.expect(!std.meta.eql(reversed_explicit.fg, reversed_explicit.bg));
p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5l" } });
_ = frame.reset(.retain_capacity);
@@ -2029,6 +2190,146 @@ test "terminal Filter preserves exact palette-null light theme default roles" {
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);
}
+
+test "terminal Filter maps the default roles before it maps anything else" {
+ 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);
+
+ // Stage one is the two anchors, and they are taken from the theme WHOLE:
+ // a cell that names no colour of its own is not routed through the
+ // projection at all, so the page and the ink are exactly the theme's.
+ for (0..3) |t| {
+ p.settings.theme = @intCast(t);
+ const stage_one = FilteredColors.init(p, pane);
+ // `dark` declares no background of its own, which is exactly why the
+ // resolver reads the tag colours as the fallback rather than `.?`.
+ const theme = p.theme();
+ try testing.expect(stage_one.theme_bg.eql(asGhostRgb(theme.bg orelse theme.tag_bg)));
+ try testing.expect(stage_one.theme_fg.eql(asGhostRgb(theme.fg orelse theme.tag_fg)));
+ // With no OSC 11 in play the mapped page IS that anchor, and the
+ // fallback is the other one: a background never contrasts with itself.
+ try testing.expect(stage_one.default_bg.eql(stage_one.theme_bg));
+ try testing.expect(stage_one.fallback_fg.eql(stage_one.theme_fg));
+ }
+
+ // OSC 11 moves the page, and stage one moves with it: the reference the
+ // floor is measured against becomes the PROJECTED dynamic background, not
+ // the theme's, because that is what `bg` paints behind a default cell.
+ p.settings.theme = 0;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]11;#5f5f5f\x1b\\" } });
+ var moved = FilteredColors.init(p, pane);
+ try testing.expect(!moved.default_bg.eql(moved.theme_bg));
+ try testing.expect(moved.default_bg.eql(moved.keyedRgb(.{ .r = 0x5f, .g = 0x5f, .b = 0x5f })));
+}
+
+test "terminal Filter refuses a foreground that would collapse onto the page" {
+ 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);
+
+ // Two ways to land on the page, one per theme orientation. On the light
+ // theme the projection's white corner IS the paper, so a truecolour white
+ // reduces to it; on a dark theme the same is true of ANSI black, which a
+ // shell reaches for with a bare `\x1b[30m` and which takes the semantic
+ // fast path rather than the nearest-key scan. Both used to render text in
+ // the colour of the page under it.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[38;2;255;255;255mW" ++
+ "\x1b[0;30mB" ++
+ "\x1b[0;31mR" } });
+
+ 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;
+
+ for (0..3) |t| {
+ p.settings.theme = @intCast(t);
+ var fc = FilteredColors.init(p, pane);
+ const page = asPardesColor(fc.default_bg);
+ const rescued = asPardesColor(fc.fallback_fg);
+ _ = frame.reset(.retain_capacity);
+ const g = try p.render(frame.allocator());
+
+ // The colour each of the three would have been given with no floor.
+ const raw_white = fc.keyedRgb(.{ .r = 255, .g = 255, .b = 255 });
+ const raw_black = fc.paletteRgb(0, GColor.default[0]);
+ const raw_red = fc.paletteRgb(1, GColor.default[1]);
+
+ for ([_]struct { at: u16, raw: GColor.RGB }{
+ .{ .at = 0, .raw = raw_white },
+ .{ .at = 1, .raw = raw_black },
+ .{ .at = 2, .raw = raw_red },
+ }) |case| {
+ const cell = g.at(tx + case.at, body_y).style;
+ try testing.expectEqual(page, cell.bg);
+ if (case.raw.contrast(fc.default_bg) < config.tty_filter_min_contrast) {
+ // Refused: the projection's answer is not painted, the anchor is.
+ try testing.expectEqual(rescued, cell.fg);
+ try testing.expect(!std.meta.eql(cell.fg, cell.bg));
+ } else {
+ // Cleared the floor, so stage two leaves it exactly alone.
+ try testing.expectEqual(asPardesColor(case.raw), cell.fg);
+ }
+ // Either way a filtered cell delegates neither colour to a backend.
+ switch (cell.fg) {
+ .rgb => |ink| try testing.expect(asGhostRgb(ink).contrast(fc.default_bg) >=
+ config.tty_filter_min_contrast),
+ else => return error.FilteredForegroundWasNotRgb,
+ }
+ }
+
+ // At least one of the three has to have been a real collapse, or this
+ // theme proved nothing: white on the light theme, black on the dark.
+ try testing.expect(raw_white.contrast(fc.default_bg) < config.tty_filter_min_contrast or
+ raw_black.contrast(fc.default_bg) < config.tty_filter_min_contrast);
+ // A saturated red is never the page on any curated theme.
+ try testing.expect(raw_red.contrast(fc.default_bg) >= config.tty_filter_min_contrast);
+ }
+}
+
+test "terminal Filter holds every projected foreground off the page" {
+ 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].?;
+
+ // The invariant over the WHOLE projection rather than a sampled colour:
+ // whatever key a foreground reduces to, what stage two hands back clears
+ // the floor. A background is exempt by construction and must stay so —
+ // `bg` is what the floor is measured against.
+ for (0..3) |t| {
+ p.settings.theme = @intCast(t);
+ var fc = FilteredColors.init(p, pane);
+ var refused: usize = 0;
+ for (fc.target, 0..) |projected, key| {
+ const ink = fc.legible(projected);
+ try testing.expect(ink.contrast(fc.default_bg) >= config.tty_filter_min_contrast);
+ if (!ink.eql(projected)) {
+ refused += 1;
+ try testing.expect(ink.eql(fc.fallback_fg));
+ // Only ever refused for being too near the page.
+ try testing.expect(projected.contrast(fc.default_bg) < config.tty_filter_min_contrast);
+ }
+ // The key a background asks for is handed back untouched, including
+ // the one whose value is the page itself.
+ try testing.expect(fc.keyedRgb(GColor.default[key]).eql(fc.target[fc.nearestKey(GColor.default[key])]));
+ }
+ // Every curated theme owns at least one collapsing key — that is why
+ // the floor exists — and the floor must not be flattening the palette.
+ try testing.expect(refused > 0);
+ try testing.expect(refused < fc.target.len / 8);
+ }
+}
+
test "tty ansi colors follow the prompt hug into normal mode" {
const testing = std.testing;
const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });