diff options
Diffstat (limited to 'src/term_pane.zig')
| -rw-r--r-- | src/term_pane.zig | 361 |
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 }); |
