//! 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 "every faithful port meets pardes's chrome floors in its own colours, with nothing left to derive" { const first = comptime indexOfTheme("lapis") + 1; const last = comptime indexOfTheme("helix"); for (themes[first..last]) |*th| { const chrome = ChromeTheme.fromTheme(th); const page = th.bg.?; // Written out already adjusted: the run-time lift and push leave // the file's own values, so what the file says is what is drawn. try std.testing.expectEqual(th.border.?, chrome.border); try std.testing.expectEqual(th.tag_active_bg.?, chrome.tag_active_bg); try std.testing.expect(contrast(th.border.?, page) >= separator_floor); try std.testing.expect(contrast(th.tag_active_bg.?, th.tag_bg) >= focusTintFloor(th)); try std.testing.expect(contrast(th.tag_fg, th.tag_bg) >= 4.5); try std.testing.expect(contrast(th.tag_active_fg.?, th.tag_active_bg.?) >= 4.5); try std.testing.expect(contrast(th.box_dim, th.tag_bg) >= 1.5); try std.testing.expect(th.palette != null); } } 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 oklabLinear(lab: [3]f32) [3]f32 { const lm = lab[0] + 0.3963377774 * lab[1] + 0.2158037573 * lab[2]; const mm = lab[0] - 0.1055613458 * lab[1] - 0.0638541728 * lab[2]; const sm = lab[0] - 0.0894841775 * lab[1] - 1.2914855480 * lab[2]; const l = lm * lm * lm; const m = mm * mm * mm; const s = sm * sm * sm; return .{ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s, }; } fn fromOklab(lab: [3]f32) [3]u8 { const linear = oklabLinear(lab); var out: [3]u8 = undefined; for (&out, linear) |*o, v| { const x = std.math.clamp(v, 0, 1); 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 }); } fn oklabDistance(a: [3]f32, b: [3]f32) f32 { return @sqrt((a[0] - b[0]) * (a[0] - b[0]) + (a[1] - b[1]) * (a[1] - b[1]) + (a[2] - b[2]) * (a[2] - b[2])); } /// How far a name's tint stands off its tag's ink in OKLab: a hue you see /// at a glance, not a shade you look for. pub const name_tint_distance: f32 = 0.07; /// How much hue a colour needs to lend a name its tint. const name_tint_chroma: f32 = 0.06; /// A name's tint for a theme that sets none (the faithful ports, the /// imports, a theme file): a hue of the theme's own -- its keyword colour, /// else the first of its ANSI accents, syntax, grip, selection or /// diagnostic colours that has one -- at the lightness nearest the tag's /// ink where that hue holds, the name reads on its ground (nameTintFloor) /// and it stands off the ink. Null when no colour of the theme will do. fn derivedNameTint(th: *const Theme, ink: [3]u8, ground: [3]u8) ?[3]u8 { const pal = th.palette orelse [_][3]u8{th.kw} ** 16; const sources = [_][3]u8{ th.kw, pal[4], pal[5], pal[6], pal[3], pal[2], pal[1], th.num, th.str, th.box } ++ [_][3]u8{ th.column_box orelse th.num, th.box_dirty orelse th.num, th.sel_bg, th.search_bg orelse th.num } ++ [_][3]u8{ th.diagnostic_info orelse th.num, th.diagnostic_hint orelse th.num, th.diagnostic_warning orelse th.num } ++ // A theme with no hue of its own at all (flatwhite): acme's DMedblue's. [_][3]u8{.{ 0x00, 0x00, 0x99 }}; // Near the ink's lightness first, from any of the theme's hues; only // then further off it. for ([2]f32{ 0.15, 1 }) |reach| for (sources) |source| { if (nameTintFrom(toOklab(source), ink, ground, reach)) |tint| return tint; }; return null; } fn nameTintFrom(source: [3]f32, ink: [3]u8, ground: [3]u8, reach: f32) ?[3]u8 { const chroma = @sqrt(source[1] * source[1] + source[2] * source[2]); if (chroma < name_tint_chroma) return null; const ink_lab = toOklab(ink); const floor = nameTintFloor(ink, ground); var step: f32 = 0; while (step <= reach) : (step += 0.01) { for ([2]f32{ 1, -1 }) |sign| { const lightness = ink_lab[0] + sign * step; if (lightness < 0 or lightness > 1) continue; // As much of the hue as that lightness holds, up to a tint's. var want = @min(chroma, 0.12); while (want >= 0.04) : (want -= 0.01) { const lab: [3]f32 = .{ lightness, source[1] / chroma * want, source[2] / chroma * want }; const in_gamut = for (oklabLinear(lab)) |v| { if (v < -0.001 or v > 1.001) break false; } else true; if (!in_gamut) continue; const tint = fromOklab(lab); if (contrast(tint, ground) < floor) continue; if (oklabDistance(toOklab(tint), ink_lab) < name_tint_distance) continue; return tint; } } } return null; } /// What a name's tint must read at on its ground: 4.5:1, or within half a /// point of the ink's own where the ink has less to spare (seoulbones_dark's /// near-white on grey leaves no hue at the ink's 4.53:1). pub fn nameTintFloor(ink: [3]u8, ground: [3]u8) f32 { return @min(4.5, contrast(ink, ground) - 0.5); } test "an OKLab fade holds its ends exactly and keeps a mid-way bright" { for ([_][3]u8{ .{ 0, 0, 0 }, .{ 255, 255, 255 }, .{ 0x0c, 0x0c, 0x0e }, .{ 0xe8, 0xc4, 0x6a }, .{ 0x1c, 0x51, 0x72 } }) |rgb| { try std.testing.expectEqual(rgb, fromOklab(toOklab(rgb))); 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 acme = @import("themes/acme.zig"); pub const lapis = @import("themes/lapis.zig"); // Faithful ports of well-known themes, a family at a time (src/themes/ // .zig says where each value came from): `theme_groups`. pub const dark_plus = @import("themes/dark_plus.zig"); pub const solarized_dark = @import("themes/solarized_dark.zig"); pub const solarized_light = @import("themes/solarized_light.zig"); pub const dracula = @import("themes/dracula.zig"); pub const dracula_soft = @import("themes/dracula_soft.zig"); pub const alucard = @import("themes/alucard.zig"); pub const monokai = @import("themes/monokai.zig"); pub const monokai_pro = @import("themes/monokai_pro.zig"); pub const monokai_pro_classic = @import("themes/monokai_pro_classic.zig"); pub const monokai_pro_machine = @import("themes/monokai_pro_machine.zig"); pub const monokai_pro_octagon = @import("themes/monokai_pro_octagon.zig"); pub const monokai_pro_ristretto = @import("themes/monokai_pro_ristretto.zig"); pub const monokai_pro_spectrum = @import("themes/monokai_pro_spectrum.zig"); pub const monokai_pro_light = @import("themes/monokai_pro_light.zig"); pub const monokai_pro_light_sun = @import("themes/monokai_pro_light_sun.zig"); pub const tokyonight_night = @import("themes/tokyonight_night.zig"); pub const tokyonight_storm = @import("themes/tokyonight_storm.zig"); pub const tokyonight_moon = @import("themes/tokyonight_moon.zig"); pub const tokyonight_day = @import("themes/tokyonight_day.zig"); pub const rose_pine = @import("themes/rose_pine.zig"); pub const rose_pine_moon = @import("themes/rose_pine_moon.zig"); pub const rose_pine_dawn = @import("themes/rose_pine_dawn.zig"); pub const catppuccin_latte = @import("themes/catppuccin_latte.zig"); pub const catppuccin_frappe = @import("themes/catppuccin_frappe.zig"); pub const catppuccin_macchiato = @import("themes/catppuccin_macchiato.zig"); pub const catppuccin_mocha = @import("themes/catppuccin_mocha.zig"); pub const seoul256_dark_233 = @import("themes/seoul256_dark_233.zig"); pub const seoul256_dark_234 = @import("themes/seoul256_dark_234.zig"); pub const seoul256_dark_235 = @import("themes/seoul256_dark_235.zig"); pub const seoul256_dark_236 = @import("themes/seoul256_dark_236.zig"); pub const seoul256_dark_237 = @import("themes/seoul256_dark_237.zig"); pub const seoul256_dark_238 = @import("themes/seoul256_dark_238.zig"); pub const seoul256_dark_239 = @import("themes/seoul256_dark_239.zig"); pub const seoul256_light_252 = @import("themes/seoul256_light_252.zig"); pub const seoul256_light_253 = @import("themes/seoul256_light_253.zig"); pub const seoul256_light_254 = @import("themes/seoul256_light_254.zig"); pub const seoul256_light_255 = @import("themes/seoul256_light_255.zig"); pub const seoul256_light_256 = @import("themes/seoul256_light_256.zig"); pub const zenbones_light = @import("themes/zenbones_light.zig"); pub const zenbones_dark = @import("themes/zenbones_dark.zig"); pub const neobones_light = @import("themes/neobones_light.zig"); pub const neobones_dark = @import("themes/neobones_dark.zig"); pub const vimbones = @import("themes/vimbones.zig"); pub const forestbones_light = @import("themes/forestbones_light.zig"); pub const forestbones_dark = @import("themes/forestbones_dark.zig"); pub const nordbones = @import("themes/nordbones.zig"); pub const rosebones_light = @import("themes/rosebones_light.zig"); pub const rosebones_dark = @import("themes/rosebones_dark.zig"); pub const tokyobones_light = @import("themes/tokyobones_light.zig"); pub const tokyobones_dark = @import("themes/tokyobones_dark.zig"); pub const seoulbones_light = @import("themes/seoulbones_light.zig"); pub const seoulbones_dark = @import("themes/seoulbones_dark.zig"); pub const duckbones = @import("themes/duckbones.zig"); pub const zenburned = @import("themes/zenburned.zig"); pub const zenwritten_light = @import("themes/zenwritten_light.zig"); pub const zenwritten_dark = @import("themes/zenwritten_dark.zig"); pub const kanagawabones = @import("themes/kanagawabones.zig"); pub const nord = @import("themes/nord.zig"); pub const ayu_dark = @import("themes/ayu_dark.zig"); pub const ayu_mirage = @import("themes/ayu_mirage.zig"); pub const ayu_light = @import("themes/ayu_light.zig"); pub const doom_one = @import("themes/doom_one.zig"); pub const doom_one_light = @import("themes/doom_one_light.zig"); // The legacy pair last. pub const helix = @import("themes/helix.zig"); pub const dark = @import("themes/dark.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; } } /// The imported themes (tools/gen_themes.zig), less any name one of ours /// takes: a faithful port (`dracula`) keeps the plain name, and helix's /// looser copy of it is still there as `dracula_helix` (the zed ones already /// carry `_zed`). fn foldImports(comptime ours: anytype, comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { comptime { @setEvalBranchQuota(400000); var out = fold(C); for (&out) |*g| for (ours) |c| if (std.mem.eql(u8, c.name, g.name)) { g.name = g.name ++ "_helix"; }; return out; } } pub const themes = fold(curated) ++ foldImports(fold(curated), @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; /// The picker's sections (`Themes`), in ring order, each running from /// the theme it names to the next section's; `NextColor` and `Theme` still /// see one flat ring. The current pardes themes, then the faithful ports a /// family at a time, then the legacy pair (`helix`, captured from an old /// helix setup and the boot default before orchard; `dark`, the host /// terminal's own ground), then everything imported. pub const ThemeGroup = struct { title: []const u8, first: []const u8 }; const theme_groups = [_]ThemeGroup{ .{ .title = "Pardes themes", .first = "orchard" }, .{ .title = "Visual Studio Code", .first = "dark_plus" }, .{ .title = "Solarized", .first = "solarized_dark" }, .{ .title = "Dracula", .first = "dracula" }, .{ .title = "Monokai", .first = "monokai" }, .{ .title = "Monokai Pro", .first = "monokai_pro" }, .{ .title = "Tokyo Night", .first = "tokyonight_night" }, .{ .title = "Rosé Pine", .first = "rose_pine" }, .{ .title = "Catppuccin", .first = "catppuccin_latte" }, .{ .title = "Seoul256", .first = "seoul256_dark_233" }, .{ .title = "Zenbones", .first = "zenbones_light" }, .{ .title = "Nord", .first = "nord" }, .{ .title = "Ayu", .first = "ayu_dark" }, .{ .title = "Doom One", .first = "doom_one" }, .{ .title = "Legacy themes", .first = "helix" }, }; fn indexOfTheme(comptime name: []const u8) usize { return comptime blk: { @setEvalBranchQuota(100000); for (themes[0..native_count], 0..) |t, i| if (std.mem.eql(u8, t.name, name)) break :blk i; @compileError("theme_groups names " ++ name ++ ", which is not one of ours"); }; } /// The heading of the picker section that starts at ring index `index`. pub fn groupAt(index: usize) ?[]const u8 { inline for (theme_groups) |g| if (index == comptime indexOfTheme(g.first)) return g.title; if (index == native_count) return "Imported themes (helix, zed)"; return null; } test "the picker's sections run in ring order and cover it" { var last: usize = 0; inline for (theme_groups, 0..) |g, n| { const at = comptime indexOfTheme(g.first); if (n == 0) try std.testing.expectEqual(@as(usize, 0), at) else try std.testing.expect(at > last); last = at; } try std.testing.expectEqualStrings("Pardes themes", groupAt(0).?); // acme is a current theme, not a legacy one. try std.testing.expect(indexOfTheme("acme") < indexOfTheme("helix")); try std.testing.expect(groupAt(native_count) != null); } /// 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); const active_bg = focusTint(th); const active_fg = th.tag_active_fg orelse th.tag_fg; // A name is always tinted: a theme that names no tint gets one of // its own hues, on each ground. One that names only the plain tint // keeps it when focused too. const name_fg = th.tag_name_fg orelse (derivedNameTint(th, th.tag_fg, th.tag_bg) orelse th.tag_fg); const active_name_fg = th.tag_active_name_fg orelse (th.tag_name_fg orelse (derivedNameTint(th, active_fg, active_bg) orelse active_fg)); return .{ .tag_bg = th.tag_bg, .tag_fg = th.tag_fg, .tag_active_bg = active_bg, .tag_active_fg = active_fg, .tag_name_fg = name_fg, .tag_active_name_fg = active_name_fg, .border = separatorOf(th), .empty_col = th.empty_col orelse separatorOf(th), .search_bg = th.search_bg orelse th.sel_bg, .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 near `name`, a line of them, for a Theme given a name there is /// none of (all of them, Themes, are too many for a refused write): every /// one sharing its first letter when they fit in `buf`, else the 8 nearest. 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 (!putName(&w, buf.len, t.name)) break; } else if (w.buffered().len > 0) return w.buffered(); // The nearest, most alike first: ponytail, a plain score (shared // prefix, the name inside, its letters in order), no edit distance. var best: [8]usize = undefined; var scores: [8]usize = undefined; var n: usize = 0; for (themes, 0..) |t, i| { const s = likeness(t.name, name); var at = n; while (at > 0 and scores[at - 1] < s) at -= 1; if (at == best.len) continue; const last = @min(n, best.len - 1); var k = last; while (k > at) : (k -= 1) { best[k] = best[k - 1]; scores[k] = scores[k - 1]; } best[at] = i; scores[at] = s; n = @min(n + 1, best.len); } w = std.Io.Writer.fixed(buf); for (best[0..n]) |i| if (!putName(&w, buf.len, themes[i].name)) break; return w.buffered(); } /// One more name on the line, whole, or none: false when it would not fit. fn putName(w: *std.Io.Writer, cap: usize, name: []const u8) bool { const sep: []const u8 = if (w.buffered().len > 0) " " else ""; if (w.buffered().len + sep.len + name.len > cap) return false; w.print("{s}{s}", .{ sep, name }) catch return false; return true; } test "a theme hint drops whole names to fit, never cutting one" { var buf: [23]u8 = undefined; const said = themesNear(&buf, "9dracula"); var words = std.mem.tokenizeScalar(u8, said, ' '); while (words.next()) |word| { var whole = false; for (themes) |t| whole = whole or std.mem.eql(u8, t.name, word); try std.testing.expect(whole); } } fn likeness(theme_name: []const u8, name: []const u8) usize { var prefix: usize = 0; while (prefix < @min(theme_name.len, name.len) and std.ascii.toLower(theme_name[prefix]) == std.ascii.toLower(name[prefix])) prefix += 1; // Its letters found in order, one not there passed over. var in_order: usize = 0; var pos: usize = 0; for (name) |c| { const at = for (theme_name[pos..], pos..) |t, i| { if (std.ascii.toLower(t) == std.ascii.toLower(c)) break i; } else continue; in_order += 1; pos = at + 1; } const inside: usize = if (name.len > 0 and std.ascii.indexOfIgnoreCase(theme_name, name) != null) name.len * 4 else 0; return prefix * 3 + in_order + inside; } /// The theme named `name`, whatever its case. pub fn themeIndex(name: []const u8) ?usize { for (themes, 0..) |t, i| if (std.ascii.eqlIgnoreCase(t.name, name)) return i; return null; } 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); // Its names take a tint of its own hues, never the plain ink. try std.testing.expect(!std.meta.eql(parsed.tag_fg, chrome.tag_name_fg)); try std.testing.expect(!std.meta.eql(chrome.tag_active_fg, chrome.tag_active_name_fg)); try std.testing.expectEqual(parsed.num, chrome.column_box); try std.testing.expectEqual(mix(parsed.num, parsed.tag_bg), chrome.column_box_dim); try std.testing.expectEqual(parsed.sel_bg, chrome.search_bg); 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); // Neither named: each derived, on its own ground. try std.testing.expectEqual(derivedNameTint(&theme, theme.tag_fg, theme.tag_bg).?, chrome.tag_name_fg); try std.testing.expectEqual(derivedNameTint(&theme, theme.tag_active_fg.?, chrome.tag_active_bg).?, chrome.tag_active_name_fg); theme.tag_name_fg = .{ 120, 130, 140 }; chrome = ChromeTheme.fromTheme(&theme); try std.testing.expectEqual(theme.tag_name_fg.?, chrome.tag_name_fg); 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(derivedNameTint(&theme, theme.tag_fg, theme.tag_bg).?, chrome.tag_name_fg); try std.testing.expectEqual(theme.tag_active_name_fg.?, chrome.tag_active_name_fg); } test "every theme tints its names off the tag's ink, and they read on both grounds" { for (&themes) |*th| { const chrome = ChromeTheme.fromTheme(th); const pairs = [2][3][3]u8{ .{ chrome.tag_fg, chrome.tag_name_fg, chrome.tag_bg }, .{ chrome.tag_active_fg, chrome.tag_active_name_fg, chrome.tag_active_bg }, }; for (pairs) |pair| { const ink, const name, const ground = pair; const apart = oklabDistance(toOklab(ink), toOklab(name)); const floor = nameTintFloor(ink, ground); if (apart < name_tint_distance or contrast(name, ground) < floor - 0.01) std.debug.print("{s}: name {x} on {x} is {d:.3} off the ink {x}, {d:.2}:1\n", .{ th.name, name, ground, apart, ink, contrast(name, ground) }); try std.testing.expect(apart >= name_tint_distance); try std.testing.expect(contrast(name, ground) >= floor - 0.01); } } }