diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/builtins.zig | 40 | ||||
| -rw-r--r-- | src/config.zig | 7 | ||||
| -rw-r--r-- | src/file_watch.zig | 158 | ||||
| -rw-r--r-- | src/gui/gui.zig | 25 | ||||
| -rw-r--r-- | src/macos.zig | 82 | ||||
| -rw-r--r-- | src/macos/pardes.h | 8 | ||||
| -rw-r--r-- | src/main.zig | 1 | ||||
| -rw-r--r-- | src/output_pane_integration_test.zig | 2 | ||||
| -rw-r--r-- | src/pardes.zig | 210 | ||||
| -rw-r--r-- | src/runtime_config.zig | 4 | ||||
| -rw-r--r-- | src/tty/tty.zig | 27 | ||||
| -rw-r--r-- | src/tutor.txt | 16 | ||||
| -rw-r--r-- | src/user_config.zig | 94 | ||||
| -rw-r--r-- | src/web.zig | 1 |
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, }; } |
