From 41bbbd05dccb02631ed785d19ed54fb4edfcc471 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 27 Sep 2026 21:49:28 -0300 Subject: Move colour themes out of pardes.zig into colors.zig Pure move, no behaviour change: the Theme type, the curated palettes, fold and the themes ring with its two comptime checks, themeContrast, mix, ChromeTheme, ChromeAnimation and initial_chrome, the Pardes methods that load theme files and switch themes (nextThemeFileGeneration, requestThemeFile, ThemeFileRequest, themeFileRequest, failThemeFile, loadThemeFile, finishThemeInitialization, invalidateThemeDependentRasters, setThemeIndex), and the ten theme tests go verbatim to colors.zig. The methods become free functions taking `p: *Pardes`; their call sites change from `p.setThemeIndex(i)` to `colors.setThemeIndex(p, i)` (37 of them, in pardes.zig, builtins.zig, file_watch.zig, macos.zig and two test files). pardes.zig keeps `pub const Theme/themes/native_theme_count/ ChromeTheme = colors.X;` for the shells that name them, and sync and enterTagEdit become pub because a moved test calls them. Co-Authored-By: Claude Opus 5.5 --- src/colors.zig | 681 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 681 insertions(+) create mode 100644 src/colors.zig (limited to 'src/colors.zig') diff --git a/src/colors.zig b/src/colors.zig new file mode 100644 index 00000000..d6e1d535 --- /dev/null +++ b/src/colors.zig @@ -0,0 +1,681 @@ +//! Colour themes: the Theme type, the curated and generated ring, chrome +//! colours and their animation, theme files, and switching themes at runtime. +const pardes = @import("pardes.zig"); +const panes = @import("panes.zig"); +const std = @import("std"); +const layout = @import("layout.zig"); +const Color = @import("surface.zig").Color; +const FontRole = @import("surface.zig").FontRole; +const config = @import("config.zig"); +const pdf_enabled = panes.Pdf.enabled; +const platform = pardes.platform; +const theme_animation = @import("pardes_config").theme_animation; +const hosted = pardes.hosted; +const MAX_PANES = pardes.MAX_PANES; +const BOX_H = pardes.BOX_H; +const Pardes = pardes.Pardes; + +pub fn mix(a: [3]u8, b: [3]u8) [3]u8 { + var out: [3]u8 = undefined; + for (&out, a, b) |*c, x, y| c.* = @intCast((@as(u16, x) + y) / 2); + return out; +} + +pub const Theme = struct { + name: []const u8, + bg: ?[3]u8, + fg: ?[3]u8, + sel_bg: [3]u8, + sel_fg: [3]u8, + tag_bg: [3]u8, + tag_fg: [3]u8, + box: [3]u8, // move box, focused pane + box_dim: [3]u8, // move box, everything else + kw: [3]u8, + str: [3]u8, + num: [3]u8, + comment: [3]u8, + lineno: [3]u8, + scroll_track: [3]u8, + scroll_thumb: [3]u8, + palette: ?[16][3]u8, + + // Pardes UI roles. Optional so existing ThemeFile documents and imported + // editor palettes keep working; ChromeTheme resolves their legacy defaults. + tag_active_bg: ?[3]u8 = null, + tag_active_fg: ?[3]u8 = null, + /// Filename/output-name accent, also used by the Tty command. + tag_name_fg: ?[3]u8 = null, + /// Active tint; absence preserves the shared name tint or active tag ink. + tag_active_name_fg: ?[3]u8 = null, + /// Column grips stay distinct from pane grips in both focus states. + column_box: ?[3]u8 = null, + column_box_dim: ?[3]u8 = null, + border: ?[3]u8 = null, + search_bg: ?[3]u8 = null, + search_fg: ?[3]u8 = null, + diagnostic_error: ?[3]u8 = null, + diagnostic_warning: ?[3]u8 = null, + diagnostic_info: ?[3]u8 = null, + diagnostic_hint: ?[3]u8 = null, + // The current line number is a quiet foreground cue, not a tag-colored + // background block. Older themes fall back to their normal gutter ink. + lineno_active: ?[3]u8 = null, +}; + +const curated = struct { + pub const orchard = @import("themes/orchard.zig"); + pub const dusk = @import("themes/dusk.zig"); + pub const ink = @import("themes/ink.zig"); + pub const paper = @import("themes/paper.zig"); + pub const daybreak = @import("themes/daybreak.zig"); + pub const atelier = @import("themes/atelier.zig"); + pub const forge = @import("themes/forge.zig"); + pub const lagoon = @import("themes/lagoon.zig"); + pub const solarium = @import("themes/solarium.zig"); + pub const spectrum = @import("themes/spectrum.zig"); + pub const harvest = @import("themes/harvest.zig"); + pub const clay = @import("themes/clay.zig"); + pub const forge_black = @import("themes/forge_black.zig"); + pub const forge_soft = @import("themes/forge_soft.zig"); + pub const orchard_black = @import("themes/orchard_black.zig"); + pub const helix = @import("themes/helix.zig"); + pub const dark = @import("themes/dark.zig"); + pub const acme = @import("themes/acme.zig"); +}; + +fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { + comptime { + @setEvalBranchQuota(400000); + var out: [@typeInfo(C).@"struct".decls.len]Theme = undefined; + for (@typeInfo(C).@"struct".decls, 0..) |d, i| { + var t: Theme = undefined; + const source = @field(C, d.name).theme; + for (@typeInfo(Theme).@"struct".fields) |f| { + @field(t, f.name) = if (@hasField(@TypeOf(source), f.name)) + @field(source, f.name) + else if (f.default_value_ptr) |ptr| + @as(*const f.type, @ptrCast(@alignCast(ptr))).* + else + @compileError("theme " ++ d.name ++ " is missing " ++ f.name); + } + out[i] = t; + } + return out; + } +} + +pub const themes = fold(curated) ++ fold(@import("generated_themes")); + +/// Original and classic-inspired Pardes palettes precede retained imports. +pub const native_theme_count = 15; + +fn themeContrast(a: [3]u8, b: [3]u8) f64 { + var luminance: [2]f64 = .{ 0, 0 }; + for ([2][3]u8{ a, b }, 0..) |rgb, i| { + for (rgb, [3]f64{ 0.2126, 0.7152, 0.0722 }) |channel, weight| { + const srgb = @as(f64, @floatFromInt(channel)) / 255; + const linear = if (srgb <= 0.04045) srgb / 12.92 else std.math.pow(f64, (srgb + 0.055) / 1.055, 2.4); + luminance[i] += weight * linear; + } + } + return (@max(luminance[0], luminance[1]) + 0.05) / (@min(luminance[0], luminance[1]) + 0.05); +} + +comptime { + if (themes.len > std.math.maxInt(u16) + 1) + @compileError("runtime config theme index no longer fits u16"); +} + +comptime { + @setEvalBranchQuota(20000); + const ours = fold(curated).len; + for (themes[0..ours]) |c| for (themes[ours..]) |g| if (std.mem.eql(u8, c.name, g.name)) + @compileError("theme name \"" ++ c.name ++ "\" is both ours and generated; rename the vendored source"); +} + +pub const ChromeTheme = struct { + tag_bg: [3]u8, + tag_fg: [3]u8, + tag_active_bg: [3]u8, + tag_active_fg: [3]u8, + tag_name_fg: [3]u8, + tag_active_name_fg: [3]u8, + border: [3]u8, + search_bg: [3]u8, + search_fg: [3]u8, + diagnostic_error: [3]u8, + diagnostic_warning: [3]u8, + diagnostic_info: [3]u8, + diagnostic_hint: [3]u8, + box: [3]u8, + box_dim: [3]u8, + column_box: [3]u8, + column_box_dim: [3]u8, + lineno: [3]u8, + scroll_track: [3]u8, + scroll_thumb: [3]u8, + + pub fn fromTheme(th: *const Theme) ChromeTheme { + return .{ + .tag_bg = th.tag_bg, + .tag_fg = th.tag_fg, + .tag_active_bg = th.tag_active_bg orelse th.tag_bg, + .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)), + .border = th.border orelse th.scroll_track, + .search_bg = th.search_bg orelse th.sel_bg, + .search_fg = th.search_fg orelse th.sel_fg, + .diagnostic_error = th.diagnostic_error orelse (th.fg orelse th.tag_fg), + .diagnostic_warning = th.diagnostic_warning orelse (th.fg orelse th.tag_fg), + .diagnostic_info = th.diagnostic_info orelse (th.fg orelse th.tag_fg), + .diagnostic_hint = th.diagnostic_hint orelse (th.fg orelse th.tag_fg), + .box = th.box, + .box_dim = th.box_dim, + .column_box = th.column_box orelse th.num, + .column_box_dim = th.column_box_dim orelse mix(th.column_box orelse th.num, th.tag_bg), + .lineno = th.lineno, + .scroll_track = th.scroll_track, + .scroll_thumb = th.scroll_thumb, + }; + } + + pub fn interpolate(from: ChromeTheme, to: ChromeTheme, step: u16, steps: u16) ChromeTheme { + var out: ChromeTheme = undefined; + inline for (@typeInfo(ChromeTheme).@"struct".fields) |field| + @field(out, field.name) = layout.Animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps); + return out; + } +}; + +pub const ChromeAnimation = if (theme_animation) + layout.Animation.Transition(ChromeTheme) +else + layout.Animation.Immediate(ChromeTheme); + +pub const initial_chrome = ChromeTheme.fromTheme(&themes[0]); + +fn nextThemeFileGeneration(p: *Pardes) u32 { + p.theme_file_generation +%= 1; + if (p.theme_file_generation == 0) p.theme_file_generation = 1; + return p.theme_file_generation; +} + +pub fn requestThemeFile(p: *Pardes, id: usize, argument: []const u8) void { + if (comptime !hosted) return; + const input = std.mem.trim(u8, argument, " \t\r\n"); + if (input.len == 0 or !std.mem.endsWith(u8, input, ".zon")) { + p.reportError(id, "theme file", error.InvalidThemePath); + return; + } + + const absolute = std.fs.path.isAbsolute(input); + if (!absolute and p.opts.config_dir == null) { + p.reportError(id, "theme file", error.NoConfigDirectory); + return; + } + + var joined: [4095]u8 = undefined; + const resolved = if (absolute) input else path: { + const dir = p.opts.config_dir.?; + const separator = std.Io.Dir.path.sep; + const needs_separator = dir.len > 0 and dir[dir.len - 1] != separator; + const len = dir.len + @intFromBool(needs_separator) + input.len; + if (len > joined.len) { + p.reportError(id, "theme file", error.PathTooLong); + return; + } + @memcpy(joined[0..dir.len], dir); + var at = dir.len; + if (needs_separator) { + joined[at] = separator; + at += 1; + } + @memcpy(joined[at..][0..input.len], input); + break :path joined[0..len]; + }; + if (!p.theme_file_path.set(resolved)) { + p.reportError(id, "theme file", error.PathTooLong); + return; + } + p.theme_file_pane = @intCast(@min(id, MAX_PANES - 1)); + const generation = nextThemeFileGeneration(p); + p.emit(.{ .theme_file = .{ .generation = generation, .on = true } }); +} + +pub const ThemeFileRequest = struct { + path: []const u8, + pane: u8, +}; + +/// Borrow the current request while a native host performs its synchronous +/// read. A later command invalidates an already-queued generation. +pub fn themeFileRequest(p: *const Pardes, generation: u32) ?ThemeFileRequest { + if (generation == 0 or generation != p.theme_file_generation) return null; + const path = p.theme_file_path.get(); + if (path.len == 0) return null; + return .{ .path = path, .pane = p.theme_file_pane }; +} + +pub fn failThemeFile(p: *Pardes, generation: u32, err: anyerror) void { + const request = themeFileRequest(p, generation) orelse return; + p.reportError(request.pane, "theme file", err); +} + +pub fn loadThemeFile(p: *Pardes, generation: u32, bytes: []const u8, animate: bool) bool { + // ZON's generated field dispatch grows with the complete theme schema. + @setEvalBranchQuota(10000); + const request = themeFileRequest(p, generation) orelse return false; + if (bytes.len > 1024 * 1024) { + p.reportError(request.pane, "theme file", error.ThemeFileTooLarge); + return false; + } + const source = p.gpa.dupeZ(u8, bytes) catch |err| { + p.reportError(request.pane, "theme file", err); + return false; + }; + defer p.gpa.free(source); + var diagnostics: std.zon.parse.Diagnostics = .{}; + defer diagnostics.deinit(p.gpa); + const parsed = std.zon.parse.fromSliceAlloc(Theme, p.gpa, source, &diagnostics, .{}) catch |err| { + p.reportError(request.pane, "theme file", err); + return false; + }; + if (parsed.name.len == 0 or parsed.name.len > 255) { + std.zon.parse.free(p.gpa, parsed); + p.reportError(request.pane, "theme file", error.InvalidThemeName); + return false; + } + + const target_chrome = ChromeTheme.fromTheme(&parsed); + if (animate and p.animate_theme_changes) + p.chrome_animation.retarget(target_chrome) + else + p.chrome_animation.snap(target_chrome); + const old = p.custom_theme; + p.custom_theme = parsed; + if (old) |theme_value| std.zon.parse.free(p.gpa, theme_value); + invalidateThemeDependentRasters(p); + _ = p.scratch.reset(.retain_capacity); + p.sync(); + return true; +} + +pub fn finishThemeInitialization(p: *Pardes) void { + p.chrome_animation.snap(ChromeTheme.fromTheme(p.theme())); + p.animate_theme_changes = true; +} + +fn invalidateThemeDependentRasters(p: *Pardes) void { + if (comptime pdf_enabled) for (p.panes) |slot| { + const pane = slot orelse continue; + const pv = &(pane.pdf orelse continue); + if (pv.tint != .disabled) pv.invalidateAllRasters(); + }; +} + +pub fn setThemeIndex(p: *Pardes, index: usize) void { + if (index >= themes.len or + (p.custom_theme == null and p.theme_file_path.get().len == 0 and + index == @as(usize, p.settings.theme))) return; + const target_chrome = ChromeTheme.fromTheme(&themes[index]); + if (p.animate_theme_changes) + p.chrome_animation.retarget(target_chrome) + else + p.chrome_animation.snap(target_chrome); + if (p.custom_theme) |theme_value| { + p.custom_theme = null; + std.zon.parse.free(p.gpa, theme_value); + } + if (p.theme_file_path.get().len > 0) { + p.theme_file_path.clear(); + const generation = nextThemeFileGeneration(p); + p.emit(.{ .theme_file = .{ .generation = generation, .on = false } }); + } + p.settings.theme = @intCast(index); + invalidateThemeDependentRasters(p); +} + +test "ThemeFile parses ZON atomically and Theme returns to the compiled ring" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ + .tty_only = true, + .config_dir = "/tmp/pardes-config", + }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + requestThemeFile(p, 0, "themes/mine.zon"); + const request_effect = while (p.nextEffect()) |effect| switch (effect) { + .theme_file => |request| break request, + else => {}, + } else return error.MissingThemeFileEffect; + try std.testing.expect(request_effect.on); + const request = themeFileRequest(p, request_effect.generation) orelse + return error.MissingThemeFileRequest; + try std.testing.expectEqualStrings("/tmp/pardes-config/themes/mine.zon", request.path); + + var custom = themes[0]; + custom.name = "mine"; + custom.bg = .{ 1, 2, 3 }; + custom.tag_bg = .{ 4, 5, 6 }; + var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer encoded.deinit(); + try std.zon.stringify.serialize(custom, .{ .whitespace = true }, &encoded.writer); + try std.testing.expect(loadThemeFile(p, request_effect.generation, encoded.written(), false)); + try std.testing.expectEqualStrings("mine", p.theme().name); + try std.testing.expectEqual(@as(?[3]u8, .{ 1, 2, 3 }), p.theme().bg); + try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); + + // A half-written save is rejected without disturbing the last good value. + try std.testing.expect(!loadThemeFile(p, request_effect.generation, ".{ .name = ", true)); + try std.testing.expectEqualStrings("mine", p.theme().name); + try std.testing.expectEqual(@as(?[3]u8, .{ 1, 2, 3 }), p.theme().bg); + + setThemeIndex(p, 0); + try std.testing.expectEqualStrings(themes[0].name, p.theme().name); + try std.testing.expect(themeFileRequest(p, request_effect.generation) == null); + const stop = while (p.nextEffect()) |effect| switch (effect) { + .theme_file => |theme_file| break theme_file, + else => {}, + } else return error.MissingThemeFileStop; + try std.testing.expect(!stop.on); +} + +test "runtime theme changes animate chrome and retarget without a jump" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const first = p.chromeTheme().*; + try std.testing.expect(!p.animationActive()); + + const dark_index = for (themes, 0..) |th, i| { + if (std.mem.eql(u8, th.name, "dark")) break i; + } else return error.MissingDarkTheme; + const acme_index = for (themes, 0..) |th, i| { + if (std.mem.eql(u8, th.name, "acme")) break i; + } else return error.MissingAcmeTheme; + + setThemeIndex(p, dark_index); + // Target semantics are immediate, while the displayed chrome begins at + // the exact previous frame. + try std.testing.expectEqualStrings("dark", p.theme().name); + try std.testing.expectEqual(first, p.chromeTheme().*); + try std.testing.expect(p.animationActive()); + p.update(.tick); + p.update(.tick); + p.update(.tick); + const midflight = p.chromeTheme().*; + try std.testing.expect(!std.meta.eql(first, midflight)); + + setThemeIndex(p, acme_index); + try std.testing.expectEqualStrings("acme", p.theme().name); + try std.testing.expectEqual(midflight, p.chromeTheme().*); + try std.testing.expect(p.animationActive()); + for (0..layout.Animation.transition_steps) |_| p.update(.tick); + try std.testing.expect(!p.animationActive()); + try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); + // Extra ticks are inert at the exact endpoint. + p.update(.tick); + try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); +} + +test "render uses target content colors while chrome is in flight" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + const source_chrome = p.chromeTheme().*; + const acme_index = for (themes, 0..) |th, i| { + if (std.mem.eql(u8, th.name, "acme")) break i; + } else return error.MissingAcmeTheme; + setThemeIndex(p, acme_index); + + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + const surface = try p.render(frame.allocator()); + // The anchored topbar has not advanced from its source frame. + try std.testing.expectEqual( + Color{ .rgb = source_chrome.tag_bg }, + surface.at(0, 0).style.bg, + ); + const topbar_cell = surface.at(0, 0); + try std.testing.expect(!std.mem.eql(u8, topbar_cell.grapheme(), " ")); + try std.testing.expectEqual(FontRole.tagline, topbar_cell.style.font_role); + const r = p.rects[0]; + const tag_cell = find_tag_cell: { + var x = r.x + config.GUTTER; + while (x < r.x + r.w) : (x += 1) { + const cell = surface.at(x, r.y); + if (!std.mem.eql(u8, cell.grapheme(), " ")) break :find_tag_cell cell; + } + return error.MissingTagText; + }; + try std.testing.expectEqual(FontRole.tagline, tag_cell.style.font_role); + try std.testing.expectEqual(FontRole.tagline, surface.at(r.x, r.y).style.font_role); + try std.testing.expectEqual(FontRole.tagline, surface.at(r.x + r.w - 1, r.y).style.font_role); + // The document/terminal page does not ride the chrome animation: acme's + // target background is already present in the same render. + const body = surface.at(r.x + r.w - 1, r.y + r.h - 1); + try std.testing.expectEqual(FontRole.body, body.style.font_role); + try std.testing.expectEqual( + Color{ .rgb = p.theme().bg.? }, + body.style.bg, + ); + try std.testing.expect(p.animationActive()); +} + +test "native themes keep text and UI roles readable on their own surfaces" { + const names = [_][]const u8{ "orchard", "dusk", "ink", "paper", "daybreak", "atelier", "forge", "lagoon", "solarium", "spectrum", "harvest", "clay", "forge_black", "forge_soft", "orchard_black" }; + try std.testing.expectEqual(names.len, native_theme_count); + for (themes[0..names.len], names) |th, name| { + try std.testing.expectEqualStrings(name, th.name); + const chrome = ChromeTheme.fromTheme(&th); + const minimum: f64 = if (std.mem.eql(u8, name, "ink") or std.mem.eql(u8, name, "daybreak")) 7 else 4.5; + const text_colors = [_][3]u8{ + th.fg.?, th.kw, th.str, th.num, th.comment, + chrome.diagnostic_error, chrome.diagnostic_warning, chrome.diagnostic_info, chrome.diagnostic_hint, + }; + for (text_colors) |fg| try std.testing.expect(themeContrast(fg, th.bg.?) >= minimum); + // Gutters are orientation aids, not source text: stay visible without + // competing with comments or pulling attention away from the body. + try std.testing.expect(themeContrast(th.lineno, th.bg.?) >= 2.5); + try std.testing.expect(themeContrast(th.lineno, th.bg.?) <= 4); + try std.testing.expect(themeContrast(th.lineno_active.?, th.bg.?) >= themeContrast(th.lineno, th.bg.?)); + try std.testing.expect(themeContrast(th.lineno_active.?, th.bg.?) <= 4.5); + try std.testing.expect(themeContrast(th.comment, th.bg.?) >= 5); + try std.testing.expect(themeContrast(th.comment, th.bg.?) >= themeContrast(th.lineno, th.bg.?) + 2); + try std.testing.expect(themeContrast(th.tag_fg, th.tag_bg) >= minimum); + try std.testing.expect(themeContrast(chrome.tag_active_fg, chrome.tag_active_bg) >= minimum); + try std.testing.expect(th.tag_name_fg != null); + try std.testing.expect(themeContrast(chrome.tag_name_fg, th.tag_bg) >= minimum); + try std.testing.expect(themeContrast(chrome.tag_active_name_fg, chrome.tag_active_bg) >= minimum); + // Focus stays on the same tonal surface, rather than inverting a + // whole command strip. Text remains readable in both states. + try std.testing.expect(themeContrast(chrome.tag_active_bg, th.tag_bg) <= 1.6); + try std.testing.expect(themeContrast(chrome.tag_active_fg, th.tag_fg) <= 1.6); + try std.testing.expect(themeContrast(th.sel_fg, th.sel_bg) >= minimum); + try std.testing.expect(themeContrast(chrome.search_fg, chrome.search_bg) >= minimum); + // ANSI black can intentionally match the page (for backgrounds). All + // other ANSI foregrounds, including bright black, remain legible. + for (th.palette.?[1..]) |fg| try std.testing.expect(themeContrast(fg, th.bg.?) >= minimum); + } +} + +test "native filename tints change hue without changing perceived lightness" { + const Perceptual = struct { + fn lab(rgb: [3]u8) [3]f64 { + var linear: [3]f64 = undefined; + for (rgb, 0..) |channel, i| { + const value = @as(f64, @floatFromInt(channel)) / 255; + linear[i] = if (value <= 0.04045) value / 12.92 else std.math.pow(f64, (value + 0.055) / 1.055, 2.4); + } + const r = linear[0]; + const g = linear[1]; + const b = linear[2]; + const l = std.math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = std.math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const n = std.math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + return .{ + 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * n, + 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * n, + 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * n, + }; + } + }; + for (themes[0..native_theme_count]) |th| { + try std.testing.expect(th.tag_name_fg != null and th.tag_active_name_fg != null); + const chrome = ChromeTheme.fromTheme(&th); + const pairs = [2][2][3]u8{ + .{ chrome.tag_fg, chrome.tag_name_fg }, + .{ chrome.tag_active_fg, chrome.tag_active_name_fg }, + }; + for (pairs) |pair| { + const base = Perceptual.lab(pair[0]); + const name = Perceptual.lab(pair[1]); + // Half a lightness point allows 8-bit quantization without a + // basename becoming visibly brighter or darker than its tag. + try std.testing.expectApproxEqAbs(base[0], name[0], 0.005); + const base_chroma = @sqrt(base[1] * base[1] + base[2] * base[2]); + const name_chroma = @sqrt(name[1] * name[1] + name[2] * name[2]); + try std.testing.expect(base_chroma > 0.005 and name_chroma > 0.02); + // A normalized dot product <= cos(60 degrees) means a clear + // hue difference, independent of saturation or lightness. + const hue_cosine = (base[1] * name[1] + base[2] * name[2]) / (base_chroma * name_chroma); + try std.testing.expect(hue_cosine <= 0.5); + } + } +} + +test "native dark palettes choose near-black or deliberately soft contrast" { + for (themes[0..native_theme_count]) |th| { + if (themeContrast(th.bg.?, .{ 0, 0, 0 }) > 10) continue; // light paper + const soft = std.mem.eql(u8, th.name, "dusk") or std.mem.eql(u8, th.name, "lagoon") or + std.mem.eql(u8, th.name, "solarium") or std.mem.eql(u8, th.name, "forge_soft"); + if (soft) { + try std.testing.expect(themeContrast(th.fg.?, th.bg.?) >= 4.5); + try std.testing.expect(themeContrast(th.fg.?, th.bg.?) <= 7); + } else { + for (th.bg.?) |channel| try std.testing.expect(channel <= 0x14); + try std.testing.expect(themeContrast(th.fg.?, th.bg.?) >= 14); + } + } + const forge = curated.forge.theme; + try std.testing.expectEqual(forge.bg[0], forge.bg[1]); + // Forge chrome is cool-neutral, never the old green-over-blue mint cast. + for ([_][3]u8{ forge.tag_bg, forge.tag_active_bg, forge.box, forge.border }) |rgb| + try std.testing.expect(rgb[2] >= rgb[1]); + try std.testing.expectEqual([3]u8{ 0, 0, 0 }, @as([3]u8, curated.forge_black.theme.bg)); + try std.testing.expectEqual([3]u8{ 0, 0, 0 }, @as([3]u8, curated.orchard_black.theme.bg)); +} + +test "line numbers stay quiet without a current-line tag background" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const pane = try p.setTestFile("first\nsecond\n"); + pane.cur_pinned = true; + pane.cur_row = 0; + var custom = themes[0]; + custom.name = try gpa.dupe(u8, "quiet-gutter"); + custom.lineno = .{ 70, 70, 70 }; + custom.lineno_active = .{ 95, 95, 95 }; + p.custom_theme = custom; + p.sync(); + const x = p.rects[0].x + config.GUTTER; + const y = p.rects[0].y + BOX_H; + for ([_]bool{ false, true }) |focus_tint| { + p.settings.focus_tint = focus_tint; + const surface = try p.render(p.scratch.allocator()); + try std.testing.expectEqual(Color{ .rgb = .{ 95, 95, 95 } }, surface.at(x, y).style.fg); + try std.testing.expectEqual(Color{ .rgb = .{ 70, 70, 70 } }, surface.at(x, y + 1).style.fg); + try std.testing.expectEqual(Color{ .rgb = custom.bg.? }, surface.at(x, y).style.bg); + try std.testing.expect(surface.at(x, y).style.bold); + try std.testing.expect(!surface.at(x, y + 1).style.bold); + } + // Old ThemeFile palettes need no new field, and do not regain the block. + p.custom_theme.?.lineno_active = null; + const fallback = try p.render(p.scratch.allocator()); + try std.testing.expectEqual(Color{ .rgb = .{ 70, 70, 70 } }, fallback.at(x, y).style.fg); + try std.testing.expectEqual(Color{ .rgb = custom.bg.? }, fallback.at(x, y).style.bg); + try std.testing.expect(fallback.at(x, y).style.bold); + // Moving the caret clears the previous cue; editing the tag parks it. + pane.cur_row = 1; + const moved = try p.render(p.scratch.allocator()); + try std.testing.expect(!moved.at(x, y).style.bold); + try std.testing.expect(moved.at(x, y + 1).style.bold); + p.enterTagEdit(pane, -1); + const tag_edit = try p.render(p.scratch.allocator()); + try std.testing.expect(!tag_edit.at(x, y + 1).style.bold); +} + +test "legacy ThemeFile documents inherit Pardes UI roles without new fields" { + @setEvalBranchQuota(10000); + var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer encoded.deinit(); + try std.zon.stringify.serialize(curated.helix.theme, .{ .whitespace = true }, &encoded.writer); + const source = try std.testing.allocator.dupeZ(u8, encoded.written()); + defer std.testing.allocator.free(source); + const parsed = try std.zon.parse.fromSliceAlloc(Theme, std.testing.allocator, source, null, .{}); + defer std.zon.parse.free(std.testing.allocator, parsed); + try std.testing.expectEqual(@as(?[3]u8, null), parsed.tag_active_bg); + try std.testing.expectEqual(@as(?[3]u8, null), parsed.tag_name_fg); + try std.testing.expectEqual(@as(?[3]u8, null), parsed.tag_active_name_fg); + try std.testing.expectEqual(@as(?[3]u8, null), parsed.column_box); + try std.testing.expectEqual(@as(?[3]u8, null), parsed.column_box_dim); + 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); + 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); + try std.testing.expectEqual(parsed.fg.?, chrome.diagnostic_error); +} + +test "column grip colors remain distinct from pane grips across native themes" { + for (themes[0..native_theme_count]) |th| { + try std.testing.expect(th.column_box != null and th.column_box_dim != null); + const chrome = ChromeTheme.fromTheme(&th); + for ([_][2][3]u8{ + .{ chrome.box, chrome.column_box }, + .{ chrome.box_dim, chrome.column_box_dim }, + }) |pair| { + var distance: u32 = 0; + for (pair[0], pair[1]) |a, b| { + const delta = @as(i32, a) - b; + distance += @intCast(delta * delta); + } + try std.testing.expect(distance >= 30 * 30); + } + } + var custom = themes[0]; + custom.column_box = .{ 90, 130, 170 }; + custom.column_box_dim = null; + var chrome = ChromeTheme.fromTheme(&custom); + try std.testing.expectEqual(custom.column_box.?, chrome.column_box); + try std.testing.expectEqual(mix(custom.column_box.?, custom.tag_bg), chrome.column_box_dim); + custom.column_box_dim = .{ 40, 60, 80 }; + chrome = ChromeTheme.fromTheme(&custom); + try std.testing.expectEqual(custom.column_box_dim.?, chrome.column_box_dim); +} + +test "custom filename tints preserve optional active fallback precedence" { + var theme = themes[0]; + 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); + theme.tag_name_fg = .{ 120, 130, 140 }; + chrome = ChromeTheme.fromTheme(&theme); + try std.testing.expectEqual(theme.tag_name_fg.?, chrome.tag_name_fg); + try std.testing.expectEqual(theme.tag_name_fg.?, chrome.tag_active_name_fg); + theme.tag_active_name_fg = .{ 150, 160, 170 }; + chrome = ChromeTheme.fromTheme(&theme); + try std.testing.expectEqual(theme.tag_name_fg.?, chrome.tag_name_fg); + 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(theme.tag_active_name_fg.?, chrome.tag_active_name_fg); +} -- cgit v1.3