diff options
| -rw-r--r-- | src/builtins.zig | 5 | ||||
| -rw-r--r-- | src/colors.zig | 681 | ||||
| -rw-r--r-- | src/file_watch.zig | 15 | ||||
| -rw-r--r-- | src/fs.zig | 1 | ||||
| -rw-r--r-- | src/macos.zig | 13 | ||||
| -rw-r--r-- | src/pardes.zig | 700 | ||||
| -rw-r--r-- | test/output.zig | 2 | ||||
| -rw-r--r-- | test/pdf.zig | 6 |
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 => {}, @@ -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); |
