//! 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; /// WCAG relative luminance, and the contrast of two colours. pub fn relativeLuminance(rgb: [3]u8) f32 { var l: [3]f32 = undefined; for (&l, rgb) |*out, v| { const x = @as(f32, @floatFromInt(v)) / 255; out.* = if (x <= 0.04045) x / 12.92 else std.math.pow(f32, (x + 0.055) / 1.055, 2.4); } return 0.2126 * l[0] + 0.7152 * l[1] + 0.0722 * l[2]; } pub fn contrast(a: [3]u8, b: [3]u8) f32 { const x = relativeLuminance(a); const y = relativeLuminance(b); return (@max(x, y) + 0.05) / (@min(x, y) + 0.05); } fn lerp(a: [3]u8, b: [3]u8, t: f32) [3]u8 { var out: [3]u8 = undefined; for (&out, a, b) |*o, x, y| o.* = @intFromFloat(std.math.clamp(@round(@as(f32, @floatFromInt(x)) + (@as(f32, @floatFromInt(y)) - @as(f32, @floatFromInt(x))) * t), 0, 255)); return out; } /// The rules between panes and columns: acme's contrast, black on white. /// A theme's own border, or its scroll track, as long as it stands off the /// page by 1.5:1; one that does not (on a near-black page, a dark rule all /// but vanishes) is lifted toward the text until it does, to 1.6:1. pub const separator_floor: f32 = 1.5; /// The page a theme with none (`dark`: the host's own) is judged against: /// the GUI's default ground, and a dark terminal's. pub const null_page: [3]u8 = .{ 18, 18, 18 }; fn separatorOf(th: *const Theme) [3]u8 { const base = th.border orelse th.scroll_track; const page = th.bg orelse null_page; if (contrast(base, page) >= separator_floor) return base; const ink = th.fg orelse th.tag_fg; var t: f32 = 0.05; while (t <= 1) : (t += 0.05) { const lifted = lerp(base, ink, t); if (contrast(lifted, page) >= 1.6) return lifted; } return ink; } /// The focused tag's tint, the main focus indicator: a theme's own, pushed /// further the way it already leans from the plain tag where the two are /// closer than the floor (1.5:1 on a dark page, where a tint is all that /// tells the focused tag at a glance; 1.25:1 on a light one), but never so /// far its text drops below its own contrast or 4.5. A theme with no tint /// (acme: the grip's button says it) keeps none. pub const focus_tint_floor: f32 = 1.25; pub const focus_tint_floor_dark: f32 = 1.5; pub fn focusTintFloor(th: *const Theme) f32 { return if (relativeLuminance(th.bg orelse null_page) < 0.18) focus_tint_floor_dark else focus_tint_floor; } fn focusTint(th: *const Theme) [3]u8 { const tint = th.tag_active_bg orelse return th.tag_bg; const floor = focusTintFloor(th); if (contrast(tint, th.tag_bg) >= floor) return tint; const ink = th.tag_active_fg orelse th.tag_fg; const text_floor = @min(contrast(ink, tint), 4.5); var best = tint; var k: f32 = 1.1; while (k <= 12) : (k += 0.1) { const pushed = lerp(th.tag_bg, tint, k); if (contrast(ink, pushed) < text_floor) break; best = pushed; if (contrast(pushed, th.tag_bg) >= floor) break; } return best; } test "every native theme's separators stand off its page, and its focus tint off its tag" { for (themes[0..native_count]) |*theme| { const chrome = ChromeTheme.fromTheme(theme); const page = theme.bg orelse null_page; try std.testing.expect(contrast(chrome.border, page) >= separator_floor); // A dark page's focused tag is told by its band too, not the grip // alone; acme's (light, and acme's own) is told by its button. if (relativeLuminance(page) < 0.18) try std.testing.expect(theme.tag_active_bg != null); if (theme.tag_active_bg) |own| { try std.testing.expect(contrast(chrome.tag_active_bg, theme.tag_bg) >= focusTintFloor(theme)); // And its text still reads. const ink = theme.tag_active_fg orelse theme.tag_fg; try std.testing.expect(contrast(ink, chrome.tag_active_bg) >= @min(contrast(ink, own), 4.5) - 0.01); } } } test "a derived separator or tint keeps the theme's own when it already stands off" { const forge = &themes[for (themes[0..native_count], 0..) |t, i| { if (std.mem.eql(u8, t.name, "forge")) break i; } else unreachable]; const chrome = ChromeTheme.fromTheme(forge); try std.testing.expectEqual(forge.border.?, chrome.border); // forge's own tint is 1.23:1 off its tag: pushed on the way it leans. try std.testing.expect(!std.meta.eql(forge.tag_active_bg.?, chrome.tag_active_bg)); for (chrome.tag_active_bg, forge.tag_active_bg.?, forge.tag_bg) |pushed, own, plain| try std.testing.expect(pushed >= own and own >= plain); } /// sRGB bytes to OKLab and back (Björn Ottosson's), and a mix in it. fn toOklab(rgb: [3]u8) [3]f32 { var l: [3]f32 = undefined; for (&l, rgb) |*out, v| { const x = @as(f32, @floatFromInt(v)) / 255; out.* = if (x <= 0.04045) x / 12.92 else std.math.pow(f32, (x + 0.055) / 1.055, 2.4); } const lm = std.math.cbrt(0.4122214708 * l[0] + 0.5363325363 * l[1] + 0.0514459929 * l[2]); const mm = std.math.cbrt(0.2119034982 * l[0] + 0.6806995451 * l[1] + 0.1073969566 * l[2]); const sm = std.math.cbrt(0.0883024619 * l[0] + 0.2817188376 * l[1] + 0.6299787005 * l[2]); return .{ 0.2104542553 * lm + 0.7936177850 * mm - 0.0040720468 * sm, 1.9779984951 * lm - 2.4285922050 * mm + 0.4505937099 * sm, 0.0259040371 * lm + 0.7827717662 * mm - 0.8086757660 * sm, }; } fn fromOklab(lab: [3]f32) [3]u8 { 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 = .{ 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, }; var out: [3]u8 = undefined; for (&out, linear) |*o, v| { const x = std.math.clamp(v, 0, 1); const srgb = if (x <= 0.0031308) x * 12.92 else 1.055 * std.math.pow(f32, x, 1.0 / 2.4) - 0.055; o.* = @intFromFloat(@round(std.math.clamp(srgb, 0, 1) * 255)); } return out; } pub fn mixOklab(a: [3]u8, b: [3]u8, t: f32) [3]u8 { if (std.mem.eql(u8, &a, &b)) return a; const x = toOklab(a); const y = toOklab(b); return fromOklab(.{ x[0] + (y[0] - x[0]) * t, x[1] + (y[1] - x[1]) * t, x[2] + (y[2] - x[2]) * t }); } 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))); try std.testing.expectEqual(rgb, mixOklab(rgb, .{ 10, 200, 30 }, 0)); } // Red to green through sRGB bytes dips dark at the middle; through // OKLab it keeps its lightness. const red: [3]u8 = .{ 255, 0, 0 }; const green: [3]u8 = .{ 0, 200, 0 }; const srgb_mid = mix(red, green); const lab_mid = mixOklab(red, green, 0.5); try std.testing.expect(relativeLuminance(lab_mid) > relativeLuminance(srgb_mid) * 1.3); } 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; } /// A theme's ornament, drawn by a pixel shell inside the chrome it already /// has (docs/render-pipeline.md §10: no gaps between panes): the lapis /// theme's. Sizes are logical pixels. None of it touches a focus indicator: /// the dots lie on the page's own ground, the checker in the scroll track /// (never its thumb), a tag's plaque inside its band beside its grip (and /// the focused one only the more striking), and the shadow under a file /// name's own letters. pub const Decor = struct { /// A dot grid on the page's ground, `page_dot_alpha` of 255 strong, a /// dot one logical pixel across (fading out by a pixel and a half) every /// `page_dot_px`, fixed to the window. page_dots: ?[3]u8 = null, page_dot_alpha: u8 = 0x22, page_dot_px: u8 = 22, /// A checker in the scroll track, squares of `rail_checker_px`, this /// colour and the track's. rail_checker: ?[3]u8 = null, rail_checker_px: u8 = 3, /// Every tag drawn as a raised plaque inside its own band (the site's /// trail and title bars): a `tag_border` frame round the text, a hard /// `tag_shadow` offset down and right by `tag_shadow_px`, both within /// the band's rows and columns, never past them; the focused pane's /// plaque striped `tag_stripe` and the tag's ground, `tag_stripe_px` /// each, its words on plates of the ground. Column and workspace tags /// take a quieter one (a one-pixel frame, a shadow of two, no stripes). tag_border: ?[3]u8 = null, tag_border_px: u8 = 2, tag_shadow: ?[3]u8 = null, tag_shadow_px: u8 = 5, tag_stripe: ?[3]u8 = null, tag_stripe_px: u8 = 2, /// The file name's offset shadow, down and right, in a pane's tag. title_shadow: ?[3]u8 = null, title_shadow_px: u8 = 2, }; 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, /// What an empty column shows under its tag; the border fill without it. empty_col: ?[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, /// A tag's selection, where it differs from the body's (acme's #9eeeee). tag_sel_bg: ?[3]u8 = null, /// Each pointer button's sweep, select, exec and look (Mouse.Button /// order), and the ink over it. Unset, tints of `sel_bg` in `sel_fg`. sweep_bg: ?[3][3]u8 = null, sweep_fg: ?[3][3]u8 = null, /// A pixel shell's rules, in pixels (2 unset); the rule between a tag /// and its body is always one. rule_px: ?u8 = null, /// A grip is acme's button: `box` filled when focused, else a ring of /// `box_border` (unset: `box_dim`) round the tag's ground; `box_dirty` /// (unset: `diagnostic_warning`, then `num`) fills it, in either ring, /// while its file is unsaved. box_border: ?[3]u8 = null, box_dirty: ?[3]u8 = null, /// The rule between a pane's tag and its body, one pixel, quieter than /// the rules between panes (acme's #8888cc under its 2px black ones). /// Unset, halfway between the tag's ground and the page. tag_rule: ?[3]u8 = null, /// A pixel shell's scroll column, the grip's button over the scrollbar, /// in pixels at a 17px tagline, the thumb one narrower (acme's 12px /// Scrollwid, the default). rail_px: ?u8 = null, /// A pixel shell's ornament (Decor): lapis's. decor: ?Decor = 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"); pub const lapis = @import("themes/lapis.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 (f.type == ?Decor and @hasField(@TypeOf(source), f.name)) decor: { // A nested literal is folded field by field too. var decor: Decor = .{}; const given = @field(source, f.name); for (@typeInfo(Decor).@"struct".fields) |g| if (@hasField(@TypeOf(given), g.name)) { @field(decor, g.name) = @field(given, g.name); }; break :decor decor; } else 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")); /// The first `native_count` of `themes` are pardes's own; the rest imported. pub const native_count = @typeInfo(curated).@"struct".decls.len; /// 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, empty_col: [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 { // The initial chrome is resolved at compile time: contrast takes pow. @setEvalBranchQuota(200_000); 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)), .border = separatorOf(th), .empty_col = th.empty_col orelse separatorOf(th), .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, }; } /// Partway from one chrome to another, each colour through OKLab /// (G10): a fade keeps its lightness even, never going muddy or dark /// between two hues as a mix of sRGB bytes does. pub fn interpolate(from: ChromeTheme, to: ChromeTheme, step: u16, steps: u16) ChromeTheme { var out: ChromeTheme = undefined; const t = @as(f32, @floatFromInt(@min(step, steps))) / @as(f32, @floatFromInt(@max(1, steps))); inline for (@typeInfo(ChromeTheme).@"struct".fields) |field| @field(out, field.name) = if (step == 0) @field(from, field.name) else if (step >= steps) @field(to, field.name) else mixOklab(@field(from, field.name), @field(to, field.name), t); return out; } }; pub const ChromeAnimation = if (theme_animation) animation.Fade(ChromeTheme) else 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(); }; } /// Themes whose names share `name`'s first letter, a line of them, for a /// Theme given a name there is none of: all of them (ThemeSel) are too many /// for a message or a refused write. pub fn themesNear(buf: []u8, name: []const u8) []const u8 { var w = std.Io.Writer.fixed(buf); for (themes) |t| { if (name.len > 0 and std.ascii.toLower(t.name[0]) != std.ascii.toLower(name[0])) continue; if (w.buffered().len + t.name.len + 1 > buf.len) break; w.print("{s}{s}", .{ if (w.buffered().len > 0) " " else "", t.name }) catch break; } return w.buffered(); } 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.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 = false }, &encoded.writer); // helix without the focused tag's pair it has since gained: a document // written before those fields existed. var legacy: std.ArrayList(u8) = .empty; defer legacy.deinit(std.testing.allocator); var rest: []const u8 = encoded.written(); while (true) { const at = @min(std.mem.indexOf(u8, rest, ".tag_active_bg") orelse rest.len, std.mem.indexOf(u8, rest, ".tag_active_fg") orelse rest.len); try legacy.appendSlice(std.testing.allocator, rest[0..at]); if (at == rest.len) break; rest = rest[at + std.mem.indexOfScalar(u8, rest[at..], '}').? + 1 ..]; rest = std.mem.trimStart(u8, rest, " ,"); } const source = try std.testing.allocator.dupeZ(u8, legacy.items); 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); }