From 41bbbd05dccb02631ed785d19ed54fb4edfcc471 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 27 Sep 2026 21:49:28 -0300 Subject: Move colour themes out of pardes.zig into colors.zig Pure move, no behaviour change: the Theme type, the curated palettes, fold and the themes ring with its two comptime checks, themeContrast, mix, ChromeTheme, ChromeAnimation and initial_chrome, the Pardes methods that load theme files and switch themes (nextThemeFileGeneration, requestThemeFile, ThemeFileRequest, themeFileRequest, failThemeFile, loadThemeFile, finishThemeInitialization, invalidateThemeDependentRasters, setThemeIndex), and the ten theme tests go verbatim to colors.zig. The methods become free functions taking `p: *Pardes`; their call sites change from `p.setThemeIndex(i)` to `colors.setThemeIndex(p, i)` (37 of them, in pardes.zig, builtins.zig, file_watch.zig, macos.zig and two test files). pardes.zig keeps `pub const Theme/themes/native_theme_count/ ChromeTheme = colors.X;` for the shells that name them, and sync and enterTagEdit become pub because a moved test calls them. Co-Authored-By: Claude Opus 5.5 --- src/builtins.zig | 5 +- src/colors.zig | 681 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/file_watch.zig | 15 +- src/fs.zig | 1 + src/macos.zig | 13 +- src/pardes.zig | 700 ++--------------------------------------------------- test/output.zig | 2 +- test/pdf.zig | 6 +- 8 files changed, 725 insertions(+), 698 deletions(-) create mode 100644 src/colors.zig diff --git a/src/builtins.zig b/src/builtins.zig index ac336ce0..ad2c5f6a 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -1,6 +1,7 @@ //! Command structs and runtime settings form the builtin registry; bindings live in config.zig. const std = @import("std"); const pardes = @import("pardes.zig"); +const colors = @import("colors.zig"); const Pardes = pardes.Pardes; const Pane = pardes.Pane; const panes = @import("panes.zig"); @@ -304,7 +305,7 @@ pub const Msg = struct { pub const NextColor = struct { pub fn run(c: Ctx) void { - c.p.setThemeIndex((@as(usize, c.p.settings.theme) + 1) % pardes.themes.len); + colors.setThemeIndex(c.p, (@as(usize, c.p.settings.theme) + 1) % pardes.themes.len); } }; @@ -320,7 +321,7 @@ pub const ThemeFile = struct { pub const enabled = pardes.hosted; pub fn run(c: Ctx) void { if (comptime enabled) - c.p.requestThemeFile(c.id, c.arg orelse return) + colors.requestThemeFile(c.p, c.id, c.arg orelse return) else unreachable; } diff --git a/src/colors.zig b/src/colors.zig new file mode 100644 index 00000000..d6e1d535 --- /dev/null +++ b/src/colors.zig @@ -0,0 +1,681 @@ +//! Colour themes: the Theme type, the curated and generated ring, chrome +//! colours and their animation, theme files, and switching themes at runtime. +const pardes = @import("pardes.zig"); +const panes = @import("panes.zig"); +const std = @import("std"); +const layout = @import("layout.zig"); +const Color = @import("surface.zig").Color; +const FontRole = @import("surface.zig").FontRole; +const config = @import("config.zig"); +const pdf_enabled = panes.Pdf.enabled; +const platform = pardes.platform; +const theme_animation = @import("pardes_config").theme_animation; +const hosted = pardes.hosted; +const MAX_PANES = pardes.MAX_PANES; +const BOX_H = pardes.BOX_H; +const Pardes = pardes.Pardes; + +pub fn mix(a: [3]u8, b: [3]u8) [3]u8 { + var out: [3]u8 = undefined; + for (&out, a, b) |*c, x, y| c.* = @intCast((@as(u16, x) + y) / 2); + return out; +} + +pub const Theme = struct { + name: []const u8, + bg: ?[3]u8, + fg: ?[3]u8, + sel_bg: [3]u8, + sel_fg: [3]u8, + tag_bg: [3]u8, + tag_fg: [3]u8, + box: [3]u8, // move box, focused pane + box_dim: [3]u8, // move box, everything else + kw: [3]u8, + str: [3]u8, + num: [3]u8, + comment: [3]u8, + lineno: [3]u8, + scroll_track: [3]u8, + scroll_thumb: [3]u8, + palette: ?[16][3]u8, + + // Pardes UI roles. Optional so existing ThemeFile documents and imported + // editor palettes keep working; ChromeTheme resolves their legacy defaults. + tag_active_bg: ?[3]u8 = null, + tag_active_fg: ?[3]u8 = null, + /// Filename/output-name accent, also used by the Tty command. + tag_name_fg: ?[3]u8 = null, + /// Active tint; absence preserves the shared name tint or active tag ink. + tag_active_name_fg: ?[3]u8 = null, + /// Column grips stay distinct from pane grips in both focus states. + column_box: ?[3]u8 = null, + column_box_dim: ?[3]u8 = null, + border: ?[3]u8 = null, + search_bg: ?[3]u8 = null, + search_fg: ?[3]u8 = null, + diagnostic_error: ?[3]u8 = null, + diagnostic_warning: ?[3]u8 = null, + diagnostic_info: ?[3]u8 = null, + diagnostic_hint: ?[3]u8 = null, + // The current line number is a quiet foreground cue, not a tag-colored + // background block. Older themes fall back to their normal gutter ink. + lineno_active: ?[3]u8 = null, +}; + +const curated = struct { + pub const orchard = @import("themes/orchard.zig"); + pub const dusk = @import("themes/dusk.zig"); + pub const ink = @import("themes/ink.zig"); + pub const paper = @import("themes/paper.zig"); + pub const daybreak = @import("themes/daybreak.zig"); + pub const atelier = @import("themes/atelier.zig"); + pub const forge = @import("themes/forge.zig"); + pub const lagoon = @import("themes/lagoon.zig"); + pub const solarium = @import("themes/solarium.zig"); + pub const spectrum = @import("themes/spectrum.zig"); + pub const harvest = @import("themes/harvest.zig"); + pub const clay = @import("themes/clay.zig"); + pub const forge_black = @import("themes/forge_black.zig"); + pub const forge_soft = @import("themes/forge_soft.zig"); + pub const orchard_black = @import("themes/orchard_black.zig"); + pub const helix = @import("themes/helix.zig"); + pub const dark = @import("themes/dark.zig"); + pub const acme = @import("themes/acme.zig"); +}; + +fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { + comptime { + @setEvalBranchQuota(400000); + var out: [@typeInfo(C).@"struct".decls.len]Theme = undefined; + for (@typeInfo(C).@"struct".decls, 0..) |d, i| { + var t: Theme = undefined; + const source = @field(C, d.name).theme; + for (@typeInfo(Theme).@"struct".fields) |f| { + @field(t, f.name) = if (@hasField(@TypeOf(source), f.name)) + @field(source, f.name) + else if (f.default_value_ptr) |ptr| + @as(*const f.type, @ptrCast(@alignCast(ptr))).* + else + @compileError("theme " ++ d.name ++ " is missing " ++ f.name); + } + out[i] = t; + } + return out; + } +} + +pub const themes = fold(curated) ++ fold(@import("generated_themes")); + +/// Original and classic-inspired Pardes palettes precede retained imports. +pub const native_theme_count = 15; + +fn themeContrast(a: [3]u8, b: [3]u8) f64 { + var luminance: [2]f64 = .{ 0, 0 }; + for ([2][3]u8{ a, b }, 0..) |rgb, i| { + for (rgb, [3]f64{ 0.2126, 0.7152, 0.0722 }) |channel, weight| { + const srgb = @as(f64, @floatFromInt(channel)) / 255; + const linear = if (srgb <= 0.04045) srgb / 12.92 else std.math.pow(f64, (srgb + 0.055) / 1.055, 2.4); + luminance[i] += weight * linear; + } + } + return (@max(luminance[0], luminance[1]) + 0.05) / (@min(luminance[0], luminance[1]) + 0.05); +} + +comptime { + if (themes.len > std.math.maxInt(u16) + 1) + @compileError("runtime config theme index no longer fits u16"); +} + +comptime { + @setEvalBranchQuota(20000); + const ours = fold(curated).len; + for (themes[0..ours]) |c| for (themes[ours..]) |g| if (std.mem.eql(u8, c.name, g.name)) + @compileError("theme name \"" ++ c.name ++ "\" is both ours and generated; rename the vendored source"); +} + +pub const ChromeTheme = struct { + tag_bg: [3]u8, + tag_fg: [3]u8, + tag_active_bg: [3]u8, + tag_active_fg: [3]u8, + tag_name_fg: [3]u8, + tag_active_name_fg: [3]u8, + border: [3]u8, + search_bg: [3]u8, + search_fg: [3]u8, + diagnostic_error: [3]u8, + diagnostic_warning: [3]u8, + diagnostic_info: [3]u8, + diagnostic_hint: [3]u8, + box: [3]u8, + box_dim: [3]u8, + column_box: [3]u8, + column_box_dim: [3]u8, + lineno: [3]u8, + scroll_track: [3]u8, + scroll_thumb: [3]u8, + + pub fn fromTheme(th: *const Theme) ChromeTheme { + return .{ + .tag_bg = th.tag_bg, + .tag_fg = th.tag_fg, + .tag_active_bg = th.tag_active_bg orelse th.tag_bg, + .tag_active_fg = th.tag_active_fg orelse th.tag_fg, + .tag_name_fg = th.tag_name_fg orelse th.tag_fg, + .tag_active_name_fg = th.tag_active_name_fg orelse (th.tag_name_fg orelse (th.tag_active_fg orelse th.tag_fg)), + .border = th.border orelse th.scroll_track, + .search_bg = th.search_bg orelse th.sel_bg, + .search_fg = th.search_fg orelse th.sel_fg, + .diagnostic_error = th.diagnostic_error orelse (th.fg orelse th.tag_fg), + .diagnostic_warning = th.diagnostic_warning orelse (th.fg orelse th.tag_fg), + .diagnostic_info = th.diagnostic_info orelse (th.fg orelse th.tag_fg), + .diagnostic_hint = th.diagnostic_hint orelse (th.fg orelse th.tag_fg), + .box = th.box, + .box_dim = th.box_dim, + .column_box = th.column_box orelse th.num, + .column_box_dim = th.column_box_dim orelse mix(th.column_box orelse th.num, th.tag_bg), + .lineno = th.lineno, + .scroll_track = th.scroll_track, + .scroll_thumb = th.scroll_thumb, + }; + } + + pub fn interpolate(from: ChromeTheme, to: ChromeTheme, step: u16, steps: u16) ChromeTheme { + var out: ChromeTheme = undefined; + inline for (@typeInfo(ChromeTheme).@"struct".fields) |field| + @field(out, field.name) = layout.Animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps); + return out; + } +}; + +pub const ChromeAnimation = if (theme_animation) + layout.Animation.Transition(ChromeTheme) +else + layout.Animation.Immediate(ChromeTheme); + +pub const initial_chrome = ChromeTheme.fromTheme(&themes[0]); + +fn nextThemeFileGeneration(p: *Pardes) u32 { + p.theme_file_generation +%= 1; + if (p.theme_file_generation == 0) p.theme_file_generation = 1; + return p.theme_file_generation; +} + +pub fn requestThemeFile(p: *Pardes, id: usize, argument: []const u8) void { + if (comptime !hosted) return; + const input = std.mem.trim(u8, argument, " \t\r\n"); + if (input.len == 0 or !std.mem.endsWith(u8, input, ".zon")) { + p.reportError(id, "theme file", error.InvalidThemePath); + return; + } + + const absolute = std.fs.path.isAbsolute(input); + if (!absolute and p.opts.config_dir == null) { + p.reportError(id, "theme file", error.NoConfigDirectory); + return; + } + + var joined: [4095]u8 = undefined; + const resolved = if (absolute) input else path: { + const dir = p.opts.config_dir.?; + const separator = std.Io.Dir.path.sep; + const needs_separator = dir.len > 0 and dir[dir.len - 1] != separator; + const len = dir.len + @intFromBool(needs_separator) + input.len; + if (len > joined.len) { + p.reportError(id, "theme file", error.PathTooLong); + return; + } + @memcpy(joined[0..dir.len], dir); + var at = dir.len; + if (needs_separator) { + joined[at] = separator; + at += 1; + } + @memcpy(joined[at..][0..input.len], input); + break :path joined[0..len]; + }; + if (!p.theme_file_path.set(resolved)) { + p.reportError(id, "theme file", error.PathTooLong); + return; + } + p.theme_file_pane = @intCast(@min(id, MAX_PANES - 1)); + const generation = nextThemeFileGeneration(p); + p.emit(.{ .theme_file = .{ .generation = generation, .on = true } }); +} + +pub const ThemeFileRequest = struct { + path: []const u8, + pane: u8, +}; + +/// Borrow the current request while a native host performs its synchronous +/// read. A later command invalidates an already-queued generation. +pub fn themeFileRequest(p: *const Pardes, generation: u32) ?ThemeFileRequest { + if (generation == 0 or generation != p.theme_file_generation) return null; + const path = p.theme_file_path.get(); + if (path.len == 0) return null; + return .{ .path = path, .pane = p.theme_file_pane }; +} + +pub fn failThemeFile(p: *Pardes, generation: u32, err: anyerror) void { + const request = themeFileRequest(p, generation) orelse return; + p.reportError(request.pane, "theme file", err); +} + +pub fn loadThemeFile(p: *Pardes, generation: u32, bytes: []const u8, animate: bool) bool { + // ZON's generated field dispatch grows with the complete theme schema. + @setEvalBranchQuota(10000); + const request = themeFileRequest(p, generation) orelse return false; + if (bytes.len > 1024 * 1024) { + p.reportError(request.pane, "theme file", error.ThemeFileTooLarge); + return false; + } + const source = p.gpa.dupeZ(u8, bytes) catch |err| { + p.reportError(request.pane, "theme file", err); + return false; + }; + defer p.gpa.free(source); + var diagnostics: std.zon.parse.Diagnostics = .{}; + defer diagnostics.deinit(p.gpa); + const parsed = std.zon.parse.fromSliceAlloc(Theme, p.gpa, source, &diagnostics, .{}) catch |err| { + p.reportError(request.pane, "theme file", err); + return false; + }; + if (parsed.name.len == 0 or parsed.name.len > 255) { + std.zon.parse.free(p.gpa, parsed); + p.reportError(request.pane, "theme file", error.InvalidThemeName); + return false; + } + + const target_chrome = ChromeTheme.fromTheme(&parsed); + if (animate and p.animate_theme_changes) + p.chrome_animation.retarget(target_chrome) + else + p.chrome_animation.snap(target_chrome); + const old = p.custom_theme; + p.custom_theme = parsed; + if (old) |theme_value| std.zon.parse.free(p.gpa, theme_value); + invalidateThemeDependentRasters(p); + _ = p.scratch.reset(.retain_capacity); + p.sync(); + return true; +} + +pub fn finishThemeInitialization(p: *Pardes) void { + p.chrome_animation.snap(ChromeTheme.fromTheme(p.theme())); + p.animate_theme_changes = true; +} + +fn invalidateThemeDependentRasters(p: *Pardes) void { + if (comptime pdf_enabled) for (p.panes) |slot| { + const pane = slot orelse continue; + const pv = &(pane.pdf orelse continue); + if (pv.tint != .disabled) pv.invalidateAllRasters(); + }; +} + +pub fn setThemeIndex(p: *Pardes, index: usize) void { + if (index >= themes.len or + (p.custom_theme == null and p.theme_file_path.get().len == 0 and + index == @as(usize, p.settings.theme))) return; + const target_chrome = ChromeTheme.fromTheme(&themes[index]); + if (p.animate_theme_changes) + p.chrome_animation.retarget(target_chrome) + else + p.chrome_animation.snap(target_chrome); + if (p.custom_theme) |theme_value| { + p.custom_theme = null; + std.zon.parse.free(p.gpa, theme_value); + } + if (p.theme_file_path.get().len > 0) { + p.theme_file_path.clear(); + const generation = nextThemeFileGeneration(p); + p.emit(.{ .theme_file = .{ .generation = generation, .on = false } }); + } + p.settings.theme = @intCast(index); + invalidateThemeDependentRasters(p); +} + +test "ThemeFile parses ZON atomically and Theme returns to the compiled ring" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ + .tty_only = true, + .config_dir = "/tmp/pardes-config", + }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + requestThemeFile(p, 0, "themes/mine.zon"); + const request_effect = while (p.nextEffect()) |effect| switch (effect) { + .theme_file => |request| break request, + else => {}, + } else return error.MissingThemeFileEffect; + try std.testing.expect(request_effect.on); + const request = themeFileRequest(p, request_effect.generation) orelse + return error.MissingThemeFileRequest; + try std.testing.expectEqualStrings("/tmp/pardes-config/themes/mine.zon", request.path); + + var custom = themes[0]; + custom.name = "mine"; + custom.bg = .{ 1, 2, 3 }; + custom.tag_bg = .{ 4, 5, 6 }; + var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer encoded.deinit(); + try std.zon.stringify.serialize(custom, .{ .whitespace = true }, &encoded.writer); + try std.testing.expect(loadThemeFile(p, request_effect.generation, encoded.written(), false)); + try std.testing.expectEqualStrings("mine", p.theme().name); + try std.testing.expectEqual(@as(?[3]u8, .{ 1, 2, 3 }), p.theme().bg); + try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); + + // A half-written save is rejected without disturbing the last good value. + try std.testing.expect(!loadThemeFile(p, request_effect.generation, ".{ .name = ", true)); + try std.testing.expectEqualStrings("mine", p.theme().name); + try std.testing.expectEqual(@as(?[3]u8, .{ 1, 2, 3 }), p.theme().bg); + + setThemeIndex(p, 0); + try std.testing.expectEqualStrings(themes[0].name, p.theme().name); + try std.testing.expect(themeFileRequest(p, request_effect.generation) == null); + const stop = while (p.nextEffect()) |effect| switch (effect) { + .theme_file => |theme_file| break theme_file, + else => {}, + } else return error.MissingThemeFileStop; + try std.testing.expect(!stop.on); +} + +test "runtime theme changes animate chrome and retarget without a jump" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const first = p.chromeTheme().*; + try std.testing.expect(!p.animationActive()); + + const dark_index = for (themes, 0..) |th, i| { + if (std.mem.eql(u8, th.name, "dark")) break i; + } else return error.MissingDarkTheme; + const acme_index = for (themes, 0..) |th, i| { + if (std.mem.eql(u8, th.name, "acme")) break i; + } else return error.MissingAcmeTheme; + + setThemeIndex(p, dark_index); + // Target semantics are immediate, while the displayed chrome begins at + // the exact previous frame. + try std.testing.expectEqualStrings("dark", p.theme().name); + try std.testing.expectEqual(first, p.chromeTheme().*); + try std.testing.expect(p.animationActive()); + p.update(.tick); + p.update(.tick); + p.update(.tick); + const midflight = p.chromeTheme().*; + try std.testing.expect(!std.meta.eql(first, midflight)); + + setThemeIndex(p, acme_index); + try std.testing.expectEqualStrings("acme", p.theme().name); + try std.testing.expectEqual(midflight, p.chromeTheme().*); + try std.testing.expect(p.animationActive()); + for (0..layout.Animation.transition_steps) |_| p.update(.tick); + try std.testing.expect(!p.animationActive()); + try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); + // Extra ticks are inert at the exact endpoint. + p.update(.tick); + try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); +} + +test "render uses target content colors while chrome is in flight" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + const source_chrome = p.chromeTheme().*; + const acme_index = for (themes, 0..) |th, i| { + if (std.mem.eql(u8, th.name, "acme")) break i; + } else return error.MissingAcmeTheme; + setThemeIndex(p, acme_index); + + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + const surface = try p.render(frame.allocator()); + // The anchored topbar has not advanced from its source frame. + try std.testing.expectEqual( + Color{ .rgb = source_chrome.tag_bg }, + surface.at(0, 0).style.bg, + ); + const topbar_cell = surface.at(0, 0); + try std.testing.expect(!std.mem.eql(u8, topbar_cell.grapheme(), " ")); + try std.testing.expectEqual(FontRole.tagline, topbar_cell.style.font_role); + const r = p.rects[0]; + const tag_cell = find_tag_cell: { + var x = r.x + config.GUTTER; + while (x < r.x + r.w) : (x += 1) { + const cell = surface.at(x, r.y); + if (!std.mem.eql(u8, cell.grapheme(), " ")) break :find_tag_cell cell; + } + return error.MissingTagText; + }; + try std.testing.expectEqual(FontRole.tagline, tag_cell.style.font_role); + try std.testing.expectEqual(FontRole.tagline, surface.at(r.x, r.y).style.font_role); + try std.testing.expectEqual(FontRole.tagline, surface.at(r.x + r.w - 1, r.y).style.font_role); + // The document/terminal page does not ride the chrome animation: acme's + // target background is already present in the same render. + const body = surface.at(r.x + r.w - 1, r.y + r.h - 1); + try std.testing.expectEqual(FontRole.body, body.style.font_role); + try std.testing.expectEqual( + Color{ .rgb = p.theme().bg.? }, + body.style.bg, + ); + try std.testing.expect(p.animationActive()); +} + +test "native themes keep text and UI roles readable on their own surfaces" { + const names = [_][]const u8{ "orchard", "dusk", "ink", "paper", "daybreak", "atelier", "forge", "lagoon", "solarium", "spectrum", "harvest", "clay", "forge_black", "forge_soft", "orchard_black" }; + try std.testing.expectEqual(names.len, native_theme_count); + for (themes[0..names.len], names) |th, name| { + try std.testing.expectEqualStrings(name, th.name); + const chrome = ChromeTheme.fromTheme(&th); + const minimum: f64 = if (std.mem.eql(u8, name, "ink") or std.mem.eql(u8, name, "daybreak")) 7 else 4.5; + const text_colors = [_][3]u8{ + th.fg.?, th.kw, th.str, th.num, th.comment, + chrome.diagnostic_error, chrome.diagnostic_warning, chrome.diagnostic_info, chrome.diagnostic_hint, + }; + for (text_colors) |fg| try std.testing.expect(themeContrast(fg, th.bg.?) >= minimum); + // Gutters are orientation aids, not source text: stay visible without + // competing with comments or pulling attention away from the body. + try std.testing.expect(themeContrast(th.lineno, th.bg.?) >= 2.5); + try std.testing.expect(themeContrast(th.lineno, th.bg.?) <= 4); + try std.testing.expect(themeContrast(th.lineno_active.?, th.bg.?) >= themeContrast(th.lineno, th.bg.?)); + try std.testing.expect(themeContrast(th.lineno_active.?, th.bg.?) <= 4.5); + try std.testing.expect(themeContrast(th.comment, th.bg.?) >= 5); + try std.testing.expect(themeContrast(th.comment, th.bg.?) >= themeContrast(th.lineno, th.bg.?) + 2); + try std.testing.expect(themeContrast(th.tag_fg, th.tag_bg) >= minimum); + try std.testing.expect(themeContrast(chrome.tag_active_fg, chrome.tag_active_bg) >= minimum); + try std.testing.expect(th.tag_name_fg != null); + try std.testing.expect(themeContrast(chrome.tag_name_fg, th.tag_bg) >= minimum); + try std.testing.expect(themeContrast(chrome.tag_active_name_fg, chrome.tag_active_bg) >= minimum); + // Focus stays on the same tonal surface, rather than inverting a + // whole command strip. Text remains readable in both states. + try std.testing.expect(themeContrast(chrome.tag_active_bg, th.tag_bg) <= 1.6); + try std.testing.expect(themeContrast(chrome.tag_active_fg, th.tag_fg) <= 1.6); + try std.testing.expect(themeContrast(th.sel_fg, th.sel_bg) >= minimum); + try std.testing.expect(themeContrast(chrome.search_fg, chrome.search_bg) >= minimum); + // ANSI black can intentionally match the page (for backgrounds). All + // other ANSI foregrounds, including bright black, remain legible. + for (th.palette.?[1..]) |fg| try std.testing.expect(themeContrast(fg, th.bg.?) >= minimum); + } +} + +test "native filename tints change hue without changing perceived lightness" { + const Perceptual = struct { + fn lab(rgb: [3]u8) [3]f64 { + var linear: [3]f64 = undefined; + for (rgb, 0..) |channel, i| { + const value = @as(f64, @floatFromInt(channel)) / 255; + linear[i] = if (value <= 0.04045) value / 12.92 else std.math.pow(f64, (value + 0.055) / 1.055, 2.4); + } + const r = linear[0]; + const g = linear[1]; + const b = linear[2]; + const l = std.math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = std.math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const n = std.math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + return .{ + 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * n, + 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * n, + 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * n, + }; + } + }; + for (themes[0..native_theme_count]) |th| { + try std.testing.expect(th.tag_name_fg != null and th.tag_active_name_fg != null); + const chrome = ChromeTheme.fromTheme(&th); + const pairs = [2][2][3]u8{ + .{ chrome.tag_fg, chrome.tag_name_fg }, + .{ chrome.tag_active_fg, chrome.tag_active_name_fg }, + }; + for (pairs) |pair| { + const base = Perceptual.lab(pair[0]); + const name = Perceptual.lab(pair[1]); + // Half a lightness point allows 8-bit quantization without a + // basename becoming visibly brighter or darker than its tag. + try std.testing.expectApproxEqAbs(base[0], name[0], 0.005); + const base_chroma = @sqrt(base[1] * base[1] + base[2] * base[2]); + const name_chroma = @sqrt(name[1] * name[1] + name[2] * name[2]); + try std.testing.expect(base_chroma > 0.005 and name_chroma > 0.02); + // A normalized dot product <= cos(60 degrees) means a clear + // hue difference, independent of saturation or lightness. + const hue_cosine = (base[1] * name[1] + base[2] * name[2]) / (base_chroma * name_chroma); + try std.testing.expect(hue_cosine <= 0.5); + } + } +} + +test "native dark palettes choose near-black or deliberately soft contrast" { + for (themes[0..native_theme_count]) |th| { + if (themeContrast(th.bg.?, .{ 0, 0, 0 }) > 10) continue; // light paper + const soft = std.mem.eql(u8, th.name, "dusk") or std.mem.eql(u8, th.name, "lagoon") or + std.mem.eql(u8, th.name, "solarium") or std.mem.eql(u8, th.name, "forge_soft"); + if (soft) { + try std.testing.expect(themeContrast(th.fg.?, th.bg.?) >= 4.5); + try std.testing.expect(themeContrast(th.fg.?, th.bg.?) <= 7); + } else { + for (th.bg.?) |channel| try std.testing.expect(channel <= 0x14); + try std.testing.expect(themeContrast(th.fg.?, th.bg.?) >= 14); + } + } + const forge = curated.forge.theme; + try std.testing.expectEqual(forge.bg[0], forge.bg[1]); + // Forge chrome is cool-neutral, never the old green-over-blue mint cast. + for ([_][3]u8{ forge.tag_bg, forge.tag_active_bg, forge.box, forge.border }) |rgb| + try std.testing.expect(rgb[2] >= rgb[1]); + try std.testing.expectEqual([3]u8{ 0, 0, 0 }, @as([3]u8, curated.forge_black.theme.bg)); + try std.testing.expectEqual([3]u8{ 0, 0, 0 }, @as([3]u8, curated.orchard_black.theme.bg)); +} + +test "line numbers stay quiet without a current-line tag background" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const pane = try p.setTestFile("first\nsecond\n"); + pane.cur_pinned = true; + pane.cur_row = 0; + var custom = themes[0]; + custom.name = try gpa.dupe(u8, "quiet-gutter"); + custom.lineno = .{ 70, 70, 70 }; + custom.lineno_active = .{ 95, 95, 95 }; + p.custom_theme = custom; + p.sync(); + const x = p.rects[0].x + config.GUTTER; + const y = p.rects[0].y + BOX_H; + for ([_]bool{ false, true }) |focus_tint| { + p.settings.focus_tint = focus_tint; + const surface = try p.render(p.scratch.allocator()); + try std.testing.expectEqual(Color{ .rgb = .{ 95, 95, 95 } }, surface.at(x, y).style.fg); + try std.testing.expectEqual(Color{ .rgb = .{ 70, 70, 70 } }, surface.at(x, y + 1).style.fg); + try std.testing.expectEqual(Color{ .rgb = custom.bg.? }, surface.at(x, y).style.bg); + try std.testing.expect(surface.at(x, y).style.bold); + try std.testing.expect(!surface.at(x, y + 1).style.bold); + } + // Old ThemeFile palettes need no new field, and do not regain the block. + p.custom_theme.?.lineno_active = null; + const fallback = try p.render(p.scratch.allocator()); + try std.testing.expectEqual(Color{ .rgb = .{ 70, 70, 70 } }, fallback.at(x, y).style.fg); + try std.testing.expectEqual(Color{ .rgb = custom.bg.? }, fallback.at(x, y).style.bg); + try std.testing.expect(fallback.at(x, y).style.bold); + // Moving the caret clears the previous cue; editing the tag parks it. + pane.cur_row = 1; + const moved = try p.render(p.scratch.allocator()); + try std.testing.expect(!moved.at(x, y).style.bold); + try std.testing.expect(moved.at(x, y + 1).style.bold); + p.enterTagEdit(pane, -1); + const tag_edit = try p.render(p.scratch.allocator()); + try std.testing.expect(!tag_edit.at(x, y + 1).style.bold); +} + +test "legacy ThemeFile documents inherit Pardes UI roles without new fields" { + @setEvalBranchQuota(10000); + var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer encoded.deinit(); + try std.zon.stringify.serialize(curated.helix.theme, .{ .whitespace = true }, &encoded.writer); + const source = try std.testing.allocator.dupeZ(u8, encoded.written()); + defer std.testing.allocator.free(source); + const parsed = try std.zon.parse.fromSliceAlloc(Theme, std.testing.allocator, source, null, .{}); + defer std.zon.parse.free(std.testing.allocator, parsed); + try std.testing.expectEqual(@as(?[3]u8, null), parsed.tag_active_bg); + try std.testing.expectEqual(@as(?[3]u8, null), parsed.tag_name_fg); + try std.testing.expectEqual(@as(?[3]u8, null), parsed.tag_active_name_fg); + try std.testing.expectEqual(@as(?[3]u8, null), parsed.column_box); + try std.testing.expectEqual(@as(?[3]u8, null), parsed.column_box_dim); + try std.testing.expectEqual(@as(?[3]u8, null), parsed.search_bg); + const chrome = ChromeTheme.fromTheme(&parsed); + try std.testing.expectEqual(parsed.tag_bg, chrome.tag_active_bg); + try std.testing.expectEqual(parsed.tag_fg, chrome.tag_name_fg); + try std.testing.expectEqual(chrome.tag_active_fg, chrome.tag_active_name_fg); + try std.testing.expectEqual(parsed.num, chrome.column_box); + try std.testing.expectEqual(mix(parsed.num, parsed.tag_bg), chrome.column_box_dim); + try std.testing.expectEqual(parsed.sel_bg, chrome.search_bg); + try std.testing.expectEqual(parsed.fg.?, chrome.diagnostic_error); +} + +test "column grip colors remain distinct from pane grips across native themes" { + for (themes[0..native_theme_count]) |th| { + try std.testing.expect(th.column_box != null and th.column_box_dim != null); + const chrome = ChromeTheme.fromTheme(&th); + for ([_][2][3]u8{ + .{ chrome.box, chrome.column_box }, + .{ chrome.box_dim, chrome.column_box_dim }, + }) |pair| { + var distance: u32 = 0; + for (pair[0], pair[1]) |a, b| { + const delta = @as(i32, a) - b; + distance += @intCast(delta * delta); + } + try std.testing.expect(distance >= 30 * 30); + } + } + var custom = themes[0]; + custom.column_box = .{ 90, 130, 170 }; + custom.column_box_dim = null; + var chrome = ChromeTheme.fromTheme(&custom); + try std.testing.expectEqual(custom.column_box.?, chrome.column_box); + try std.testing.expectEqual(mix(custom.column_box.?, custom.tag_bg), chrome.column_box_dim); + custom.column_box_dim = .{ 40, 60, 80 }; + chrome = ChromeTheme.fromTheme(&custom); + try std.testing.expectEqual(custom.column_box_dim.?, chrome.column_box_dim); +} + +test "custom filename tints preserve optional active fallback precedence" { + var theme = themes[0]; + theme.tag_name_fg = null; + theme.tag_active_name_fg = null; + var chrome = ChromeTheme.fromTheme(&theme); + try std.testing.expectEqual(theme.tag_fg, chrome.tag_name_fg); + try std.testing.expectEqual(theme.tag_active_fg.?, chrome.tag_active_name_fg); + theme.tag_name_fg = .{ 120, 130, 140 }; + chrome = ChromeTheme.fromTheme(&theme); + try std.testing.expectEqual(theme.tag_name_fg.?, chrome.tag_name_fg); + try std.testing.expectEqual(theme.tag_name_fg.?, chrome.tag_active_name_fg); + theme.tag_active_name_fg = .{ 150, 160, 170 }; + chrome = ChromeTheme.fromTheme(&theme); + try std.testing.expectEqual(theme.tag_name_fg.?, chrome.tag_name_fg); + try std.testing.expectEqual(theme.tag_active_name_fg.?, chrome.tag_active_name_fg); + theme.tag_name_fg = null; + chrome = ChromeTheme.fromTheme(&theme); + try std.testing.expectEqual(theme.tag_fg, chrome.tag_name_fg); + try std.testing.expectEqual(theme.tag_active_name_fg.?, chrome.tag_active_name_fg); +} diff --git a/src/file_watch.zig b/src/file_watch.zig index dfcce65f..2d1c0d25 100644 --- a/src/file_watch.zig +++ b/src/file_watch.zig @@ -13,6 +13,7 @@ const builtin = @import("builtin"); const libc = std.c; const linux = std.os.linux; const pardes = @import("pardes.zig"); +const colors = @import("colors.zig"); const filesystem = @import("fs.zig"); const message = pardes.Pardes.Message; @@ -551,13 +552,13 @@ pub fn applyThemeEffect( ) bool { watchPath(fd, watches, theme_slot, null, 0, .{ .text = 0 }); if (!on) return false; - const request = core.themeFileRequest(generation) orelse return false; + const request = colors.themeFileRequest(core, generation) orelse return false; const bytes = filesystem.readFile(gpa, request.path) catch |err| { - core.failThemeFile(generation, err); + colors.failThemeFile(core, generation, err); return false; }; defer gpa.free(bytes); - if (!core.loadThemeFile(generation, bytes, animate)) return false; + if (!colors.loadThemeFile(core, generation, bytes, animate)) return false; const hash = std.hash.Wyhash.hash(0, bytes); watchPath(fd, watches, theme_slot, request.path, generation, .{ .text = hash }); if (watches[theme_slot] == null) return false; @@ -574,7 +575,7 @@ pub fn reloadTheme( announce: bool, ) bool { const watched = watches[theme_slot] orelse return false; - const request = core.themeFileRequest(watched.serial) orelse return false; + const request = colors.themeFileRequest(core, watched.serial) orelse return false; // Same re-arm as reloadPane's, for the same reason: a .zon saved by rename // moves the inode the file filter is on. if (comptime builtin.os.tag == .macos) { @@ -582,7 +583,7 @@ pub fn reloadTheme( } const bytes = filesystem.readFile(gpa, request.path) catch |err| { core.invalidate(); - core.failThemeFile(watched.serial, err); + colors.failThemeFile(core, watched.serial, err); return false; }; defer gpa.free(bytes); @@ -591,7 +592,7 @@ pub fn reloadTheme( .text => |accepted| if (accepted == hash) return false, .pdf => unreachable, } - if (!core.loadThemeFile(watched.serial, bytes, true)) return false; + if (!colors.loadThemeFile(core, watched.serial, bytes, true)) return false; core.invalidate(); const live = if (watches[theme_slot]) |*entry| entry else return false; if (live.serial != watched.serial) return false; @@ -784,7 +785,7 @@ test "theme watch reloads valid ZON and keeps the last theme across a bad save" const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); while (core.nextEffect()) |_| {} - core.requestThemeFile(0, path); + colors.requestThemeFile(core, 0, path); const request = while (core.nextEffect()) |effect| switch (effect) { .theme_file => |theme_file| break theme_file, else => {}, diff --git a/src/fs.zig b/src/fs.zig index e1fe0b9c..86154fc1 100644 --- a/src/fs.zig +++ b/src/fs.zig @@ -37,6 +37,7 @@ const source_files = [_]Source{ .{ .path = "src/pardes.zig", .contents = @embedFile("pardes.zig") }, .{ .path = "src/panes.zig", .contents = @embedFile("panes.zig") }, .{ .path = "src/surface.zig", .contents = @embedFile("surface.zig") }, + .{ .path = "src/colors.zig", .contents = @embedFile("colors.zig") }, .{ .path = "src/File.zig", .contents = @embedFile("File.zig") }, .{ .path = "src/Output.zig", .contents = @embedFile("Output.zig") }, .{ .path = "src/Mini.zig", .contents = @embedFile("Mini.zig") }, diff --git a/src/macos.zig b/src/macos.zig index b19c5996..402b3c12 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -6,6 +6,7 @@ const builtin = @import("builtin"); const posix = std.posix; const libc = std.c; const pardes = @import("pardes.zig"); +const colors = @import("colors.zig"); const look = @import("look.zig"); const message = pardes.Pardes.Message; const layout = @import("layout.zig"); @@ -1133,13 +1134,13 @@ fn setThemeFileWatch(st: *State, request_generation: u32, on: bool, animate: boo const stopped = st.file_watches.stop(st.gpa, theme_watch_pane); hostWatchFile(theme_watch_pane, stopped, null, 0); if (!on) return; - const request = st.core.themeFileRequest(request_generation) orelse return; + const request = colors.themeFileRequest(st.core, request_generation) orelse return; const bytes = filesystem.readFile(st.gpa, request.path) catch |err| { - st.core.failThemeFile(request_generation, err); + colors.failThemeFile(st.core, request_generation, err); return; }; defer st.gpa.free(bytes); - if (!st.core.loadThemeFile(request_generation, bytes, animate)) return; + if (!colors.loadThemeFile(st.core, request_generation, bytes, animate)) return; const callback_generation = st.file_watches.replace( st.gpa, theme_watch_pane, @@ -1154,10 +1155,10 @@ fn setThemeFileWatch(st: *State, request_generation: u32, on: bool, animate: boo fn reloadWatchedTheme(st: *State, announce: bool) bool { const watched = if (st.file_watches.entries[theme_watch_pane]) |*entry| entry else return false; - const request = st.core.themeFileRequest(watched.serial) orelse return false; + const request = colors.themeFileRequest(st.core, watched.serial) orelse return false; if (!std.mem.eql(u8, watched.path, request.path)) return false; const bytes = filesystem.readFile(st.gpa, watched.path) catch |err| { - st.core.failThemeFile(watched.serial, err); + colors.failThemeFile(st.core, watched.serial, err); return false; }; defer st.gpa.free(bytes); @@ -1166,7 +1167,7 @@ fn reloadWatchedTheme(st: *State, announce: bool) bool { .text => |accepted| if (accepted == hash) return false, .pdf => unreachable, } - if (!st.core.loadThemeFile(watched.serial, bytes, true)) return false; + if (!colors.loadThemeFile(st.core, watched.serial, bytes, true)) return false; const live = if (st.file_watches.entries[theme_watch_pane]) |*entry| entry else return false; if (live.serial != watched.serial) return false; live.generation_on_disk = .{ .text = hash }; diff --git a/src/pardes.zig b/src/pardes.zig index 23cb1734..cb020d89 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -11,6 +11,11 @@ pub const syntax = @import("syntax.zig"); pub const locations_config = @import("locations_config.zig"); const tracy = @import("tracy.zig"); pub const panes = @import("panes.zig"); +pub const colors = @import("colors.zig"); +pub const Theme = colors.Theme; +pub const themes = colors.themes; +pub const native_theme_count = colors.native_theme_count; +pub const ChromeTheme = colors.ChromeTheme; /// The screen types live in surface.zig; every shell names them through here. pub const Color = @import("surface.zig").Color; pub const FontRole = @import("surface.zig").FontRole; @@ -425,6 +430,7 @@ pub const Loc = struct { }; test { + _ = @import("colors.zig"); _ = @import("surface.zig"); _ = @import("body_layer.zig"); _ = @import("tag_layer.zig"); @@ -1001,52 +1007,6 @@ test "startup config runs builtin lines in order and isolates bad lines" { }; } -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()) |_| {} - - p.requestThemeFile(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 = p.themeFileRequest(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(p.loadThemeFile(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(!p.loadThemeFile(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); - - p.setThemeIndex(0); - try std.testing.expectEqualStrings(themes[0].name, p.theme().name); - try std.testing.expect(p.themeFileRequest(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 "scene effects keep the idle frontend animation clock active" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); @@ -1062,86 +1022,6 @@ test "scene effects keep the idle frontend animation clock active" { try std.testing.expect(!p.animationActive()); } -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; - - p.setThemeIndex(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)); - - p.setThemeIndex(acme_index); - try std.testing.expectEqualStrings("acme", p.theme().name); - try std.testing.expectEqual(midflight, p.chromeTheme().*); - try std.testing.expect(p.animationActive()); - for (0..layout.Animation.transition_steps) |_| p.update(.tick); - try std.testing.expect(!p.animationActive()); - try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); - // Extra ticks are inert at the exact endpoint. - p.update(.tick); - try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); -} - -test "render uses target content colors while chrome is in flight" { - const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); - defer p.deinit(); - const source_chrome = p.chromeTheme().*; - const acme_index = for (themes, 0..) |th, i| { - if (std.mem.eql(u8, th.name, "acme")) break i; - } else return error.MissingAcmeTheme; - p.setThemeIndex(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 "topbar hover highlights exactly the executable word" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); defer p.deinit(); @@ -3293,342 +3173,6 @@ const tutor_text = @embedFile("tutor.txt"); // ---- theme ---- -fn mix(a: [3]u8, b: [3]u8) [3]u8 { - var out: [3]u8 = undefined; - for (&out, a, b) |*c, x, y| c.* = @intCast((@as(u16, x) + y) / 2); - return out; -} - -pub const Theme = struct { - name: []const u8, - bg: ?[3]u8, - fg: ?[3]u8, - sel_bg: [3]u8, - sel_fg: [3]u8, - tag_bg: [3]u8, - tag_fg: [3]u8, - box: [3]u8, // move box, focused pane - box_dim: [3]u8, // move box, everything else - kw: [3]u8, - str: [3]u8, - num: [3]u8, - comment: [3]u8, - lineno: [3]u8, - scroll_track: [3]u8, - scroll_thumb: [3]u8, - palette: ?[16][3]u8, - - // Pardes UI roles. Optional so existing ThemeFile documents and imported - // editor palettes keep working; ChromeTheme resolves their legacy defaults. - tag_active_bg: ?[3]u8 = null, - tag_active_fg: ?[3]u8 = null, - /// Filename/output-name accent, also used by the Tty command. - tag_name_fg: ?[3]u8 = null, - /// Active tint; absence preserves the shared name tint or active tag ink. - tag_active_name_fg: ?[3]u8 = null, - /// Column grips stay distinct from pane grips in both focus states. - column_box: ?[3]u8 = null, - column_box_dim: ?[3]u8 = null, - border: ?[3]u8 = null, - search_bg: ?[3]u8 = null, - search_fg: ?[3]u8 = null, - diagnostic_error: ?[3]u8 = null, - diagnostic_warning: ?[3]u8 = null, - diagnostic_info: ?[3]u8 = null, - diagnostic_hint: ?[3]u8 = null, - // The current line number is a quiet foreground cue, not a tag-colored - // background block. Older themes fall back to their normal gutter ink. - lineno_active: ?[3]u8 = null, -}; - -const curated = struct { - pub const orchard = @import("themes/orchard.zig"); - pub const dusk = @import("themes/dusk.zig"); - pub const ink = @import("themes/ink.zig"); - pub const paper = @import("themes/paper.zig"); - pub const daybreak = @import("themes/daybreak.zig"); - pub const atelier = @import("themes/atelier.zig"); - pub const forge = @import("themes/forge.zig"); - pub const lagoon = @import("themes/lagoon.zig"); - pub const solarium = @import("themes/solarium.zig"); - pub const spectrum = @import("themes/spectrum.zig"); - pub const harvest = @import("themes/harvest.zig"); - pub const clay = @import("themes/clay.zig"); - pub const forge_black = @import("themes/forge_black.zig"); - pub const forge_soft = @import("themes/forge_soft.zig"); - pub const orchard_black = @import("themes/orchard_black.zig"); - pub const helix = @import("themes/helix.zig"); - pub const dark = @import("themes/dark.zig"); - pub const acme = @import("themes/acme.zig"); -}; - -fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { - comptime { - @setEvalBranchQuota(400000); - var out: [@typeInfo(C).@"struct".decls.len]Theme = undefined; - for (@typeInfo(C).@"struct".decls, 0..) |d, i| { - var t: Theme = undefined; - const source = @field(C, d.name).theme; - for (@typeInfo(Theme).@"struct".fields) |f| { - @field(t, f.name) = if (@hasField(@TypeOf(source), f.name)) - @field(source, f.name) - else if (f.default_value_ptr) |ptr| - @as(*const f.type, @ptrCast(@alignCast(ptr))).* - else - @compileError("theme " ++ d.name ++ " is missing " ++ f.name); - } - out[i] = t; - } - return out; - } -} - -pub const themes = fold(curated) ++ fold(@import("generated_themes")); - -/// Original and classic-inspired Pardes palettes precede retained imports. -pub const native_theme_count = 15; - -fn themeContrast(a: [3]u8, b: [3]u8) f64 { - var luminance: [2]f64 = .{ 0, 0 }; - for ([2][3]u8{ a, b }, 0..) |rgb, i| { - for (rgb, [3]f64{ 0.2126, 0.7152, 0.0722 }) |channel, weight| { - const srgb = @as(f64, @floatFromInt(channel)) / 255; - const linear = if (srgb <= 0.04045) srgb / 12.92 else std.math.pow(f64, (srgb + 0.055) / 1.055, 2.4); - luminance[i] += weight * linear; - } - } - return (@max(luminance[0], luminance[1]) + 0.05) / (@min(luminance[0], luminance[1]) + 0.05); -} - -test "native themes keep text and UI roles readable on their own surfaces" { - const names = [_][]const u8{ "orchard", "dusk", "ink", "paper", "daybreak", "atelier", "forge", "lagoon", "solarium", "spectrum", "harvest", "clay", "forge_black", "forge_soft", "orchard_black" }; - try std.testing.expectEqual(names.len, native_theme_count); - for (themes[0..names.len], names) |th, name| { - try std.testing.expectEqualStrings(name, th.name); - const chrome = ChromeTheme.fromTheme(&th); - const minimum: f64 = if (std.mem.eql(u8, name, "ink") or std.mem.eql(u8, name, "daybreak")) 7 else 4.5; - const text_colors = [_][3]u8{ - th.fg.?, th.kw, th.str, th.num, th.comment, - chrome.diagnostic_error, chrome.diagnostic_warning, chrome.diagnostic_info, chrome.diagnostic_hint, - }; - for (text_colors) |fg| try std.testing.expect(themeContrast(fg, th.bg.?) >= minimum); - // Gutters are orientation aids, not source text: stay visible without - // competing with comments or pulling attention away from the body. - try std.testing.expect(themeContrast(th.lineno, th.bg.?) >= 2.5); - try std.testing.expect(themeContrast(th.lineno, th.bg.?) <= 4); - try std.testing.expect(themeContrast(th.lineno_active.?, th.bg.?) >= themeContrast(th.lineno, th.bg.?)); - try std.testing.expect(themeContrast(th.lineno_active.?, th.bg.?) <= 4.5); - try std.testing.expect(themeContrast(th.comment, th.bg.?) >= 5); - try std.testing.expect(themeContrast(th.comment, th.bg.?) >= themeContrast(th.lineno, th.bg.?) + 2); - try std.testing.expect(themeContrast(th.tag_fg, th.tag_bg) >= minimum); - try std.testing.expect(themeContrast(chrome.tag_active_fg, chrome.tag_active_bg) >= minimum); - try std.testing.expect(th.tag_name_fg != null); - try std.testing.expect(themeContrast(chrome.tag_name_fg, th.tag_bg) >= minimum); - try std.testing.expect(themeContrast(chrome.tag_active_name_fg, chrome.tag_active_bg) >= minimum); - // Focus stays on the same tonal surface, rather than inverting a - // whole command strip. Text remains readable in both states. - try std.testing.expect(themeContrast(chrome.tag_active_bg, th.tag_bg) <= 1.6); - try std.testing.expect(themeContrast(chrome.tag_active_fg, th.tag_fg) <= 1.6); - try std.testing.expect(themeContrast(th.sel_fg, th.sel_bg) >= minimum); - try std.testing.expect(themeContrast(chrome.search_fg, chrome.search_bg) >= minimum); - // ANSI black can intentionally match the page (for backgrounds). All - // other ANSI foregrounds, including bright black, remain legible. - for (th.palette.?[1..]) |fg| try std.testing.expect(themeContrast(fg, th.bg.?) >= minimum); - } -} - -test "native filename tints change hue without changing perceived lightness" { - const Perceptual = struct { - fn lab(rgb: [3]u8) [3]f64 { - var linear: [3]f64 = undefined; - for (rgb, 0..) |channel, i| { - const value = @as(f64, @floatFromInt(channel)) / 255; - linear[i] = if (value <= 0.04045) value / 12.92 else std.math.pow(f64, (value + 0.055) / 1.055, 2.4); - } - const r = linear[0]; - const g = linear[1]; - const b = linear[2]; - const l = std.math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); - const m = std.math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); - const n = std.math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); - return .{ - 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * n, - 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * n, - 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * n, - }; - } - }; - for (themes[0..native_theme_count]) |th| { - try std.testing.expect(th.tag_name_fg != null and th.tag_active_name_fg != null); - const chrome = ChromeTheme.fromTheme(&th); - const pairs = [2][2][3]u8{ - .{ chrome.tag_fg, chrome.tag_name_fg }, - .{ chrome.tag_active_fg, chrome.tag_active_name_fg }, - }; - for (pairs) |pair| { - const base = Perceptual.lab(pair[0]); - const name = Perceptual.lab(pair[1]); - // Half a lightness point allows 8-bit quantization without a - // basename becoming visibly brighter or darker than its tag. - try std.testing.expectApproxEqAbs(base[0], name[0], 0.005); - const base_chroma = @sqrt(base[1] * base[1] + base[2] * base[2]); - const name_chroma = @sqrt(name[1] * name[1] + name[2] * name[2]); - try std.testing.expect(base_chroma > 0.005 and name_chroma > 0.02); - // A normalized dot product <= cos(60 degrees) means a clear - // hue difference, independent of saturation or lightness. - const hue_cosine = (base[1] * name[1] + base[2] * name[2]) / (base_chroma * name_chroma); - try std.testing.expect(hue_cosine <= 0.5); - } - } -} - -test "native dark palettes choose near-black or deliberately soft contrast" { - for (themes[0..native_theme_count]) |th| { - if (themeContrast(th.bg.?, .{ 0, 0, 0 }) > 10) continue; // light paper - const soft = std.mem.eql(u8, th.name, "dusk") or std.mem.eql(u8, th.name, "lagoon") or - std.mem.eql(u8, th.name, "solarium") or std.mem.eql(u8, th.name, "forge_soft"); - if (soft) { - try std.testing.expect(themeContrast(th.fg.?, th.bg.?) >= 4.5); - try std.testing.expect(themeContrast(th.fg.?, th.bg.?) <= 7); - } else { - for (th.bg.?) |channel| try std.testing.expect(channel <= 0x14); - try std.testing.expect(themeContrast(th.fg.?, th.bg.?) >= 14); - } - } - const forge = curated.forge.theme; - try std.testing.expectEqual(forge.bg[0], forge.bg[1]); - // Forge chrome is cool-neutral, never the old green-over-blue mint cast. - for ([_][3]u8{ forge.tag_bg, forge.tag_active_bg, forge.box, forge.border }) |rgb| - try std.testing.expect(rgb[2] >= rgb[1]); - try std.testing.expectEqual([3]u8{ 0, 0, 0 }, @as([3]u8, curated.forge_black.theme.bg)); - try std.testing.expectEqual([3]u8{ 0, 0, 0 }, @as([3]u8, curated.orchard_black.theme.bg)); -} - -test "line numbers stay quiet without a current-line tag background" { - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); - defer p.deinit(); - const pane = try p.setTestFile("first\nsecond\n"); - pane.cur_pinned = true; - pane.cur_row = 0; - var custom = themes[0]; - custom.name = try gpa.dupe(u8, "quiet-gutter"); - custom.lineno = .{ 70, 70, 70 }; - custom.lineno_active = .{ 95, 95, 95 }; - p.custom_theme = custom; - p.sync(); - const x = p.rects[0].x + config.GUTTER; - const y = p.rects[0].y + BOX_H; - for ([_]bool{ false, true }) |focus_tint| { - p.settings.focus_tint = focus_tint; - const surface = try p.render(p.scratch.allocator()); - try std.testing.expectEqual(Color{ .rgb = .{ 95, 95, 95 } }, surface.at(x, y).style.fg); - try std.testing.expectEqual(Color{ .rgb = .{ 70, 70, 70 } }, surface.at(x, y + 1).style.fg); - try std.testing.expectEqual(Color{ .rgb = custom.bg.? }, surface.at(x, y).style.bg); - try std.testing.expect(surface.at(x, y).style.bold); - try std.testing.expect(!surface.at(x, y + 1).style.bold); - } - // Old ThemeFile palettes need no new field, and do not regain the block. - p.custom_theme.?.lineno_active = null; - const fallback = try p.render(p.scratch.allocator()); - try std.testing.expectEqual(Color{ .rgb = .{ 70, 70, 70 } }, fallback.at(x, y).style.fg); - try std.testing.expectEqual(Color{ .rgb = custom.bg.? }, fallback.at(x, y).style.bg); - try std.testing.expect(fallback.at(x, y).style.bold); - // Moving the caret clears the previous cue; editing the tag parks it. - pane.cur_row = 1; - const moved = try p.render(p.scratch.allocator()); - try std.testing.expect(!moved.at(x, y).style.bold); - try std.testing.expect(moved.at(x, y + 1).style.bold); - p.enterTagEdit(pane, -1); - const tag_edit = try p.render(p.scratch.allocator()); - try std.testing.expect(!tag_edit.at(x, y + 1).style.bold); -} - -test "legacy ThemeFile documents inherit Pardes UI roles without new fields" { - @setEvalBranchQuota(10000); - var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); - defer encoded.deinit(); - try std.zon.stringify.serialize(curated.helix.theme, .{ .whitespace = true }, &encoded.writer); - const source = try std.testing.allocator.dupeZ(u8, encoded.written()); - defer std.testing.allocator.free(source); - const parsed = try std.zon.parse.fromSliceAlloc(Theme, std.testing.allocator, source, null, .{}); - defer std.zon.parse.free(std.testing.allocator, parsed); - try std.testing.expectEqual(@as(?[3]u8, null), parsed.tag_active_bg); - try std.testing.expectEqual(@as(?[3]u8, null), parsed.tag_name_fg); - try std.testing.expectEqual(@as(?[3]u8, null), parsed.tag_active_name_fg); - try std.testing.expectEqual(@as(?[3]u8, null), parsed.column_box); - try std.testing.expectEqual(@as(?[3]u8, null), parsed.column_box_dim); - try std.testing.expectEqual(@as(?[3]u8, null), parsed.search_bg); - const chrome = ChromeTheme.fromTheme(&parsed); - try std.testing.expectEqual(parsed.tag_bg, chrome.tag_active_bg); - try std.testing.expectEqual(parsed.tag_fg, chrome.tag_name_fg); - try std.testing.expectEqual(chrome.tag_active_fg, chrome.tag_active_name_fg); - try std.testing.expectEqual(parsed.num, chrome.column_box); - try std.testing.expectEqual(mix(parsed.num, parsed.tag_bg), chrome.column_box_dim); - try std.testing.expectEqual(parsed.sel_bg, chrome.search_bg); - try std.testing.expectEqual(parsed.fg.?, chrome.diagnostic_error); -} - -test "column grip colors remain distinct from pane grips across native themes" { - for (themes[0..native_theme_count]) |th| { - try std.testing.expect(th.column_box != null and th.column_box_dim != null); - const chrome = ChromeTheme.fromTheme(&th); - for ([_][2][3]u8{ - .{ chrome.box, chrome.column_box }, - .{ chrome.box_dim, chrome.column_box_dim }, - }) |pair| { - var distance: u32 = 0; - for (pair[0], pair[1]) |a, b| { - const delta = @as(i32, a) - b; - distance += @intCast(delta * delta); - } - try std.testing.expect(distance >= 30 * 30); - } - } - var custom = themes[0]; - custom.column_box = .{ 90, 130, 170 }; - custom.column_box_dim = null; - var chrome = ChromeTheme.fromTheme(&custom); - try std.testing.expectEqual(custom.column_box.?, chrome.column_box); - try std.testing.expectEqual(mix(custom.column_box.?, custom.tag_bg), chrome.column_box_dim); - custom.column_box_dim = .{ 40, 60, 80 }; - chrome = ChromeTheme.fromTheme(&custom); - try std.testing.expectEqual(custom.column_box_dim.?, chrome.column_box_dim); -} - -test "custom filename tints preserve optional active fallback precedence" { - var theme = themes[0]; - theme.tag_name_fg = null; - theme.tag_active_name_fg = null; - var chrome = ChromeTheme.fromTheme(&theme); - try std.testing.expectEqual(theme.tag_fg, chrome.tag_name_fg); - try std.testing.expectEqual(theme.tag_active_fg.?, chrome.tag_active_name_fg); - theme.tag_name_fg = .{ 120, 130, 140 }; - chrome = ChromeTheme.fromTheme(&theme); - try std.testing.expectEqual(theme.tag_name_fg.?, chrome.tag_name_fg); - try std.testing.expectEqual(theme.tag_name_fg.?, chrome.tag_active_name_fg); - theme.tag_active_name_fg = .{ 150, 160, 170 }; - chrome = ChromeTheme.fromTheme(&theme); - try std.testing.expectEqual(theme.tag_name_fg.?, chrome.tag_name_fg); - try std.testing.expectEqual(theme.tag_active_name_fg.?, chrome.tag_active_name_fg); - theme.tag_name_fg = null; - chrome = ChromeTheme.fromTheme(&theme); - try std.testing.expectEqual(theme.tag_fg, chrome.tag_name_fg); - try std.testing.expectEqual(theme.tag_active_name_fg.?, chrome.tag_active_name_fg); -} - -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"); -} - const boot_buffer = \\x selects a line, Tab runs it. 0x optional. \\ @@ -3668,67 +3212,6 @@ test "every line of the board's boot buffer renders whole" { try std.testing.expect(std.mem.indexOf(u8, boot_buffer, "\nGpio\n") != null); } -pub const ChromeTheme = struct { - tag_bg: [3]u8, - tag_fg: [3]u8, - tag_active_bg: [3]u8, - tag_active_fg: [3]u8, - tag_name_fg: [3]u8, - tag_active_name_fg: [3]u8, - border: [3]u8, - search_bg: [3]u8, - search_fg: [3]u8, - diagnostic_error: [3]u8, - diagnostic_warning: [3]u8, - diagnostic_info: [3]u8, - diagnostic_hint: [3]u8, - box: [3]u8, - box_dim: [3]u8, - column_box: [3]u8, - column_box_dim: [3]u8, - lineno: [3]u8, - scroll_track: [3]u8, - scroll_thumb: [3]u8, - - pub fn fromTheme(th: *const Theme) ChromeTheme { - return .{ - .tag_bg = th.tag_bg, - .tag_fg = th.tag_fg, - .tag_active_bg = th.tag_active_bg orelse th.tag_bg, - .tag_active_fg = th.tag_active_fg orelse th.tag_fg, - .tag_name_fg = th.tag_name_fg orelse th.tag_fg, - .tag_active_name_fg = th.tag_active_name_fg orelse (th.tag_name_fg orelse (th.tag_active_fg orelse th.tag_fg)), - .border = th.border orelse th.scroll_track, - .search_bg = th.search_bg orelse th.sel_bg, - .search_fg = th.search_fg orelse th.sel_fg, - .diagnostic_error = th.diagnostic_error orelse (th.fg orelse th.tag_fg), - .diagnostic_warning = th.diagnostic_warning orelse (th.fg orelse th.tag_fg), - .diagnostic_info = th.diagnostic_info orelse (th.fg orelse th.tag_fg), - .diagnostic_hint = th.diagnostic_hint orelse (th.fg orelse th.tag_fg), - .box = th.box, - .box_dim = th.box_dim, - .column_box = th.column_box orelse th.num, - .column_box_dim = th.column_box_dim orelse mix(th.column_box orelse th.num, th.tag_bg), - .lineno = th.lineno, - .scroll_track = th.scroll_track, - .scroll_thumb = th.scroll_thumb, - }; - } - - pub fn interpolate(from: ChromeTheme, to: ChromeTheme, step: u16, steps: u16) ChromeTheme { - var out: ChromeTheme = undefined; - inline for (@typeInfo(ChromeTheme).@"struct".fields) |field| - @field(out, field.name) = layout.Animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps); - return out; - } -}; - -const ChromeAnimation = if (theme_animation) - layout.Animation.Transition(ChromeTheme) -else - layout.Animation.Immediate(ChromeTheme); -const initial_chrome = ChromeTheme.fromTheme(&themes[0]); - // ---- the boundary types ---- test "insert and normal modes edit complete Unicode graphemes" { @@ -4004,7 +3487,7 @@ test "Look hover waits without mutating the pane and input cancels it" { _ = frame.reset(.retain_capacity); const surface = try p.render(frame.allocator()); - const expected_bg = mix(p.theme().bg orelse p.theme().tag_bg, mix(p.theme().bg orelse p.theme().tag_bg, p.theme().sel_bg)); + const expected_bg = colors.mix(p.theme().bg orelse p.theme().tag_bg, colors.mix(p.theme().bg orelse p.theme().tag_bg, p.theme().sel_bg)); try std.testing.expectEqual(Color{ .rgb = expected_bg }, surface.at(word_x, body_y).style.bg); // Background traffic in another pane does not change the text or geometry @@ -5763,7 +5246,7 @@ pub const Pardes = struct { theme_file_path: config.Runtime.Text(limits.host_path_cap) = .{}, theme_file_generation: u32 = 0, theme_file_pane: u8 = 0, - chrome_animation: ChromeAnimation = ChromeAnimation.init(initial_chrome), + chrome_animation: colors.ChromeAnimation = colors.ChromeAnimation.init(colors.initial_chrome), animate_theme_changes: bool = false, native_images: bool = false, /// The shell can take a changed image a few rows at a time (a texture row @@ -5987,7 +5470,7 @@ pub const Pardes = struct { } p.sync(); p.applyStartupConfig(); - p.finishThemeInitialization(); + colors.finishThemeInitialization(p); p.sync(); p.presentation.enabled = true; // A config init that opens a file with `Look …` armed the pulse before @@ -6476,112 +5959,6 @@ pub const Pardes = struct { p.font_request_taken = false; } - 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 = p.nextThemeFileGeneration(); - 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 = p.themeFileRequest(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 = p.themeFileRequest(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); - p.invalidateThemeDependentRasters(); - _ = p.scratch.reset(.retain_capacity); - p.sync(); - return true; - } - pub const Message = struct { const libc = std.c; const Tm = extern struct { @@ -7492,7 +6869,7 @@ pub const Pardes = struct { pane.tag_init = true; } - fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void { + pub fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void { if (pane.tag_edit and pane.prompt == .none) pane.tag_saved_col = pane.tag_col; const edit0: i32 = @intCast((p.tagPrefix(pane) catch return).len); p.seedTail(pane); @@ -12735,7 +12112,7 @@ pub const Pardes = struct { .theme => { const want = std.mem.trim(u8, arg orelse return, " \t\r\n"); for (themes, 0..) |t, i| if (std.mem.eql(u8, t.name, want)) { - p.setThemeIndex(i); + colors.setThemeIndex(p, i); return; }; }, @@ -13033,7 +12410,7 @@ pub const Pardes = struct { p.active = @min(st.active, MAX_PANES - 1); p.sync(); p.applyStartupConfig(); - p.finishThemeInitialization(); + colors.finishThemeInitialization(p); p.sync(); p.presentation.enabled = true; _ = p.takeHaptic(); // see init: a restored session is not a gesture @@ -13281,7 +12658,7 @@ pub const Pardes = struct { /// Recompute geometry, push grid-size changes to each emulator + pty, fire /// deferred greetings. The mirror of the prototype's loop epilogue. - fn sync(p: *Pardes) void { + pub fn sync(p: *Pardes) void { if (p.header_column != null and p.columnBarHeight() == 0) { p.exitHeader(); } @@ -13363,41 +12740,6 @@ pub const Pardes = struct { return p.haptic; } - fn finishThemeInitialization(p: *Pardes) void { - p.chrome_animation.snap(ChromeTheme.fromTheme(p.theme())); - p.animate_theme_changes = true; - } - - fn invalidateThemeDependentRasters(p: *Pardes) void { - if (comptime pdf_enabled) for (p.panes) |slot| { - const pane = slot orelse continue; - const pv = &(pane.pdf orelse continue); - if (pv.tint != .disabled) pv.invalidateAllRasters(); - }; - } - - pub fn setThemeIndex(p: *Pardes, index: usize) void { - if (index >= themes.len or - (p.custom_theme == null and p.theme_file_path.get().len == 0 and - index == @as(usize, p.settings.theme))) return; - const target_chrome = ChromeTheme.fromTheme(&themes[index]); - if (p.animate_theme_changes) - p.chrome_animation.retarget(target_chrome) - else - p.chrome_animation.snap(target_chrome); - if (p.custom_theme) |theme_value| { - p.custom_theme = null; - std.zon.parse.free(p.gpa, theme_value); - } - if (p.theme_file_path.get().len > 0) { - p.theme_file_path.clear(); - const generation = p.nextThemeFileGeneration(); - p.emit(.{ .theme_file = .{ .generation = generation, .on = false } }); - } - p.settings.theme = @intCast(index); - p.invalidateThemeDependentRasters(); - } - pub fn reprojectTagHit(p: *const Pardes, tag_hit: TagHit) ?TagHit { for (p.surface.tagLayers()) |*layer| { if (layer.cols == 0 or layer.kind != tag_hit.kind or layer.id != tag_hit.id or layer.serial != tag_hit.serial) continue; @@ -14370,7 +13712,7 @@ pub const Pardes = struct { } if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < BOX_H) { - const preview_bg = mix(page_rgb, mix(page_rgb, th.sel_bg)); + const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); p.paintPointerSelection(pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); }; } @@ -14450,9 +13792,9 @@ pub const Pardes = struct { const accent2 = if (std.mem.eql(u8, &th.num, &th.str)) th.comment else th.num; const sel_btn = [3][3]u8{ - mix(th.sel_bg, mix(th.sel_bg, th.kw)), - mix(th.sel_bg, mix(th.sel_bg, th.str)), - mix(th.sel_bg, mix(th.sel_bg, accent2)), + colors.mix(th.sel_bg, colors.mix(th.sel_bg, th.kw)), + colors.mix(th.sel_bg, colors.mix(th.sel_bg, th.str)), + colors.mix(th.sel_bg, colors.mix(th.sel_bg, accent2)), }; if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) { @@ -14460,7 +13802,7 @@ pub const Pardes = struct { // win over this quiet affordance. mark_hover carries the // cells this paints out to the hosts as the affordance rect — // macOS composes its glass overlay over exactly these. - const preview_bg = mix(page_rgb, mix(page_rgb, th.sel_bg)); + const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); if (preview.file_word) |word| panes.File.paintWordSelection(p, pane, r, word.row, word.lo, word.hi, preview_bg) else if (preview.sel) |sel| @@ -14480,7 +13822,7 @@ pub const Pardes = struct { if ((pane.mode == .normal or modal_hover) and pane.msel.active) { const hover_only = modal_hover and pane.mode != .normal; const modal_bg = if (hover_only) - mix(page_rgb, mix(page_rgb, th.sel_bg)) + colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)) else th.sel_bg; const lo = @min(pane.msel.r0, pane.msel.r1); @@ -14517,11 +13859,11 @@ pub const Pardes = struct { const bnd = cellBounds(sr); const hover_only = primary and modal_hover and pane.mode != .normal and !preview; const bg = if (hover_only) - mix(page_rgb, mix(page_rgb, th.sel_bg)) + colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)) else if (primary) th.sel_bg else - mix(th.sel_bg, page_rgb); + colors.mix(th.sel_bg, page_rgb); var vr: i32 = 0; while (vr + @as(i32, BOX_H) < @as(i32, body_bottom)) : (vr += 1) { const w = pane.wrapAt(vr); diff --git a/test/output.zig b/test/output.zig index cda5f556..ebea79d4 100644 --- a/test/output.zig +++ b/test/output.zig @@ -950,7 +950,7 @@ test "ThemeSel groups native themes first and skips headings in both directions" const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 }); defer p.deinit(); const source = try p.setTestFile("notes\n"); - p.setThemeIndex(pardes.themes.len - 1); + pardes.colors.setThemeIndex(p, pardes.themes.len - 1); const original_theme = p.settings.theme; try std.testing.expect(p.executeBuiltinLine(0, "ThemeSel")); const tid = source.search_pane orelse return error.MissingThemeList; diff --git a/test/pdf.zig b/test/pdf.zig index 930cc5c0..25d0de76 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -1310,7 +1310,7 @@ test "PdfTint cycles pane-local state without adding tag clutter" { const dark_index = for (themes, 0..) |th, i| { if (std.mem.eql(u8, th.name, "dark")) break i; } else return error.MissingDarkTheme; - p.setThemeIndex(dark_index); + pardes.colors.setThemeIndex(p, dark_index); const colors = panes.Pdf.tintColors(p); try std.testing.expectEqual(p.theme().tag_bg, colors.background); try std.testing.expectEqual(p.theme().tag_fg, colors.foreground); @@ -1386,7 +1386,7 @@ test "PDF tint and tinted theme changes rebuild every visible raster only" { const dark_index = for (themes, 0..) |th, i| { if (std.mem.eql(u8, th.name, "dark")) break i; } else return error.MissingDarkTheme; - p.setThemeIndex(dark_index); + pardes.colors.setThemeIndex(p, dark_index); for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(!raster.tried); _ = frame.reset(.retain_capacity); const themed_surface = try p.render(frame.allocator()); @@ -1440,7 +1440,7 @@ test "PDF tint and tinted theme changes rebuild every visible raster only" { if (std.mem.eql(u8, th.name, "acme")) break i; } else return error.MissingAcmeTheme; for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried); - p.setThemeIndex(acme_index); + pardes.colors.setThemeIndex(p, acme_index); // Disabled keys ignore theme colors, so neither explicit invalidation nor // the per-raster key comparison asks MuPDF for replacement pixels. for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried); -- cgit v1.3