From c3d0b84b7961ae26d2d654e7120821cc2d83d20d Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 24 Aug 2026 10:56:05 -0300 Subject: host: the core owns the event loop; every platform becomes a vtable of optional methods --- src/macos.zig | 525 ++++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 328 insertions(+), 197 deletions(-) (limited to 'src/macos.zig') 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 - -/// Perform the IO the core queued. `threads_ok` is false for the one drain -/// inside pardes_init, which runs before any reader task exists. +// ---------------------------------------------------------------- host seam + +/// 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]; - - 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 => {}, - } + // 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]; + + 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); +} -- cgit v1.3