summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig210
1 files changed, 202 insertions, 8 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 5bf03788..3639af0f 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -388,6 +388,52 @@ 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();
@@ -2814,6 +2860,13 @@ pub const Effect = union(enum) {
/// Real text files and PDFs ask for this; an output buffer has no file
/// behind it.
watch: struct { pane: u8, on: bool },
+ /// Load/unload the one runtime theme file watch. The path lives in the
+ /// core's fixed request buffer; carrying only its generation keeps this
+ /// already-large effect ring compact.
+ theme_file: struct { generation: u32, on: bool },
+ /// Write the build-time theme ring below the per-user config directory.
+ /// The pane receives the completion/error message from the native host.
+ dump_themes: struct { pane: u8 },
quit,
fn Buf(comptime n: usize) type {
@@ -4432,11 +4485,15 @@ pub const Options = struct {
/// otherwise open the nested.zig socket, and this is the way argv already
/// reaches them.
nested: bool = false,
- /// Native main fills this with the contents of the per-user config file.
+ /// Native main fills this with the contents of the per-user config init.
/// Keeping discovery out of the core makes constructors and web builds
/// deterministic; when present, each line is dispatched as a builtin
/// before init returns and therefore before any frontend can render.
startup_config: ?[]const u8 = null,
+ /// Native launcher's resolved per-user `pardes` directory. Relative
+ /// ThemeFile operands and DumpThemes are rooted here. Null for web and
+ /// direct core callers which did not opt into per-user configuration.
+ config_dir: ?[]const u8 = null,
/// ...and WHERE that came from, which is a separate fact: the path
/// resolves even when the file does not exist, and that is precisely the
/// case the Config builtin is asked about. Null on the web and in every
@@ -4615,6 +4672,14 @@ pub const Pardes = struct {
/// pump from taking one still-pending font request more than once before it
/// acknowledges success or rejection.
font_request_taken: bool = false,
+ /// One user theme loaded from a .zon file. The parsed value owns its name;
+ /// all color fields are inline. `theme_file_generation` makes a queued IO
+ /// request stale as soon as another ThemeFile/Theme/NextColor command wins.
+ custom_theme: ?Theme = null,
+ custom_theme_active: bool = false,
+ theme_file_path: runtime_cfg.Text(4095) = .{},
+ theme_file_generation: u32 = 0,
+ theme_file_pane: u8 = 0,
chrome_animation: ChromeAnimation = ChromeAnimation.init(initial_chrome),
/// False only while startup configuration or a dump restore is selecting
/// its first theme. No frame is rendered in that interval.
@@ -4813,7 +4878,7 @@ pub const Pardes = struct {
p.finishThemeInitialization();
p.sync();
p.panel_animation_enabled = true;
- // A config file that opens a file with `Look …` armed the pulse before
+ // A config init that opens a file with `Look …` armed the pulse before
// anyone touched anything. Nobody asked for that, so boot is silent.
_ = p.takeHaptic();
return p;
@@ -4829,6 +4894,7 @@ pub const Pardes = struct {
if (p.yank) |y| gpa.free(y);
if (p.dump_out) |d| gpa.free(d);
if (p.last_dump) |d| gpa.free(d);
+ if (p.custom_theme) |theme_value| std.zon.parse.free(gpa, theme_value);
if (p.chord_arg) |a| gpa.free(a);
if (p.pipe_wait) |*wait| wait.deinit(gpa);
p.shell_rows.reset(gpa);
@@ -5136,6 +5202,117 @@ 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;
+ }
+
+ /// Resolve and queue `ThemeFile` without doing filesystem work in the
+ /// core. Relative paths belong to the per-user config directory; absolute
+ /// paths remain useful for trying a file elsewhere.
+ pub fn requestThemeFile(p: *Pardes, id: usize, argument: []const u8) void {
+ if (comptime platform == .web) 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);
+ }
+
+ /// Parse and atomically wear one exact ZON snapshot. Parse failure leaves
+ /// the last valid custom/built-in theme untouched. `animate` is false for
+ /// the launcher's pre-frame drain and true for an interactive load/reload.
+ pub fn loadThemeFile(p: *Pardes, generation: u32, bytes: []const u8, animate: bool) bool {
+ 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;
+ p.custom_theme_active = true;
+ if (old) |theme_value| std.zon.parse.free(p.gpa, theme_value);
+ p.invalidateThemeDependentRasters();
+ _ = p.scratch.reset(.retain_capacity);
+ p.sync();
+ return true;
+ }
+
/// Post the transient message on `id`'s last row. Called by a SHELL once
/// the IO it narrates has actually happened, exactly as openNewFile above
/// completes a `new_file` effect: the core neither writes files nor owns a
@@ -12639,6 +12816,7 @@ pub const Pardes = struct {
}
pub fn theme(p: *const Pardes) *const Theme {
+ if (p.custom_theme_active) return &p.custom_theme.?;
return &themes[p.settings.theme];
}
@@ -12682,23 +12860,39 @@ pub const Pardes = struct {
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();
+ };
+ }
+
/// The sole live-session theme mutation path. Target colors change now;
/// anchored chrome retargets from its currently displayed palette. Only
/// PDFs whose pixels depend on target theme colors are marked stale, once;
/// untinted source rasters remain byte-for-byte resident.
pub fn setThemeIndex(p: *Pardes, index: usize) void {
- if (index >= themes.len or index == @as(usize, p.settings.theme)) return;
+ if (index >= themes.len or
+ (!p.custom_theme_active 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;
+ p.custom_theme_active = false;
+ 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);
- 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();
- };
+ p.invalidateThemeDependentRasters();
}
// ---- render: build the canonical surface ----