//! 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 tagline = @import("tagline.zig"); const panes = @import("panes.zig"); const std = @import("std"); const layout = @import("layout.zig"); const animation = @import("animation.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) = animation.Animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps); return out; } }; pub const ChromeAnimation = if (theme_animation) animation.Animation.Transition(ChromeTheme) else animation.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..animation.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.body.cur_pinned = true; pane.body.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.body.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); tagline.enterTag(p, pane); 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); }