summaryrefslogtreecommitdiff
path: root/src/macos.zig
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/macos.zig
parentc24a9e40215bc30c68c9db7675f1c6a07d9bbae3 (diff)
downloadpardes-382abe3dafb872b3e6c9792a9cc0abcf6267e180.tar.gz
pardes-382abe3dafb872b3e6c9792a9cc0abcf6267e180.zip
file_watch + builtins + config: richer watch semantics, new builtins, config docs
Diffstat (limited to 'src/macos.zig')
-rw-r--r--src/macos.zig82
1 files changed, 78 insertions, 4 deletions
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 => {},
}