summaryrefslogtreecommitdiff
path: root/src/macos.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/macos.zig')
-rw-r--r--src/macos.zig521
1 files changed, 326 insertions, 195 deletions
diff --git a/src/macos.zig b/src/macos.zig
index 2569e6b3..e54e013c 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -25,7 +25,6 @@ const posix = std.posix;
const libc = std.c;
const pardes = @import("pardes.zig");
const look = @import("look.zig");
-const temp_file = @import("temp_file.zig");
const shell_bin = @import("shell_bin.zig");
const message = @import("message.zig");
const nested = @import("nested.zig");
@@ -499,7 +498,9 @@ const State = struct {
threaded: *std.Io.Threaded,
io: std.Io,
core: *pardes.Pardes,
- arena: std.heap.ArenaAllocator,
+ /// False for the one effect drain inside pardes_init and nothing else: no
+ /// reader task exists yet, and the first theme file must land without a fade.
+ started: bool = false,
runtime: Runtime,
cells: []Cell = &.{},
/// Frozen canonical grid paired with an encoded change mask while a content or
@@ -609,6 +610,9 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
var opts: pardes.Options = .{
.tty_only = true,
+ // The purpose-built 16 MiB stack-fallback buffer this host has always
+ // rendered out of; the core builds its per-frame Surface arena on it.
+ .frame_allocator = allocs.frame,
.image_allocator = allocs.image,
.pdf_allocator = allocs.pdf,
.tree_sitter_allocator = allocs.tree_sitter,
@@ -653,15 +657,15 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
.threaded = threaded,
.io = io,
.core = core,
- .arena = .init(allocs.frame),
.config_arena = config_arena,
.prompt_rcs = prompt_rcs,
.runtime = if (runtime) |r| r.* else .{},
};
const st = &state.?;
- // the core's one way to ask about this host's ptys, pulled at the Exec that
- // cares rather than pushed with the cwd above
- core.tty_query = .{ .ctx = st, .taken = &ttyTakenAt };
+ // Every capability this host has, including the tty pull the core makes at
+ // the Exec that cares rather than at the cwd read above. Assigned here and
+ // not left to `pump`, because the spawns below happen outside one.
+ core.host = hostFor(st);
// The real grid, delivered as an EVENT and not as Options.cols/rows: the
// core defers an integrated shell's greeting until this resize and OSC
@@ -678,7 +682,8 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
// itself — chdir and execv, raw syscalls with nothing allocated between
// fork and exec — not the thread count. Ordering it this way anyway keeps
// the two backends readable side by side.
- _ = drainEffects(st, false);
+ while (core.nextEffect()) |effect| core.perform(effect);
+ st.started = true;
for (&st.ptys, 0..) |*slot, id| if (slot.*) |*pt| startReader(st, pt, @intCast(id));
// Last, because it is the one thing here that publishes this process to
@@ -750,7 +755,6 @@ export fn pardes_deinit() void {
if (st.previous_cells.len > 0) st.gpa.free(st.previous_cells);
if (st.changed_cells.len > 0) st.gpa.free(st.changed_cells);
if (st.images.len > 0) st.gpa.free(st.images);
- st.arena.deinit();
st.core.deinit();
pardes.image.stop();
if (comptime pardes.pdf_enabled) pardes.pdf.stop();
@@ -870,7 +874,8 @@ export fn pardes_panel_animation_failed() void {
st.core.abandonPanelAnimations();
}
-/// Re-read the cwd of every shell that just spoke, and only those.
+/// The core's per-frame poll: re-read the cwd of every shell that just spoke,
+/// and only those.
///
/// A pane's tag shows this and a relative `Look` resolves against it, so it has
/// to follow the shell around rather than stay at the directory the pane was
@@ -881,8 +886,9 @@ export fn pardes_panel_animation_failed() void {
/// and a busy one costs one libproc call per pane per burst.
///
/// Whether a shell's tty is still that shell is NOT refreshed here: nothing
-/// draws it, so the core pulls it instead (see `ttyTakenAt`).
-fn refreshCwds(st: *State) void {
+/// draws it, so the core pulls it instead (see `ttyTaken`).
+fn refreshCwds(ctx: ?*anyopaque) void {
+ const st = hostState(ctx);
for (&st.cwd_stale, 0..) |*stale, id| {
if (!stale.*) continue;
stale.* = false;
@@ -908,17 +914,9 @@ fn watchInitialGeneration(st: *State, pane: u8, path: []const u8) ?file_watch.Ge
return null;
}
-fn setFileWatch(st: *State, pane: u8, on: bool) void {
- if (!on) {
- const generation = st.file_watches.stop(st.gpa, pane);
- hostWatchFile(pane, generation, null, 0);
- return;
- }
- const path = paneWatchPath(st.core, pane) orelse {
- const generation = st.file_watches.stop(st.gpa, pane);
- hostWatchFile(pane, generation, null, 0);
- return;
- };
+/// Start watching the path the core resolved for this pane. Turning a watch off
+/// is the caller's business (`watchFile`); everything here is the start.
+fn setFileWatch(st: *State, pane: u8, path: []const u8) void {
const value = st.core.panes[pane] orelse return;
const generation_on_disk = watchInitialGeneration(st, pane, path) orelse return;
const generation = st.file_watches.replace(
@@ -1091,32 +1089,13 @@ export fn pardes_watch_changed(pane: u8, generation: u32) void {
if (st.file_watches.notify(pane, generation)) wake(st);
}
-/// The core's `tty_query`, asked only where a command line is about to be typed:
-/// is a program holding this pane's tty instead of the prompt we forked?
-/// `look.ttyTaken` answers `false` on darwin until it grows a libproc
-/// implementation, so this host behaves exactly as it did — the wiring is here
-/// so it cannot rot, and it costs nothing until then.
-fn ttyTakenAt(ctx: ?*anyopaque, pane: usize) bool {
- const st: *const State = @ptrCast(@alignCast(ctx orelse return false));
- const pt = st.ptys[pane] orelse return false;
- return look.ttyTaken(pt.pid, pt.file.handle);
-}
-
-/// Drain what the reader tasks collected into the core, then perform whatever
-/// the core queued in response. Returns whether this tick did any IO.
-///
-/// NOT a repaint signal, however tempting: the core changes the grid on its own
-/// for a cursor move, a selection, a mode change and a scroll, none of which
-/// queue an effect or read a pty, so all four return false here. The macOS host
-/// learned that the expensive way — see the comment on pump() in
-/// src/macos/Sources/AppDelegate.swift.
-export fn pardes_tick() bool {
- const st = &(state orelse return false);
- // Cleared before the drain: a reader that pushes during this tick must be
- // able to schedule the next one.
- st.inbox.wake_pending.store(false, .release);
+/// What arrived off the loop thread since the last tick: pty output, a reaped
+/// shell, a nested `Look`, and the file-watch edges Swift debounced. Every one
+/// of them carries borrowed bytes, so they go straight into `update` rather
+/// than through the core's event queue.
+fn drainInbox(st: *State) bool {
var batch = st.inbox.take();
- var changed = batch.len > 0;
+ var did = batch.len > 0;
for (batch.slice()) |msg| {
defer msg.free(st.gpa);
switch (msg) {
@@ -1141,14 +1120,45 @@ export fn pardes_tick() bool {
for (0..pardes.MAX_PANES) |pane| {
const id: u8 = @intCast(pane);
if (!st.file_watches.takeDirty(id)) continue;
- if (reloadWatchedFile(st, id, true)) changed = true;
+ if (reloadWatchedFile(st, id, true)) did = true;
}
if (st.file_watches.takeDirty(theme_watch_pane)) {
- if (reloadWatchedTheme(st, true)) changed = true;
+ if (reloadWatchedTheme(st, true)) did = true;
}
- refreshCwds(st);
- if (drainEffects(st, true)) changed = true;
- return changed;
+ return did;
+}
+
+/// Hand the core what arrived off-thread, then perform whatever it queued in
+/// response. Returns whether this tick had IO to do, which is what bounds the
+/// app's "pump until quiet" drain loop.
+///
+/// It deliberately does NOT render. AppKit wants to be TOLD the view is dirty
+/// and to draw once per display refresh: a pty burst is a dozen wakeups and a
+/// dozen ticks, and rendering inside each of them would encode eleven grids
+/// nobody ever sees. The render is `pardes_frame`, which the draw callback
+/// calls at display cadence — the coalescing this whole boundary is shaped
+/// around, and what src/macos/pardes.h has always said pardes_frame is.
+///
+/// NOT a repaint signal, however tempting: the core changes the grid on its own
+/// for a cursor move, a selection, a mode change and a scroll, none of which
+/// queue an effect or read a pty, so all four return false here. The macOS host
+/// learned that the expensive way — see the comment on pump() in
+/// src/macos/Sources/AppDelegate.swift.
+export fn pardes_tick() bool {
+ const st = &(state orelse return false);
+ // Cleared before the drain: a reader that pushes during this tick must be
+ // able to schedule the next one.
+ st.inbox.wake_pending.store(false, .release);
+ var did = drainInbox(st);
+ // Straight to `perform`, not through `pump`: the effects are the IO half of
+ // a tick and the render is not. `core.host` was seated once at init and is
+ // this host for the life of the session, so both this loop and the
+ // `tty_taken` pull the next keystroke makes land here.
+ while (st.core.nextEffect()) |effect| {
+ did = true;
+ st.core.perform(effect);
+ }
+ return did;
}
/// Advance exactly one display-clock frame. Event pumps deliberately never
@@ -1381,22 +1391,44 @@ export fn pardes_resize(cols_arg: u16, rows_arg: u16, cell_w: u16, cell_h: u16)
// ---------------------------------------------------------------- frame out
+/// Render one frame, and the only place this host renders: AppKit's draw
+/// callback, which is the one call it coalesces. A burst of input or pty output
+/// marks the view dirty many times and is drawn once, so however much work the
+/// ticks above drained, the grid is encoded once per display refresh.
+///
+/// It is the core's whole loop iteration — drain, perform, poll, render,
+/// present — and it cannot block: `wait_input` is null, because AppKit
+/// delivered the events before it called us and sleeping inside a run-loop
+/// callback is a beachball. `present` copies the result into the flat buffers
+/// the accessors below describe (presentFrame); returns their cell count, or 0
+/// if the render failed.
export fn pardes_frame() u32 {
const st = &(state orelse return 0);
- _ = st.arena.reset(.retain_capacity);
- // The accessors below must never describe a different frame than the count
- // this returns, so a failure empties all of them together rather than
- // leaving last frame's buffer behind a fresh cols/rows.
+ st.core.pump(hostFor(st)) catch |err| {
+ log.err("render failed: {t}", .{err});
+ clearFrame(st);
+ return 0;
+ };
+ return @intCast(st.frame_len);
+}
+
+/// Everything the accessors below describe is emptied together, so a failure
+/// can never leave last frame's buffer behind a fresh cols/rows.
+fn clearFrame(st: *State) void {
st.frame_len = 0;
st.frame_cols = 0;
st.frame_rows = 0;
st.images_len = 0;
st.panel_tracks_len = 0;
st.panel_diff_len = 0;
- const surface = st.core.render(st.arena.allocator()) catch |err| {
- log.err("render failed: {t}", .{err});
- return 0;
- };
+}
+
+/// Copy one rendered frame into the flat buffers the native renderer reads.
+/// Core-owned Cell layout is never borrowed across the ABI, so the grid, the
+/// panel diff, the attachments and the tracks are all encoded here.
+fn presentFrame(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
+ const st = hostState(ctx);
+ clearFrame(st);
const count: usize = @as(usize, surface.cols) * surface.rows;
if (count != st.cells.len) {
if (count == 0) {
@@ -1407,7 +1439,7 @@ export fn pardes_frame() u32 {
st.gpa.alloc(Cell, count)
else
st.gpa.realloc(st.cells, count);
- st.cells = resized catch return 0;
+ st.cells = resized catch return;
}
}
st.frame_len = count;
@@ -1417,7 +1449,6 @@ export fn pardes_frame() u32 {
collectPanelDiff(st, surface, count);
collectImages(st, surface);
collectPanelTracks(st, surface);
- return @intCast(count);
}
/// Flatten tracks into the C-visible order the shader composites them. Pane
@@ -1709,147 +1740,191 @@ fn activeFilePath(st: *State) ?[]const u8 {
return null;
}
-// ---------------------------------------------------------------- effects
+// ---------------------------------------------------------------- host seam
-/// Perform the IO the core queued. `threads_ok` is false for the one drain
-/// inside pardes_init, which runs before any reader task exists.
+/// What this host can do, for the core's own loop to call. What it deliberately
+/// cannot:
+/// * `wait_input` — AppKit delivered the events before it called us and owns
+/// the sleep; blocking inside a run-loop callback is a beachball.
+/// * `post_present` — presentation is acknowledged when the destination
+/// context has accepted the frame (pardes_frame_presented), which is a
+/// later callback, not the moment the cells were encoded.
+/// * `lsp` — the core's own empty answer is exactly what this host replied,
+/// and for the same reason: a dropped request leaves lsp_wait armed and
+/// every later dot-Tab dead.
+/// * `pipe` — no worker to hand a job to. Teardown is not a method at all:
+/// pardes_deinit is the app's own call, made after AppKit's loop rather
+/// than from inside one.
///
-/// ponytail: lsp and pipe still do no work here. Each wants real machinery — a
+/// ponytail: lsp and pipe still do no work. Each wants real machinery — a
/// worker plus a snapshot of the pane's file for lsp (src/tty/tty.zig:919), and
/// a job copy for pipe. Watch is deliberately different: FileWatcher.swift
/// owns its per-directory DispatchSource and only returns a debounced hint;
-/// this main-thread drain owns the bytes, hash and shared text/PDF core event.
-fn drainEffects(st: *State, threads_ok: bool) bool {
+/// these main-thread methods own the bytes, hash and shared text/PDF core event.
+const vtable: pardes.Host.VTable = .{
+ .push_present = presentFrame,
+ .push_poll_frame = refreshCwds,
+ .push_spawn = spawnShell,
+ .push_pty_write = ptyWrite,
+ .push_pty_resize = ptyResize,
+ .pull_tty_taken = ttyTaken,
+ .push_write_file = writeFile,
+ .push_write_dump = writeDump,
+ .push_watch_file = watchFile,
+ .push_watch_theme = watchTheme,
+ .push_dump_themes = dumpThemes,
+ .push_set_clipboard = setClipboard,
+ .pull_read_clipboard = readClipboard,
+ .push_open_link = openLink,
+};
+
+fn hostFor(st: *State) pardes.Host {
+ return .{ .ctx = st, .vtable = &vtable };
+}
+
+fn hostState(ctx: ?*anyopaque) *State {
+ return @ptrCast(@alignCast(ctx.?));
+}
+
+fn spawnShell(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
+ const st = hostState(ctx);
const core = st.core;
- var did = false;
- while (core.nextEffect()) |effect| {
- did = true;
- switch (effect) {
- .spawn => |sp| {
- // The core reuses pane ids and has no close effect, so a
- // deleted pane's shell lives in its slot until a respawn lands
- // here. Reap it: cancel joins the reader, and the generation
- // bump makes its late bytes and eof unreadable.
- reap(st, sp.pane);
- st.gens[sp.pane] +%= 1;
- const gen = st.gens[sp.pane];
+ // The core reuses pane ids and has no close effect, so a deleted pane's
+ // shell lives in its slot until a respawn lands here. Reap it: cancel joins
+ // the reader, and the generation bump makes its late bytes and eof
+ // unreadable.
+ reap(st, pane);
+ st.gens[pane] +%= 1;
+ const gen = st.gens[pane];
- const cwd = sp.cwd.slice();
- var cwd_buf: [256:0]u8 = undefined;
- var cwd_z: ?[*:0]const u8 = null;
- // <= because writing the sentinel slot of a [N:0]u8 is legal,
- // and Effect's cwd buffer is exactly 256: `<` would silently
- // drop a maximal path and start the shell wherever the app
- // bundle was launched from instead.
- if (cwd.len > 0 and cwd.len <= cwd_buf.len) {
- @memcpy(cwd_buf[0..cwd.len], cwd);
- cwd_buf[cwd.len] = 0;
- cwd_z = @ptrCast(&cwd_buf);
- }
- const child = forkShell(core, sp.pane, &st.prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w);
- st.ptys[sp.pane] = .{
- .file = child.file,
- .pid = child.pid,
- .gen = gen,
- .reader = .{ .any_future = null, .result = {} },
- };
- // Report the pane's starting directory back to the core (tags);
- // the slot needs no occupancy reset, nothing is remembered.
- var lbuf: [1024]u8 = undefined;
- if (look.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd);
- if (threads_ok) if (st.ptys[sp.pane]) |*pt| startReader(st, pt, sp.pane);
- },
- .write => |w| {
- if (st.ptys[w.pane]) |pt| writeFd(pt.file.handle, w.bytes.slice());
- },
- .resize_pty => |rs| {
- if (st.ptys[rs.pane]) |pt| {
- const ws: posix.winsize = .{ .row = rs.rows, .col = rs.cols, .xpixel = 0, .ypixel = 0 };
- _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws));
- }
- },
- .open_link => |url| look.openLink(url.slice()),
- .save_file => |sf| {
- const pane = core.panes[sf.pane] orelse continue;
- const f = pane.file orelse continue;
- var pathbuf: [4096:0]u8 = undefined;
- if (f.path.len >= pathbuf.len) continue;
- @memcpy(pathbuf[0..f.path.len], f.path);
- pathbuf[f.path.len] = 0;
- const fd = libc.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
- if (fd < 0) continue;
- writeFd(fd, f.content);
- _ = libc.close(fd);
- // The directory source will observe our own close. Move its
- // baseline first so that notification is a hash no-op instead
- // of manufacturing an external reload and undo boundary.
- st.file_watches.restampText(
- sf.pane,
- f.path,
- std.hash.Wyhash.hash(0, f.content),
- );
- // After the write, not beside it: every `continue` above is a
- // save that did not happen and must not be reported as one.
- var mbuf: [256]u8 = undefined;
- core.setMessage(sf.pane, message.stamp(&mbuf, "saved", f.path));
- },
- .new_file => |request| {
- var path_buf: [4096:0]u8 = undefined;
- const made = temp_file.create(&path_buf) orelse continue;
- if (core.openNewFile(request.pane, request.serial, made.path))
- made.adopt()
- else
- made.discard();
- },
- .write_dump => {
- const out = core.dump_out orelse continue;
- var pbuf: [1024:0]u8 = undefined;
- const path = pardes.dump.outPath(&pbuf) orelse continue;
- const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
- if (fd < 0) continue;
- writeFd(fd, out);
- _ = 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;
- cb(st.runtime.userdata, y.ptr, y.len);
- },
- // The host answers with pardes_paste, which the AppDelegate calls
- // straight back inside this call: NSPasteboard reads are
- // synchronous, so the paste event lands here, mid-drain. That is
- // safe and deliberate — pardes_paste only feeds core.update, and
- // whatever that queues is picked up by this same loop rather than
- // waiting a tick. A host with a null callback simply never pastes.
- .read_clipboard => {
- const cb = st.runtime.read_clipboard orelse continue;
- cb(st.runtime.userdata);
- },
- // An empty answer, immediately: the honest reply from a shell with
- // no worker, and the only safe one. Tab after a `.` DIVERTS to the
- // backend instead of indenting and indents late, when the answer
- // comes back empty (lspResponse); drop the effect and lsp_wait
- // stays armed, the retroactive indent never fires, and every
- // 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 => {},
- }
+ var cwd_buf: [256:0]u8 = undefined;
+ var cwd_z: ?[*:0]const u8 = null;
+ // <= because writing the sentinel slot of a [N:0]u8 is legal, and Effect's
+ // cwd buffer is exactly 256: `<` would silently drop a maximal path and
+ // start the shell wherever the app bundle was launched from instead.
+ if (cwd.len > 0 and cwd.len <= cwd_buf.len) {
+ @memcpy(cwd_buf[0..cwd.len], cwd);
+ cwd_buf[cwd.len] = 0;
+ cwd_z = @ptrCast(&cwd_buf);
}
- return did;
+ const child = forkShell(core, pane, &st.prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w);
+ st.ptys[pane] = .{
+ .file = child.file,
+ .pid = child.pid,
+ .gen = gen,
+ .reader = .{ .any_future = null, .result = {} },
+ };
+ // Report the pane's starting directory back to the core (tags); the slot
+ // needs no occupancy reset, nothing is remembered.
+ var lbuf: [1024]u8 = undefined;
+ if (look.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(pane, wd);
+ if (st.started) if (st.ptys[pane]) |*pt| startReader(st, pt, pane);
+}
+
+fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void {
+ const st = hostState(ctx);
+ if (st.ptys[pane]) |pt| writeFd(pt.file.handle, bytes);
+}
+
+fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void {
+ const st = hostState(ctx);
+ const pt = st.ptys[pane] orelse return;
+ const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws));
+}
+
+/// Asked only where a command line is about to be typed: is a program holding
+/// this pane's tty instead of the prompt we forked? `look.ttyTaken` answers
+/// `false` on darwin until it grows a libproc implementation, so this host
+/// behaves exactly as it did — the wiring is here so it cannot rot, and it
+/// costs nothing until then.
+fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool {
+ const st = hostState(ctx);
+ const pt = st.ptys[pane] orelse return false;
+ return look.ttyTaken(pt.pid, pt.file.handle);
+}
+
+/// A file pane's save and a scrollback's both land here; the core has already
+/// resolved which path and which bytes.
+fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
+ const st = hostState(ctx);
+ var pathbuf: [4096:0]u8 = undefined;
+ if (path.len >= pathbuf.len) return;
+ @memcpy(pathbuf[0..path.len], path);
+ pathbuf[path.len] = 0;
+ const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ if (fd < 0) return;
+ writeFd(fd, bytes);
+ _ = libc.close(fd);
+ // The directory source will observe our own close. Move its baseline first
+ // so that notification is a hash no-op instead of manufacturing an external
+ // reload and undo boundary.
+ st.file_watches.restampText(pane, path, std.hash.Wyhash.hash(0, bytes));
+ // After the write, not beside it: every early return above is a save that
+ // did not happen and must not be reported as one.
+ var mbuf: [256]u8 = undefined;
+ st.core.setMessage(pane, message.stamp(&mbuf, "saved", path));
+}
+
+fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
+ const st = hostState(ctx);
+ var pbuf: [1024:0]u8 = undefined;
+ const path = pardes.dump.outPath(&pbuf) orelse return;
+ const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ if (fd < 0) return;
+ writeFd(fd, bytes);
+ _ = libc.close(fd);
+ st.core.setLastDump(path);
+}
+
+fn watchFile(ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool) void {
+ const st = hostState(ctx);
+ // No path is a pane with nothing on disk to watch (an output buffer, an
+ // image), which is the same answer as being turned off.
+ if (!on or path.len == 0) {
+ const generation = st.file_watches.stop(st.gpa, pane);
+ hostWatchFile(pane, generation, null, 0);
+ return;
+ }
+ setFileWatch(st, pane, path);
+}
+
+fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void {
+ const st = hostState(ctx);
+ setThemeFileWatch(st, generation, on, st.started);
+}
+
+fn dumpThemes(ctx: ?*anyopaque, pane: u8) void {
+ const st = hostState(ctx);
+ const config_dir = st.core.opts.config_dir orelse return;
+ const out_dir = user_config.dumpThemes(st.io, st.gpa, config_dir, pardes.themes) catch |err| {
+ st.core.reportError(pane, "dump themes", err);
+ return;
+ };
+ defer st.gpa.free(out_dir);
+ var mbuf: [256]u8 = undefined;
+ st.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir));
+}
+
+fn setClipboard(ctx: ?*anyopaque, text: []const u8) void {
+ const st = hostState(ctx);
+ const cb = st.runtime.set_clipboard orelse return;
+ cb(st.runtime.userdata, text.ptr, text.len);
+}
+
+/// The host answers with pardes_paste, which the AppDelegate calls straight
+/// back inside this call: NSPasteboard reads are synchronous, so the paste
+/// event lands mid-pump. That is safe and deliberate — pardes_paste only feeds
+/// core.update, and whatever that queues is picked up by the same effect loop
+/// rather than waiting a tick. A host with a null callback simply never pastes.
+fn readClipboard(ctx: ?*anyopaque) void {
+ const st = hostState(ctx);
+ const cb = st.runtime.read_clipboard orelse return;
+ cb(st.runtime.userdata);
+}
+
+fn openLink(_: ?*anyopaque, url: []const u8) void {
+ look.openLink(url);
}
// ---------------------------------------------------------------- workers
@@ -2406,3 +2481,59 @@ test "the dial flings in proportion to the release, and not at all when placed"
try std.testing.expect(travel(100_000) / rotation_notch_degrees < 12);
try std.testing.expect(travel(100_000) / rotation_notch_degrees > 8);
}
+
+// The loop, end to end, on the one machine that can run it: the core owns the
+// iteration now, so the two things this file used to spell out by hand are
+// exactly what a live session has to keep proving. A frame exists because the
+// DRAW rendered one — ticks drain work and never render, which is what lets
+// AppKit coalesce a burst into a single encoded grid — and elapsed animation
+// time is spent only by the display clock, however many times the tick runs.
+//
+// It really boots: a shell is forked, an inbox drains, effects are performed
+// through the vtable. Everything above it is the Swift app, which needs a Mac.
+test "a live session renders on the draw and animates only on the display clock" {
+ try std.testing.expectEqual(@as(c_int, 0), pardes_init(null, 80, 24));
+ defer pardes_deinit();
+ const st = &state.?;
+
+ // The spawn effect reached forkpty rather than the core's silent fallback:
+ // init performs its own drain, before any reader task exists.
+ try std.testing.expect(st.ptys[0] != null);
+ // A tick drains and performs. It publishes no frame, so ten of them in a
+ // pty burst cost one render and not ten.
+ _ = pardes_tick();
+ _ = pardes_tick();
+ try std.testing.expectEqual(@as(u16, 0), pardes_frame_cols());
+ try std.testing.expect(pardes_frame_cells() == null);
+
+ // The draw is what renders and presents.
+ try std.testing.expectEqual(@as(u32, 80 * 24), pardes_frame());
+ try std.testing.expectEqual(@as(u16, 80), pardes_frame_cols());
+ try std.testing.expectEqual(@as(u16, 24), pardes_frame_rows());
+ try std.testing.expect(pardes_frame_cells() != null);
+
+ // Two themes, so the second retarget is a real transition whatever the
+ // developer's config booted this session wearing.
+ for ([_][]const u8{ "Theme dark", "Theme acme" }) |command| {
+ pardes_command(command.ptr, command.len);
+ _ = pardes_tick();
+ _ = pardes_frame();
+ }
+ try std.testing.expect(pardes_animating());
+ const step = st.core.chrome_animation.step;
+ // Input and pty pumps drain work and draws encode it; neither spends a
+ // frame, which is what keeps a burst of keys from collapsing a ten-frame
+ // fade into one.
+ _ = pardes_tick();
+ _ = pardes_frame();
+ _ = pardes_tick();
+ _ = pardes_frame();
+ try std.testing.expectEqual(step, st.core.chrome_animation.step);
+ try std.testing.expectEqual(@as(usize, 0), st.core.in_len);
+ // Only the display clock spends it, and exactly one frame per call.
+ try std.testing.expect(pardes_animation_tick());
+ try std.testing.expectEqual(step + 1, st.core.chrome_animation.step);
+ _ = pardes_tick();
+ _ = pardes_frame();
+ try std.testing.expectEqual(step + 1, st.core.chrome_animation.step);
+}