diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-18 14:21:38 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-18 23:45:30 -0300 |
| commit | 382abe3dafb872b3e6c9792a9cc0abcf6267e180 (patch) | |
| tree | e30f47d911561db9b02e714287a1658822c77a09 /src/pardes.zig | |
| parent | c24a9e40215bc30c68c9db7675f1c6a07d9bbae3 (diff) | |
| download | pardes-382abe3dafb872b3e6c9792a9cc0abcf6267e180.tar.gz pardes-382abe3dafb872b3e6c9792a9cc0abcf6267e180.zip | |
file_watch + builtins + config: richer watch semantics, new builtins, config docs
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 210 |
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 ---- |
