summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/builtins.zig5
-rw-r--r--src/colors.zig681
-rw-r--r--src/file_watch.zig15
-rw-r--r--src/fs.zig1
-rw-r--r--src/macos.zig13
-rw-r--r--src/pardes.zig700
-rw-r--r--test/output.zig2
-rw-r--r--test/pdf.zig6
8 files changed, 725 insertions, 698 deletions
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);