summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-18 14:21:38 -0300
committerGabriel Schneider <[email protected]>2026-08-18 23:45:30 -0300
commit382abe3dafb872b3e6c9792a9cc0abcf6267e180 (patch)
treee30f47d911561db9b02e714287a1658822c77a09 /src
parentc24a9e40215bc30c68c9db7675f1c6a07d9bbae3 (diff)
downloadpardes-382abe3dafb872b3e6c9792a9cc0abcf6267e180.tar.gz
pardes-382abe3dafb872b3e6c9792a9cc0abcf6267e180.zip
file_watch + builtins + config: richer watch semantics, new builtins, config docs
Diffstat (limited to 'src')
-rw-r--r--src/builtins.zig40
-rw-r--r--src/config.zig7
-rw-r--r--src/file_watch.zig158
-rw-r--r--src/gui/gui.zig25
-rw-r--r--src/macos.zig82
-rw-r--r--src/macos/pardes.h8
-rw-r--r--src/main.zig1
-rw-r--r--src/output_pane_integration_test.zig2
-rw-r--r--src/pardes.zig210
-rw-r--r--src/runtime_config.zig4
-rw-r--r--src/tty/tty.zig27
-rw-r--r--src/tutor.txt16
-rw-r--r--src/user_config.zig94
-rw-r--r--src/web.zig1
14 files changed, 625 insertions, 50 deletions
diff --git a/src/builtins.zig b/src/builtins.zig
index d42aeafc..3f1c5f85 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -325,6 +325,37 @@ pub const ThemeSel = struct {
}
};
+/// Load one complete Theme value from a .zon file. Relative paths are rooted
+/// at the per-user pardes directory, so an init line can simply say
+/// `ThemeFile themes/mine.zon`. The native host owns the read and watch; the
+/// core owns parsing and keeps the last valid value across a bad live edit.
+pub const ThemeFile = struct {
+ pub const takes_arg = true;
+ pub const enabled = pardes.platform != .web;
+ pub fn run(c: Ctx) void {
+ if (comptime enabled)
+ c.p.requestThemeFile(c.id, c.arg orelse return)
+ else
+ unreachable;
+ }
+};
+
+/// Materialize every compiled theme as editable ZON under
+/// `<config>/themes/builtin`. Filesystem work remains a host effect, just like
+/// Dump and Save; the build-time ring itself is the sole source of the data.
+pub const DumpThemes = struct {
+ pub const enabled = pardes.platform != .web;
+ pub fn run(c: Ctx) void {
+ if (comptime enabled) {
+ if (c.p.opts.config_dir == null) {
+ c.p.reportError(c.id, "dump themes", error.NoConfigDirectory);
+ return;
+ }
+ c.p.emit(.{ .dump_themes = .{ .pane = @intCast(c.id) } });
+ } else unreachable;
+ }
+};
+
// ---- the GUI's font list, and NOTHING on any other platform ----
//
// Runtime settings such as Font are generated from runtime_config.settings.
@@ -579,10 +610,11 @@ pub const Help = struct {
///
/// The one question docs/config.md cannot answer, because the answer depends
/// on the machine: XDG_CONFIG_HOME if it is set and absolute, else
-/// ~/Library/Application Support/pardes on macOS and ~/.config/pardes
-/// everywhere else. Printing it beats documenting it — the row is ordinary
-/// text, so a right click on it opens the file, and when there is no file
-/// there yet the path is still exactly what you needed to know.
+/// ~/Library/Application Support/pardes/init on macOS and
+/// ~/.config/pardes/init everywhere else. Printing it beats documenting it —
+/// the row is ordinary text, so a right click on it opens the file, and when
+/// there is no file there yet the path is still exactly what you needed to
+/// know.
pub const Config = struct {
pub const output: OutputTraits = .{ .name = config.config_buffer };
pub fn run(c: Ctx) void {
diff --git a/src/config.zig b/src/config.zig
index f58716e5..965570f9 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -208,6 +208,13 @@ pub const leader_path = paths: {
// The web build has no runnable effect argument, so it has no command,
// help row, dispatcher case, or leader entry for EffectCode either.
if (builtins.EffectCode.enabled) table.set(.EffectCode, null);
+ // Native-only filesystem theme commands. ThemeFile needs an operand and
+ // DumpThemes is intentionally occasional, so both stay word-executed
+ // rather than spending leader chords.
+ if (pardes.platform != .web) {
+ table.set(.ThemeFile, null);
+ table.set(.DumpThemes, null);
+ }
// The pane-local PDF commands exist only in MuPDF builds through their
// explicit registry availability, so name their paths inside the same
// comptime branch. PdfTint/PdfFit retain their display slots and
diff --git a/src/file_watch.zig b/src/file_watch.zig
index b616d481..a5347844 100644
--- a/src/file_watch.zig
+++ b/src/file_watch.zig
@@ -42,29 +42,30 @@ pub const Watch = struct {
generation: Generation,
};
-pub const Table = [pardes.MAX_PANES]?Watch;
+/// The final slot is the one non-pane watched payload: the active ThemeFile.
+/// Sharing the table also shares directory descriptors correctly when a user
+/// happens to open that .zon as an ordinary document.
+pub const theme_slot = pardes.MAX_PANES;
+pub const Table = [pardes.MAX_PANES + 1]?Watch;
-/// Mark or unmark one pane pathname. Panes in the same directory share an
-/// inotify descriptor, so removing one pane drops the kernel mark only after
-/// its last user disappears.
-pub fn watchPane(
+fn watchPath(
fd: c_int,
watches: *Table,
- id: u8,
+ slot: usize,
path: ?[]const u8,
serial: u32,
generation: Generation,
) void {
if (comptime builtin.os.tag != .linux) return;
if (fd < 0) return;
- if (watches[id]) |old| {
+ if (watches[slot]) |old| {
var shared = false;
for (watches, 0..) |other, i| {
const candidate = other orelse continue;
- if (i != id and candidate.wd == old.wd) shared = true;
+ if (i != slot and candidate.wd == old.wd) shared = true;
}
if (!shared) _ = libc.inotify_rm_watch(fd, old.wd);
- watches[id] = null;
+ watches[slot] = null;
}
const watched_path = path orelse return;
const dir = std.fs.path.dirname(watched_path) orelse ".";
@@ -77,7 +78,22 @@ pub fn watchPane(
const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR;
const wd = libc.inotify_add_watch(fd, dir_buf[0..dir.len :0], mask);
if (wd < 0) return;
- watches[id] = .{ .wd = wd, .serial = serial, .generation = generation };
+ watches[slot] = .{ .wd = wd, .serial = serial, .generation = generation };
+}
+
+/// Mark or unmark one pane pathname. Panes in the same directory share an
+/// inotify descriptor, so removing one pane drops the kernel mark only after
+/// its last user disappears.
+pub fn watchPane(
+ fd: c_int,
+ watches: *Table,
+ id: u8,
+ path: ?[]const u8,
+ serial: u32,
+ generation: Generation,
+) void {
+ std.debug.assert(id < pardes.MAX_PANES);
+ watchPath(fd, watches, id, path, serial, generation);
}
/// Reconcile one marked pane. A true result requests an immediate second pass:
@@ -178,12 +194,73 @@ pub fn reloadChanged(
watches: *Table,
) bool {
var retry = false;
- for (watches, 0..) |slot, id| {
+ for (watches[0..pardes.MAX_PANES], 0..) |slot, id| {
if (slot != null) retry = reloadPane(core, io, gpa, watches, id, true) or retry;
}
+ if (watches[theme_slot] != null)
+ retry = reloadTheme(core, gpa, watches, true) or retry;
return retry;
}
+/// Stop the old theme watch, synchronously load the requested snapshot, then
+/// install its parent-directory mark and reconcile once to close the read ->
+/// watch race. Returns true only when that reconciliation wants another pass.
+pub fn applyThemeEffect(
+ core: *pardes.Pardes,
+ gpa: std.mem.Allocator,
+ fd: c_int,
+ watches: *Table,
+ generation: u32,
+ on: bool,
+ animate: bool,
+) bool {
+ watchPath(fd, watches, theme_slot, null, 0, .{ .text = 0 });
+ if (!on) return false;
+ const request = core.themeFileRequest(generation) orelse return false;
+ const bytes = look.readFile(gpa, request.path) catch |err| {
+ core.failThemeFile(generation, err);
+ return false;
+ };
+ defer gpa.free(bytes);
+ if (!core.loadThemeFile(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;
+ return reloadTheme(core, gpa, watches, false);
+}
+
+/// Re-read the one active theme file after a coalesced directory edge. The
+/// generation advances only after a valid parse, so a malformed save never
+/// replaces the last good colors or blesses bad bytes as the new baseline.
+pub fn reloadTheme(
+ core: *pardes.Pardes,
+ gpa: std.mem.Allocator,
+ watches: *Table,
+ announce: bool,
+) bool {
+ const watched = watches[theme_slot] orelse return false;
+ const request = core.themeFileRequest(watched.serial) orelse return false;
+ const bytes = look.readFile(gpa, request.path) catch |err| {
+ core.failThemeFile(watched.serial, err);
+ return false;
+ };
+ defer gpa.free(bytes);
+ const hash = std.hash.Wyhash.hash(0, bytes);
+ switch (watched.generation) {
+ .text => |accepted| if (accepted == hash) return false,
+ .pdf => unreachable,
+ }
+ if (!core.loadThemeFile(watched.serial, bytes, true)) return false;
+ const live = if (watches[theme_slot]) |*entry| entry else return false;
+ if (live.serial != watched.serial) return false;
+ live.generation = .{ .text = hash };
+ if (announce) {
+ var msg_buf: [256]u8 = undefined;
+ core.setMessage(request.pane, message.stamp(&msg_buf, "reloaded theme", request.path));
+ }
+ return false;
+}
+
pub fn identify(io: std.Io, path: []const u8) !Identity {
const stat = try std.Io.Dir.cwd().statFile(io, path, .{});
if (stat.kind != .file) return error.NotFile;
@@ -216,3 +293,62 @@ test "file identity changes for in-place and rename-over writes" {
try std.testing.expect(after.inode != replaced.inode);
try std.testing.expect(!after.eql(replaced));
}
+
+test "theme watch reloads valid ZON and keeps the last theme across a bad save" {
+ if (comptime builtin.os.tag != .linux or pardes.platform == .web) return;
+ const io = std.testing.io;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var base_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const base_len = try tmp.dir.realPath(io, &base_buf);
+ const path = try std.fs.path.join(gpa, &.{ base_buf[0..base_len], "mine.zon" });
+ defer gpa.free(path);
+
+ var first = pardes.themes[0];
+ first.name = "watched";
+ first.bg = .{ 1, 2, 3 };
+ var first_zon: std.Io.Writer.Allocating = .init(gpa);
+ defer first_zon.deinit();
+ try std.zon.stringify.serialize(first, .{ .whitespace = true }, &first_zon.writer);
+ try tmp.dir.writeFile(io, .{ .sub_path = "mine.zon", .data = first_zon.written() });
+
+ const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
+ defer core.deinit();
+ while (core.nextEffect()) |_| {}
+ core.requestThemeFile(0, path);
+ const request = while (core.nextEffect()) |effect| switch (effect) {
+ .theme_file => |theme_file| break theme_file,
+ else => {},
+ } else return error.MissingThemeFileEffect;
+
+ const fd = libc.inotify_init1(linux.IN.CLOEXEC | linux.IN.NONBLOCK);
+ if (fd < 0) return error.InotifyInitFailed;
+ defer _ = libc.close(fd);
+ var watches: Table = @splat(null);
+ defer _ = applyThemeEffect(core, gpa, fd, &watches, 0, false, false);
+ try std.testing.expect(!applyThemeEffect(
+ core,
+ gpa,
+ fd,
+ &watches,
+ request.generation,
+ true,
+ false,
+ ));
+ try std.testing.expectEqual(@as(?[3]u8, .{ 1, 2, 3 }), core.theme().bg);
+ try std.testing.expect(watches[theme_slot] != null);
+
+ var second = first;
+ second.bg = .{ 9, 8, 7 };
+ var second_zon: std.Io.Writer.Allocating = .init(gpa);
+ defer second_zon.deinit();
+ try std.zon.stringify.serialize(second, .{ .whitespace = true }, &second_zon.writer);
+ try tmp.dir.writeFile(io, .{ .sub_path = "mine.zon", .data = second_zon.written() });
+ try std.testing.expect(!reloadTheme(core, gpa, &watches, false));
+ try std.testing.expectEqual(@as(?[3]u8, .{ 9, 8, 7 }), core.theme().bg);
+
+ try tmp.dir.writeFile(io, .{ .sub_path = "mine.zon", .data = ".{ .name = " });
+ try std.testing.expect(!reloadTheme(core, gpa, &watches, false));
+ try std.testing.expectEqual(@as(?[3]u8, .{ 9, 8, 7 }), core.theme().bg);
+}
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index d0757d7f..dda843b5 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -17,6 +17,7 @@ const config = @import("../config.zig");
const look = @import("../look.zig");
const message = @import("../message.zig");
const file_watch = @import("../file_watch.zig");
+const user_config = @import("../user_config.zig");
const deck = @import("deck.zig");
const crt = @import("crt.zig");
const fonts = @import("../fonts.zig"); // the Font builtin's half of the seam
@@ -1946,7 +1947,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// the replay core's pane ids mean new things, and the dying core's
// `watch off` effects go into a queue nobody drains — drop the lot
// here. The new core emits its own `on`s as it builds its panes.
- for (0..watches.len) |wid| file_watch.watchPane(
+ for (0..pardes.MAX_PANES) |wid| file_watch.watchPane(
inotify_fd,
&watches,
@intCast(wid),
@@ -1954,6 +1955,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
0,
.{ .text = 0 },
);
+ _ = file_watch.applyThemeEffect(core, gpa, inotify_fd, &watches, 0, false, false);
clearNativeImages(&g);
g.presented_images.clearRetainingCapacity();
g.prepared_images.clearRetainingCapacity();
@@ -3082,6 +3084,16 @@ fn drainEffects(
core.setLastDump(path);
_ = libc.close(fd);
},
+ .dump_themes => |request| {
+ const config_dir = core.opts.config_dir orelse continue;
+ const out_dir = user_config.dumpThemes(io, gpa, config_dir, pardes.themes) catch |err| {
+ core.reportError(request.pane, "dump themes", err);
+ continue;
+ };
+ defer gpa.free(out_dir);
+ var mbuf: [256]u8 = undefined;
+ core.setMessage(request.pane, message.stamp(&mbuf, "dumped themes", out_dir));
+ },
.set_clipboard => {
if (g == null) continue;
const y = core.yank orelse continue;
@@ -3108,6 +3120,17 @@ fn drainEffects(
if (file_watch.applyEffect(core, io, gpa, inotify_fd, watches, w.pane, w.on))
queue.push(.files_changed);
},
+ .theme_file => |request| {
+ if (file_watch.applyThemeEffect(
+ core,
+ gpa,
+ inotify_fd,
+ watches,
+ request.generation,
+ request.on,
+ threads_ok,
+ )) queue.push(.files_changed);
+ },
.quit => {},
};
}
diff --git a/src/macos.zig b/src/macos.zig
index 3c1458a3..38d2c961 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -172,6 +172,10 @@ const scene_frame_hz: u32 = 60;
/// Keep the float-valued time and noise frame precise, then repeat after a
/// little over an hour. None of the effects has state across this boundary.
const scene_frame_wrap: u32 = 4096 * scene_frame_hz;
+/// FileWatcher.swift keys sources by an opaque u8. Pane ids occupy 0..15;
+/// the next value is the one process-global ThemeFile source.
+const theme_watch_pane: u8 = @intCast(pardes.MAX_PANES);
+const watch_slot_count = pardes.MAX_PANES + 1;
// ---------------------------------------------------------------- state
@@ -201,9 +205,9 @@ const WatchedFile = struct {
};
const FileWatches = struct {
- entries: [pardes.MAX_PANES]?WatchedFile = @splat(null),
- generations: [pardes.MAX_PANES]u32 = @splat(0),
- dirty: [pardes.MAX_PANES]bool = @splat(false),
+ entries: [watch_slot_count]?WatchedFile = @splat(null),
+ generations: [watch_slot_count]u32 = @splat(0),
+ dirty: [watch_slot_count]bool = @splat(false),
fn nextGeneration(watches: *FileWatches, pane: u8) u32 {
watches.generations[pane] +%= 1;
@@ -617,6 +621,7 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
const found = user_config.load(io, config_arena.allocator(), env);
opts.startup_config = found.bytes;
opts.startup_config_path = found.path;
+ opts.config_dir = found.dir;
}
pardes.image.start(io, allocs.image);
@@ -729,6 +734,10 @@ export fn pardes_deinit() void {
const generation = st.file_watches.stop(st.gpa, id);
hostWatchFile(id, generation, null, 0);
};
+ if (st.file_watches.entries[theme_watch_pane] != null) {
+ const generation = st.file_watches.stop(st.gpa, theme_watch_pane);
+ hostWatchFile(theme_watch_pane, generation, null, 0);
+ }
// Every reader is joined here, before anything it touches is freed. The
// runtime joins its tasks on exit, so a reader left parked in read(2) would
// hang the process instead of the app quitting.
@@ -932,6 +941,57 @@ fn setFileWatch(st: *State, pane: u8, on: bool) void {
_ = reloadWatchedFile(st, pane, false);
}
+fn setThemeFileWatch(st: *State, request_generation: u32, on: bool, animate: bool) void {
+ 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 bytes = look.readFile(st.gpa, request.path) catch |err| {
+ st.core.failThemeFile(request_generation, err);
+ return;
+ };
+ defer st.gpa.free(bytes);
+ if (!st.core.loadThemeFile(request_generation, bytes, animate)) return;
+ const callback_generation = st.file_watches.replace(
+ st.gpa,
+ theme_watch_pane,
+ request.path,
+ request_generation,
+ .{ .text = std.hash.Wyhash.hash(0, bytes) },
+ ) catch return;
+ const watched = st.file_watches.entries[theme_watch_pane].?;
+ hostWatchFile(theme_watch_pane, callback_generation, watched.path.ptr, watched.path.len);
+ // Read-before-watch has the same rename-over gap as document panes. One
+ // immediate reconciliation after Swift activates the source closes it.
+ _ = reloadWatchedTheme(st, false);
+}
+
+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;
+ if (!std.mem.eql(u8, watched.path, request.path)) return false;
+ const bytes = look.readFile(st.gpa, watched.path) catch |err| {
+ st.core.failThemeFile(watched.serial, err);
+ return false;
+ };
+ defer st.gpa.free(bytes);
+ const hash = std.hash.Wyhash.hash(0, bytes);
+ switch (watched.generation_on_disk) {
+ .text => |accepted| if (accepted == hash) return false,
+ .pdf => unreachable,
+ }
+ if (!st.core.loadThemeFile(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 };
+ live.retries_left = 0;
+ if (announce) {
+ var mbuf: [256]u8 = undefined;
+ st.core.setMessage(request.pane, message.stamp(&mbuf, "reloaded theme", request.path));
+ }
+ return true;
+}
+
/// Read and apply on the main thread. Swift only says that this path or its
/// parent changed. Text hashes an exact bounded snapshot; PDFs may be much
/// larger than that bound and MuPDF reopens the path itself, so they compare a
@@ -1027,7 +1087,7 @@ fn reloadWatchedFile(st: *State, pane: u8, announce: bool) bool {
/// IO and state mutation stay in pardes_tick with pty/nested messages.
export fn pardes_watch_changed(pane: u8, generation: u32) void {
const st = &(state orelse return);
- if (pane >= pardes.MAX_PANES) return;
+ if (pane >= watch_slot_count) return;
if (st.file_watches.notify(pane, generation)) wake(st);
}
@@ -1083,6 +1143,9 @@ export fn pardes_tick() bool {
if (!st.file_watches.takeDirty(id)) continue;
if (reloadWatchedFile(st, id, true)) changed = true;
}
+ if (st.file_watches.takeDirty(theme_watch_pane)) {
+ if (reloadWatchedTheme(st, true)) changed = true;
+ }
refreshCwds(st);
if (drainEffects(st, true)) changed = true;
return changed;
@@ -1748,6 +1811,16 @@ fn drainEffects(st: *State, threads_ok: bool) bool {
_ = libc.close(fd);
core.setLastDump(path);
},
+ .dump_themes => |request| {
+ const config_dir = core.opts.config_dir orelse continue;
+ const out_dir = user_config.dumpThemes(st.io, st.gpa, config_dir, pardes.themes) catch |err| {
+ core.reportError(request.pane, "dump themes", err);
+ continue;
+ };
+ defer st.gpa.free(out_dir);
+ var mbuf: [256]u8 = undefined;
+ core.setMessage(request.pane, message.stamp(&mbuf, "dumped themes", out_dir));
+ },
.set_clipboard => {
const cb = st.runtime.set_clipboard orelse continue;
const y = core.yank orelse continue;
@@ -1771,6 +1844,7 @@ fn drainEffects(st: *State, threads_ok: bool) bool {
// dot-Tab for the rest of the session does nothing at all.
.lsp => |q| core.update(.{ .lsp_resp = .{ .id = q.id, .rows = "" } }),
.watch => |watch| setFileWatch(st, watch.pane, watch.on),
+ .theme_file => |request| setThemeFileWatch(st, request.generation, request.on, threads_ok),
.pipe => {},
.quit => {},
}
diff --git a/src/macos/pardes.h b/src/macos/pardes.h
index a506423d..33bedc9e 100644
--- a/src/macos/pardes.h
+++ b/src/macos/pardes.h
@@ -276,10 +276,10 @@ void pardes_mouse(pardes_mouse_button_e button, pardes_mouse_kind_e kind,
// same event: it would keep hover previews and resize hints alive off-window.
void pardes_pointer_leave(void);
-// Host file watchers call this after their debounce interval. `generation`
-// comes from pardes_host_watch_file and makes an event queued before a pane
-// replacement inert. The callback only schedules the ordinary main-thread
-// pump; file IO and the shared text/PDF reload happen inside pardes_tick.
+// Host file watchers call this after their debounce interval. `pane` is the
+// opaque watch slot (including pardes' private ThemeFile slot), and `generation`
+// makes events queued before a replacement inert. The callback only schedules
+// the ordinary main-thread pump; text, PDF, and theme IO happen in pardes_tick.
void pardes_watch_changed(uint8_t pane, uint32_t generation);
// Trackpad/precision wheel distance in CELLS, sign following the grid
diff --git a/src/main.zig b/src/main.zig
index 48022db5..be188200 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -206,6 +206,7 @@ fn nativeMain(init: std.process.Init) !void {
const found = @import("user_config.zig").load(init.io, arena, init.environ_map);
opts.startup_config = found.bytes;
opts.startup_config_path = found.path;
+ opts.config_dir = found.dir;
switch (pardes.platform) {
.tty => try @import("tty/tty.zig").run(init, opts),
.gui => try @import("gui/gui.zig").run(init, opts),
diff --git a/src/output_pane_integration_test.zig b/src/output_pane_integration_test.zig
index 25acbbbd..13d41506 100644
--- a/src/output_pane_integration_test.zig
+++ b/src/output_pane_integration_test.zig
@@ -16,7 +16,7 @@ const font_picker = pardes.font_picker;
const fonts = if (font_picker) @import("fonts.zig") else struct {};
test "Config prints the startup path and refreshes its one output" {
- const path = "/home/pardes-test/.config/pardes";
+ const path = "/home/pardes-test/.config/pardes/init";
const p = try Pardes.init(std.testing.allocator, .{ .startup_config_path = path });
defer p.deinit();
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 ----
diff --git a/src/runtime_config.zig b/src/runtime_config.zig
index 806cb766..edfd8652 100644
--- a/src/runtime_config.zig
+++ b/src/runtime_config.zig
@@ -440,7 +440,7 @@ test "Config report observes every simple setting and all live context" {
var state: State = .{};
var storage: [4096]u8 = undefined;
const context: ReportContext = .{
- .startup_config_path = "/tmp/pardes/config",
+ .startup_config_path = "/tmp/pardes/init",
.platform = "gui",
.theme_name = "acme",
.compiled_default_shell = "/bin/sh",
@@ -517,7 +517,7 @@ test "Config report observes every simple setting and all live context" {
"Font pending: on\n",
"Font effective size: 13.75 points\n",
"TaglineSize: 82%\n",
- "Startup config: /tmp/pardes/config\n",
+ "Startup config: /tmp/pardes/init\n",
"Platform: gui\n",
"Compiled default shell: /bin/sh\n",
"GUI shader source: live GLSL compiled during this build\n",
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 05585f46..a0159f46 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -15,6 +15,7 @@ const temp_file = @import("../temp_file.zig");
const shell_bin = @import("../shell_bin.zig");
const message = @import("../message.zig");
const file_watch = @import("../file_watch.zig");
+const user_config = @import("../user_config.zig");
const selection_pipe = @import("../selection_pipe.zig");
const nested = @import("../nested.zig");
const panel_compositor = @import("panel_compositor.zig");
@@ -342,7 +343,7 @@ test "Linux host watch closes initial race and reloads rename-over PDF while idl
if (fd < 0) return error.InotifyInitFailed;
defer _ = libc.close(fd);
var watches: file_watch.Table = @splat(null);
- defer for (0..watches.len) |id| file_watch.watchPane(
+ defer for (0..pardes.MAX_PANES) |id| file_watch.watchPane(
fd,
&watches,
@intCast(id),
@@ -854,7 +855,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// the replay core's pane ids mean new things, and the dying core's
// `watch off` effects go into a queue nobody drains — drop the lot
// here. The new core emits its own `on`s as it builds its panes.
- for (0..watches.len) |wid| file_watch.watchPane(
+ for (0..pardes.MAX_PANES) |wid| file_watch.watchPane(
inotify_fd,
&watches,
@intCast(wid),
@@ -862,6 +863,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
0,
.{ .text = 0 },
);
+ _ = file_watch.applyThemeEffect(core, gpa, inotify_fd, &watches, 0, false, false);
var image_iterator = kitty_handles.valueIterator();
while (image_iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id);
kitty_handles.clearRetainingCapacity();
@@ -1122,6 +1124,16 @@ fn drainEffects(
_ = libc.close(fd);
core.setLastDump(path);
},
+ .dump_themes => |request| {
+ const config_dir = core.opts.config_dir orelse continue;
+ const out_dir = user_config.dumpThemes(io, gpa, config_dir, pardes.themes) catch |err| {
+ core.reportError(request.pane, "dump themes", err);
+ continue;
+ };
+ defer gpa.free(out_dir);
+ var mbuf: [256]u8 = undefined;
+ core.setMessage(request.pane, message.stamp(&mbuf, "dumped themes", out_dir));
+ },
.set_clipboard => {
// mirror the core's yank register out via OSC 52
if (core.yank) |y| vx.copyToSystemClipboard(tty.writer(), y, gpa) catch {};
@@ -1206,6 +1218,17 @@ fn drainEffects(
if (file_watch.applyEffect(core, io, gpa, inotify_fd, watches, w.pane, w.on))
loop.postEvent(.files_changed) catch {};
},
+ .theme_file => |request| {
+ if (file_watch.applyThemeEffect(
+ core,
+ gpa,
+ inotify_fd,
+ watches,
+ request.generation,
+ request.on,
+ threads_ok,
+ )) loop.postEvent(.files_changed) catch {};
+ },
.quit => {},
};
}
diff --git a/src/tutor.txt b/src/tutor.txt
index f9ee1932..2fb89ea0 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -1064,6 +1064,13 @@ abc
`Theme <name>` goes straight to one, ThemeSel (SPC t t) lists them
all into a buffer you walk.
+ A native build can also wear one complete ZON file at runtime:
+ `ThemeFile themes/mine.zon` resolves from the pardes config directory
+ and, where document watching is available, reloads after every valid save.
+ A broken save leaves the last good colors on screen. `DumpThemes` writes
+ copyable references under themes/builtin; copy one out, rename it and edit
+ that rather than guessing the fields.
+
Fonts, on the SDL and macOS builds only — in a terminal the font
belongs to the terminal and in a browser to the page. FontSel
(SPC t f) lists every MONOSPACE face on the machine into a "+Fonts"
@@ -1089,10 +1096,10 @@ abc
effect builtin as its argument) opens the exact backend sources that
were embedded when this binary was built.
- Before its first frame a native pardes reads ONE file: a file called
- `pardes` in your config directory — $XDG_CONFIG_HOME/pardes when that
- is set to an absolute path, else ~/.config/pardes, and on macOS
- ~/Library/Application Support/pardes. `SPC f c` opens a refreshable
+ Before its first frame a native pardes reads ONE file: `init` inside its
+ `pardes` config directory — $XDG_CONFIG_HOME/pardes/init when that is
+ set to an absolute path, else ~/.config/pardes/init, and on macOS
+ ~/Library/Application Support/pardes/init. `SPC f c` opens a refreshable
"+Config" buffer containing the resolved path and every live setting:
theme, shell, requested/effective font, panel/scene effects, hover
delay, and the ordinary display toggles. The path is printed whether
@@ -1111,6 +1118,7 @@ abc
you would execute it anywhere else:
Theme gruvbox_dark_hard
+ ThemeFile themes/mine.zon
Shell zsh
TaglineSize 70
Tagbottom
diff --git a/src/user_config.zig b/src/user_config.zig
index aca8cbf3..15253ab2 100644
--- a/src/user_config.zig
+++ b/src/user_config.zig
@@ -1,7 +1,8 @@
//! Native startup configuration discovery and loading.
//!
-//! The file is named `pardes` inside the platform's per-user configuration
-//! directory. Discovery is deliberately launcher-owned: native
+//! `pardes` is a directory inside the platform's per-user configuration
+//! directory; its startup command file is `pardes/init`. Discovery is
+//! deliberately launcher-owned: native
//! launchers opt in by putting the bytes in `Options.startup_config`, while
//! tests and the browser keep the default `null`.
@@ -10,7 +11,10 @@ const builtin = @import("builtin");
const max_bytes = 1024 * 1024;
-/// Resolve the native per-user config file with stdlib environment and path
+pub const init_name = "init";
+pub const builtin_themes_subdir = "themes/builtin";
+
+/// Resolve the native per-user config directory with stdlib environment and path
/// APIs. XDG_CONFIG_HOME is accepted only when absolute, as required by the
/// XDG base-directory specification; an empty/relative value falls back.
pub fn path(gpa: std.mem.Allocator, env: *const std.process.Environ.Map) !?[]u8 {
@@ -31,12 +35,14 @@ pub fn path(gpa: std.mem.Allocator, env: *const std.process.Environ.Map) !?[]u8
return try std.fs.path.join(gpa, &.{ home, ".config", "pardes" });
}
-/// The config file: WHERE it was looked for, and what was there. Missing,
+/// The config directory and its init file: WHERE they were looked for, and
+/// what was there. Missing,
/// unreadable, oversized, or otherwise unusable config is simply no config —
/// but the path resolves either way, because "nothing is there yet" is the
/// answer the Config builtin exists to give and a null would erase it. The
-/// arena passed by the launcher owns both for the process.
+/// arena passed by the launcher owns every returned slice for the process.
pub const Found = struct {
+ dir: ?[]const u8 = null,
path: ?[]const u8 = null,
bytes: ?[]const u8 = null,
};
@@ -46,8 +52,10 @@ pub fn load(
gpa: std.mem.Allocator,
env: *const std.process.Environ.Map,
) Found {
- const config_path = (path(gpa, env) catch return .{}) orelse return .{};
+ const config_dir = (path(gpa, env) catch return .{}) orelse return .{};
+ const config_path = std.fs.path.join(gpa, &.{ config_dir, init_name }) catch return .{ .dir = config_dir };
return .{
+ .dir = config_dir,
.path = config_path,
.bytes = std.Io.Dir.cwd().readFileAlloc(io, config_path, gpa, .limited(max_bytes)) catch null,
};
@@ -85,7 +93,7 @@ test "config path honors XDG and rejects a relative XDG directory" {
try std.testing.expectEqualStrings(expected, fallback);
}
-test "config loader is silent when missing and returns exact file bytes" {
+test "config loader reads init inside the config directory" {
if (builtin.os.tag == .windows) return;
var tmp = std.testing.tmpDir(.{});
@@ -98,15 +106,83 @@ test "config loader is silent when missing and returns exact file bytes" {
try env.put("XDG_CONFIG_HOME", base_buf[0..base_len]);
const missing = load(std.testing.io, std.testing.allocator, &env);
+ defer std.testing.allocator.free(missing.dir.?);
defer std.testing.allocator.free(missing.path.?);
- const expected = try std.fs.path.join(std.testing.allocator, &.{ base_buf[0..base_len], "pardes" });
+ const expected_dir = try std.fs.path.join(std.testing.allocator, &.{ base_buf[0..base_len], "pardes" });
+ defer std.testing.allocator.free(expected_dir);
+ const expected = try std.fs.path.join(std.testing.allocator, &.{ expected_dir, init_name });
defer std.testing.allocator.free(expected);
+ try std.testing.expectEqualStrings(expected_dir, missing.dir.?);
try std.testing.expectEqualStrings(expected, missing.path.?);
try std.testing.expect(missing.bytes == null);
const source = "Theme dark\nUnknown command\nTheme acme\n";
- try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "pardes", .data = source });
+ try tmp.dir.createDir(std.testing.io, "pardes", .default_dir);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "pardes/init", .data = source });
const found = load(std.testing.io, std.testing.allocator, &env);
+ defer std.testing.allocator.free(found.dir.?);
defer std.testing.allocator.free(found.path.?);
defer std.testing.allocator.free(found.bytes.?);
+ try std.testing.expectEqualStrings(expected_dir, found.dir.?);
try std.testing.expectEqualStrings(source, found.bytes.?);
}
+
+/// Write one editable `.zon` file per compiled theme. The caller supplies the
+/// ring so this filesystem-only module does not import the core. Existing
+/// generated copies are replaced; unrelated files in the directory are left
+/// alone, which lets a user keep custom themes beside the reference set.
+pub fn dumpThemes(
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ config_dir: []const u8,
+ theme_values: anytype,
+) ![]u8 {
+ const out_dir = try std.fs.path.join(gpa, &.{ config_dir, builtin_themes_subdir });
+ errdefer gpa.free(out_dir);
+ try std.Io.Dir.cwd().createDirPath(io, out_dir);
+
+ for (theme_values) |theme_value| {
+ var encoded: std.Io.Writer.Allocating = .init(gpa);
+ defer encoded.deinit();
+ try std.zon.stringify.serialize(theme_value, .{ .whitespace = true }, &encoded.writer);
+
+ const filename = try std.fmt.allocPrint(gpa, "{s}.zon", .{theme_value.name});
+ defer gpa.free(filename);
+ const output_path = try std.fs.path.join(gpa, &.{ out_dir, filename });
+ defer gpa.free(output_path);
+ try std.Io.Dir.cwd().writeFile(io, .{
+ .sub_path = output_path,
+ .data = encoded.written(),
+ });
+ }
+ return out_dir;
+}
+
+test "theme dump creates the builtin subdirectory and ZON files" {
+ const io = std.testing.io;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var base_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const base_len = try tmp.dir.realPath(io, &base_buf);
+ const Sample = struct { name: []const u8, rgb: [3]u8 };
+ const samples = [_]Sample{
+ .{ .name = "one", .rgb = .{ 1, 2, 3 } },
+ .{ .name = "two", .rgb = .{ 4, 5, 6 } },
+ };
+ const output = try dumpThemes(io, gpa, base_buf[0..base_len], &samples);
+ defer gpa.free(output);
+ const expected = try std.fs.path.join(gpa, &.{ base_buf[0..base_len], builtin_themes_subdir });
+ defer gpa.free(expected);
+ try std.testing.expectEqualStrings(expected, output);
+
+ const one_path = try std.fs.path.join(gpa, &.{ output, "one.zon" });
+ defer gpa.free(one_path);
+ const bytes = try std.Io.Dir.cwd().readFileAlloc(io, one_path, gpa, .limited(4096));
+ defer gpa.free(bytes);
+ const source = try gpa.dupeZ(u8, bytes);
+ defer gpa.free(source);
+ const parsed = try std.zon.parse.fromSliceAlloc(Sample, gpa, source, null, .{});
+ defer std.zon.parse.free(gpa, parsed);
+ try std.testing.expectEqualStrings("one", parsed.name);
+ try std.testing.expectEqual([3]u8{ 1, 2, 3 }, parsed.rgb);
+}
diff --git a/src/web.zig b/src/web.zig
index ebcc9546..24ae7296 100644
--- a/src/web.zig
+++ b/src/web.zig
@@ -477,6 +477,7 @@ export fn pardes_effect_next() u32 {
effect_aux1 = e.serial;
break :blk 11;
},
+ .theme_file, .dump_themes => unreachable,
.quit => 10,
};
}