From 799bfc56f75535ad13089ee2fcbe907f91305ca3 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 20:30:29 -0300 Subject: A theme that names no tint for its tags' names takes one of its own hues, so every theme draws a file's name, a +Name and the Tty word off the tag's ink The faithful ports (neobones_dark, dracula, tokyonight, the *bones, ...), every helix import and any theme file set no tag_name_fg, and ChromeTheme fell back to the plain ink: their tags lost the accent the pardes themes have. The user's neobones_dark looked tinted before only because the Sep 24 build had no such theme and fell back to orchard. The tint is now derived when unset: the theme's keyword colour, else the first of its ANSI accents, syntax, grip, selection or diagnostic colours with a hue, placed at the lightness nearest the ink where the hue holds, at least 4.5:1 on its ground (half a point under the ink's own where the ink has less to spare) and 0.07 off the ink in OKLab. Each ground, plain and focused, gets its own. A theme that names its tint keeps it exactly. Tests: every theme's chrome keeps both tints off the ink and legible; every theme draws a file's basename, +Errors and Tty+sh in a tint, focused or not. Co-Authored-By: Claude Opus 5.5 --- src/colors.zig | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++------ src/draw.zig | 54 +++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/colors.zig b/src/colors.zig index 72e25aed..b6da0d2c 100644 --- a/src/colors.zig +++ b/src/colors.zig @@ -153,18 +153,22 @@ fn toOklab(rgb: [3]u8) [3]f32 { }; } -fn fromOklab(lab: [3]f32) [3]u8 { +fn oklabLinear(lab: [3]f32) [3]f32 { const lm = lab[0] + 0.3963377774 * lab[1] + 0.2158037573 * lab[2]; const mm = lab[0] - 0.1055613458 * lab[1] - 0.0638541728 * lab[2]; const sm = lab[0] - 0.0894841775 * lab[1] - 1.2914855480 * lab[2]; const l = lm * lm * lm; const m = mm * mm * mm; const s = sm * sm * sm; - const linear: [3]f32 = .{ + return .{ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s, }; +} + +fn fromOklab(lab: [3]f32) [3]u8 { + const linear = oklabLinear(lab); var out: [3]u8 = undefined; for (&out, linear) |*o, v| { const x = std.math.clamp(v, 0, 1); @@ -181,6 +185,72 @@ pub fn mixOklab(a: [3]u8, b: [3]u8, t: f32) [3]u8 { return fromOklab(.{ x[0] + (y[0] - x[0]) * t, x[1] + (y[1] - x[1]) * t, x[2] + (y[2] - x[2]) * t }); } +fn oklabDistance(a: [3]f32, b: [3]f32) f32 { + return @sqrt((a[0] - b[0]) * (a[0] - b[0]) + (a[1] - b[1]) * (a[1] - b[1]) + (a[2] - b[2]) * (a[2] - b[2])); +} + +/// How far a name's tint stands off its tag's ink in OKLab: a hue you see +/// at a glance, not a shade you look for. +pub const name_tint_distance: f32 = 0.07; +/// How much hue a colour needs to lend a name its tint. +const name_tint_chroma: f32 = 0.06; + +/// A name's tint for a theme that sets none (the faithful ports, the +/// imports, a theme file): a hue of the theme's own -- its keyword colour, +/// else the first of its ANSI accents, syntax, grip, selection or +/// diagnostic colours that has one -- at the lightness nearest the tag's +/// ink where that hue holds, the name reads on its ground (nameTintFloor) +/// and it stands off the ink. Null when no colour of the theme will do. +fn derivedNameTint(th: *const Theme, ink: [3]u8, ground: [3]u8) ?[3]u8 { + const pal = th.palette orelse [_][3]u8{th.kw} ** 16; + const sources = [_][3]u8{ th.kw, pal[4], pal[5], pal[6], pal[3], pal[2], pal[1], th.num, th.str, th.box } ++ + [_][3]u8{ th.column_box orelse th.num, th.box_dirty orelse th.num, th.sel_bg, th.search_bg orelse th.num } ++ + [_][3]u8{ th.diagnostic_info orelse th.num, th.diagnostic_hint orelse th.num, th.diagnostic_warning orelse th.num } ++ + // A theme with no hue of its own at all (flatwhite): acme's DMedblue's. + [_][3]u8{.{ 0x00, 0x00, 0x99 }}; + // Near the ink's lightness first, from any of the theme's hues; only + // then further off it. + for ([2]f32{ 0.15, 1 }) |reach| for (sources) |source| { + if (nameTintFrom(toOklab(source), ink, ground, reach)) |tint| return tint; + }; + return null; +} + +fn nameTintFrom(source: [3]f32, ink: [3]u8, ground: [3]u8, reach: f32) ?[3]u8 { + const chroma = @sqrt(source[1] * source[1] + source[2] * source[2]); + if (chroma < name_tint_chroma) return null; + const ink_lab = toOklab(ink); + const floor = nameTintFloor(ink, ground); + var step: f32 = 0; + while (step <= reach) : (step += 0.01) { + for ([2]f32{ 1, -1 }) |sign| { + const lightness = ink_lab[0] + sign * step; + if (lightness < 0 or lightness > 1) continue; + // As much of the hue as that lightness holds, up to a tint's. + var want = @min(chroma, 0.12); + while (want >= 0.04) : (want -= 0.01) { + const lab: [3]f32 = .{ lightness, source[1] / chroma * want, source[2] / chroma * want }; + const in_gamut = for (oklabLinear(lab)) |v| { + if (v < -0.001 or v > 1.001) break false; + } else true; + if (!in_gamut) continue; + const tint = fromOklab(lab); + if (contrast(tint, ground) < floor) continue; + if (oklabDistance(toOklab(tint), ink_lab) < name_tint_distance) continue; + return tint; + } + } + } + return null; +} + +/// What a name's tint must read at on its ground: 4.5:1, or within half a +/// point of the ink's own where the ink has less to spare (seoulbones_dark's +/// near-white on grey leaves no hue at the ink's 4.53:1). +pub fn nameTintFloor(ink: [3]u8, ground: [3]u8) f32 { + return @min(4.5, contrast(ink, ground) - 0.5); +} + test "an OKLab fade holds its ends exactly and keeps a mid-way bright" { for ([_][3]u8{ .{ 0, 0, 0 }, .{ 255, 255, 255 }, .{ 0x0c, 0x0c, 0x0e }, .{ 0xe8, 0xc4, 0x6a }, .{ 0x1c, 0x51, 0x72 } }) |rgb| { try std.testing.expectEqual(rgb, fromOklab(toOklab(rgb))); @@ -548,13 +618,21 @@ pub const ChromeTheme = struct { pub fn fromTheme(th: *const Theme) ChromeTheme { // The initial chrome is resolved at compile time: contrast takes pow. @setEvalBranchQuota(200_000); + const active_bg = focusTint(th); + const active_fg = th.tag_active_fg orelse th.tag_fg; + // A name is always tinted: a theme that names no tint gets one of + // its own hues, on each ground. One that names only the plain tint + // keeps it when focused too. + const name_fg = th.tag_name_fg orelse (derivedNameTint(th, th.tag_fg, th.tag_bg) orelse th.tag_fg); + const active_name_fg = th.tag_active_name_fg orelse + (th.tag_name_fg orelse (derivedNameTint(th, active_fg, active_bg) orelse active_fg)); return .{ .tag_bg = th.tag_bg, .tag_fg = th.tag_fg, - .tag_active_bg = focusTint(th), - .tag_active_fg = th.tag_active_fg orelse th.tag_fg, - .tag_name_fg = th.tag_name_fg orelse th.tag_fg, - .tag_active_name_fg = th.tag_active_name_fg orelse (th.tag_name_fg orelse (th.tag_active_fg orelse th.tag_fg)), + .tag_active_bg = active_bg, + .tag_active_fg = active_fg, + .tag_name_fg = name_fg, + .tag_active_name_fg = active_name_fg, .border = separatorOf(th), .empty_col = th.empty_col orelse separatorOf(th), .search_bg = th.search_bg orelse th.sel_bg, @@ -1108,8 +1186,9 @@ test "legacy ThemeFile documents inherit Pardes UI roles without new fields" { try std.testing.expectEqual(@as(?[3]u8, null), parsed.search_bg); const chrome = ChromeTheme.fromTheme(&parsed); try std.testing.expectEqual(parsed.tag_bg, chrome.tag_active_bg); - try std.testing.expectEqual(parsed.tag_fg, chrome.tag_name_fg); - try std.testing.expectEqual(chrome.tag_active_fg, chrome.tag_active_name_fg); + // Its names take a tint of its own hues, never the plain ink. + try std.testing.expect(!std.meta.eql(parsed.tag_fg, chrome.tag_name_fg)); + try std.testing.expect(!std.meta.eql(chrome.tag_active_fg, chrome.tag_active_name_fg)); try std.testing.expectEqual(parsed.num, chrome.column_box); try std.testing.expectEqual(mix(parsed.num, parsed.tag_bg), chrome.column_box_dim); try std.testing.expectEqual(parsed.sel_bg, chrome.search_bg); @@ -1148,8 +1227,9 @@ test "custom filename tints preserve optional active fallback precedence" { theme.tag_name_fg = null; theme.tag_active_name_fg = null; var chrome = ChromeTheme.fromTheme(&theme); - try std.testing.expectEqual(theme.tag_fg, chrome.tag_name_fg); - try std.testing.expectEqual(theme.tag_active_fg.?, chrome.tag_active_name_fg); + // Neither named: each derived, on its own ground. + try std.testing.expectEqual(derivedNameTint(&theme, theme.tag_fg, theme.tag_bg).?, chrome.tag_name_fg); + try std.testing.expectEqual(derivedNameTint(&theme, theme.tag_active_fg.?, chrome.tag_active_bg).?, chrome.tag_active_name_fg); theme.tag_name_fg = .{ 120, 130, 140 }; chrome = ChromeTheme.fromTheme(&theme); try std.testing.expectEqual(theme.tag_name_fg.?, chrome.tag_name_fg); @@ -1160,6 +1240,25 @@ test "custom filename tints preserve optional active fallback precedence" { try std.testing.expectEqual(theme.tag_active_name_fg.?, chrome.tag_active_name_fg); theme.tag_name_fg = null; chrome = ChromeTheme.fromTheme(&theme); - try std.testing.expectEqual(theme.tag_fg, chrome.tag_name_fg); + try std.testing.expectEqual(derivedNameTint(&theme, theme.tag_fg, theme.tag_bg).?, chrome.tag_name_fg); try std.testing.expectEqual(theme.tag_active_name_fg.?, chrome.tag_active_name_fg); } + +test "every theme tints its names off the tag's ink, and they read on both grounds" { + for (&themes) |*th| { + const chrome = ChromeTheme.fromTheme(th); + const pairs = [2][3][3]u8{ + .{ chrome.tag_fg, chrome.tag_name_fg, chrome.tag_bg }, + .{ chrome.tag_active_fg, chrome.tag_active_name_fg, chrome.tag_active_bg }, + }; + for (pairs) |pair| { + const ink, const name, const ground = pair; + const apart = oklabDistance(toOklab(ink), toOklab(name)); + const floor = nameTintFloor(ink, ground); + if (apart < name_tint_distance or contrast(name, ground) < floor - 0.01) + std.debug.print("{s}: name {x} on {x} is {d:.3} off the ink {x}, {d:.2}:1\n", .{ th.name, name, ground, apart, ink, contrast(name, ground) }); + try std.testing.expect(apart >= name_tint_distance); + try std.testing.expect(contrast(name, ground) >= floor - 0.01); + } + } +} diff --git a/src/draw.zig b/src/draw.zig index 9c9e11dd..10303117 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -1477,6 +1477,60 @@ test "a grip's four states, focused or not and clean or dirty, are each its own pane.file.?.revision = pane.file.?.saved_revision; } +test "every theme draws a file's name, a +Errors and a Tty word in a tint, never the tag's plain ink" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .cols = 120, .rows = 40, .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("text\n"); + try std.testing.expect(p.executeBuiltinLine(0, "Tty+sh")); + const terminal = p.active; + p.acknowledgeShell(terminal, "/bin/sh", false); + try @import("Output.zig").openErrors(p, 0, try gpa.dupe(u8, "oops\n")); + while (p.nextEffect()) |_| {} + p.animate_theme_changes = false; + const errors = for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + if (pane.file) |f| if (std.mem.endsWith(u8, f.path, "+Errors")) break id; + } else return error.NoErrorsPane; + const Probe = struct { id: usize, word: []const u8 }; + const probes = [_]Probe{ .{ .id = 0, .word = "test.txt" }, .{ .id = terminal, .word = "Tty+sh" }, .{ .id = errors, .word = "+Errors" } }; + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + for (pardes.themes, 0..) |theme, index| { + colors.setThemeIndex(p, index); + // Each pane focused in turn: every word on both grounds. + for (probes) |focused| { + p.active = focused.id; + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + for (probes) |probe| { + const r = p.rects[probe.id]; + const y = p.tagTop(p.panes[probe.id].?, r); + // The row's text, and the column each byte of it is drawn at. + var row: [512]u8 = undefined; + var cols: [512]u16 = undefined; + var len: usize = 0; + for (r.x..r.x + r.w) |x| { + const g = s.at(@intCast(x), y).grapheme(); + for (if (g.len == 0) " " else g) |byte| if (len < row.len) { + row[len] = byte; + cols[len] = @intCast(x); + len += 1; + }; + } + const name = std.mem.indexOf(u8, row[0..len], probe.word) orelse return error.WordNotDrawn; + const plain = std.mem.indexOf(u8, row[0..len], " Del") orelse return error.WordNotDrawn; + const tint = s.at(cols[name], y).style.fg; + const ink = s.at(cols[plain + 1], y).style.fg; + if (std.meta.eql(tint, ink)) std.debug.print("{s}: {s} drawn in the tag's ink\n", .{ theme.name, probe.word }); + try std.testing.expect(!std.meta.eql(tint, ink)); + // The whole word, in the one tint. + try std.testing.expect(std.meta.eql(s.at(cols[name + probe.word.len - 1], y).style.fg, tint)); + } + } + } +} + test "a tag of three rows has its grip on the first, and band under it that still grabs the pane" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); -- cgit v1.3