From 3f2d6f43199d0e230490396deb50f8dc49c7b8b0 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 1 Sep 2026 14:34:19 -0300 Subject: hosts: the effects three shells kept a copy of become one, and the mac's own bugs go with them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine read-only scouts compared every host-side concern across `src/macos.zig`, `src/tty/tty.zig`, `src/gui/gui.zig` and `src/detached/server.zig`. What they found was not a style problem: each duplicated body had drifted, and in every case the drift WAS a bug the users of that shell could see. So the fixes and the deduplication are the same change. **One PATH, adopted before the first fork.** LaunchServices hands a bundle launchd's environment, whose `PATH` is `/usr/bin:/bin:/usr/sbin:/sbin`. Every pty shell, `|` filter and language server the app forked inherited it, so `yazi` in `/opt/homebrew/bin` was absent from a Dock launch and present in the identical binary run from a terminal — the "it worked briefly" window was simply the sessions started from a shell. `shell_bin.adoptSystemPath` composes `/etc/paths` then `/etc/paths.d/*` in the order `path_helper` reads them, deduplicating on first occurrence, and runs once at startup in all four native hosts. It APPENDS: an entry already present keeps its position, so running it over a real session cannot demote a mise shim behind `/usr/bin` and silently change which `node` runs. A `PATH` that was configured is left byte-for-byte alone; only one nobody configured is repaired. `prepareForFork` folds that adoption together with the prompt-rc staging and the `BASH_SILENCE_DEPRECATION_WARNING` setenv the five hand-copied prefork sites had between them — `server.zig` had none of it, which is why every detached pane opened with Apple's zsh banner. **The LSP protocol client never worked on macOS.** It opened its control socket with `libc.SOCK.CLOEXEC`; Zig defines that constant for Linux and Darwin answers `socketpair` with `EPROTONOSUPPORT`, so the call failed before any fork, `ensure` returned `error.NoServer`, and every row in the spec table — rust-analyzer, clangd, gopls — was unreachable in every macOS build. The in-process ZLS backend kept answering, which is what made it read as "only Zig is supported". It is a plain socket plus `fcntl(FD_CLOEXEC)` now, the route `fuse.zig:943` and `nested.zig:95` already took for the same reason. The snapshot suite that covered this path had never run natively on a Mac: the harness targets defaulted to x86_64-linux. **One LSP host worker.** `src/lsp_host.zig` is the snapshot, the worker body and the job lifetime that `tty.zig` and `gui.zig` carried verbatim — `gui.zig` said so in a comment — and that `macos.zig` did not carry at all: `lsp` and `pipe` were absent from its `Host.VTable`, so the core answered its own empty answer, `SPC l i` rendered a blank panel and a `|` filter silently did nothing. All three shells share the module, and the AppKit host implements both effects. Its status sink is now REGISTERED as well as defined, so unsolicited server news reaches the message row instead of nowhere. **The animation clock measures time.** `pardes_animation_tick` advanced one scene frame per callback and published `frame_count / 60`, so scene time was a count of callbacks rather than elapsed seconds — and `AppDelegate` re-armed `asyncAfter(.now() + 0.016)` only after the previous frame's work had finished, making the true period 16 ms plus all of it. Motion ran at about three quarters of wall clock and unevenly. The tick now spends measured monotonic time in whole `frame_ns` steps and banks the remainder, so a late callback advances two frames instead of stretching one; `spendTickTime` is that arithmetic as a pure function with its own tests and no display attached. On macOS 14+ the animating run is one `CADisplayLink` phase-locked to vsync rather than a chain rebuilt after every frame; macOS 13 keeps the old chain. **Three more single definitions.** `panel_animation.paintOrder` is the moving-then-opening-then-closing composite order as a rule the core applies once in `Pardes.render` — `macos.zig` was re-sorting an already-sorted list. `selection_pipe.Tasks` is the bounded in-flight pipe table `tty.zig` and `gui.zig` each declared. `boxContains` was a fourth copy of the half-open cell test and is now an alias of `Box.contains`. **A filtered terminal stops asking libm per cell.** `Filter`'s legibility stage called `RGB.contrast` for every painted cell, and that ends in `std.math.pow` up to six times, re-deriving a ratio against a background that had not moved; the existing memo cache covered the palette reduction beside it and never this. The indexed path's input is a `u8`, so all 256 answers are enumerated once per pass — after the default roles are fixed, before the first cell is read — and what a cell names becomes an array index. Only truecolour still reduces. ReleaseFast, 190x56, Tracy: recolour 3.09 ms -> 0.130 ms, frame 3.37 ms -> 0.299 ms. The comptime luminance table is pinned to `RGB.luminance` and `RGB.contrast` by exact-equality test over every channel value and all 65 536 palette pairs, because the decision is a threshold comparison where one ULP is a different colour. A `filterInit` Tracy zone records the part that is still per-pass: 2.9 us warm against a 117 us pass, which is the measurement that says not to cache it across frames. Released as 0.0.2. `build.zig.zon` carries the version into `pardes --version` and into the `Changelog` pane through `@embedFile`, so the entries above open a `## 0.0.2` section and `## 0.0.1` closes with the tagline work of the parent commit. Two bugs here were mine, caught by review rather than by me: a double free in the macOS pipe drain arm (`Msg.free` already owns the response) that segfaulted the app on the first `|`, and a proposed `getRowAndCell` optimisation that targeted 2 of 43 draw samples while the contrast math beside it took 12 — and would not have compiled. The profile that justified it was a Debug build, which `build.zig:1160` already documents as ~5x slower than release. Native and -Dplatform=macos suites: 0 failures. All targets build with Tracy on and off; the shipped release binary contains no `___tracy_emit_zone_begin`. App reinstalled, signature verified, dmg regenerated, launched with 0 crash reports; installed binaries verified byte-identical to a fresh build. --- src/term_pane.zig | 361 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 331 insertions(+), 30 deletions(-) (limited to 'src/term_pane.zig') 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 }); -- cgit v1.3