From c3c8bbd8d8add99088c774c54bc1acf1e39ec895 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sat, 8 Aug 2026 10:44:56 -0300 Subject: a native macOS backend: libpardes plus an AppKit shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds -Dplatform=macos, a fourth backend beside tty, gui and web. Zig keeps the core, the ptys, every effect and the worker threads; Swift owns NSApplication, the window, input translation, and drawing the cell grid with CoreText. They meet at a hand-written C ABI in src/macos/pardes.h, built as a static library the app links. The ABI is src/web.zig's boundary with the wasm removed, because both hosts are the same animal: someone else owns the clock, feeds events in through flat functions, and reads one packed cell buffer out. The browser proved the shape. The one divergence is that the browser has no processes and forwards every effect to JavaScript, whereas forkpty is right here, so src/macos.zig performs them — spawn, write, resize_pty, save_file, new_file, write_dump, open_link, set_clipboard. lsp, pipe and watch are answered with nothing and marked; the core already tolerates that, since the browser answers none of them either. This deliberately inverts ghostty's split, which was studied first and is written up in docs/ghostty-macos-notes.md. Ghostty hands Zig a bare NSView*, installs its own CALayer and owns the frame clock; Swift never renders. Pardes does the opposite because its frame is already a cell grid and CoreText draws one natively — the alternative is a second hand-rolled glyph atlas, which is what most of gui.zig's 4,300 lines already are. It would also have been written blind: the Swift half cannot be compiled here. What makes the scaffold verifiable rather than dead code is that the Zig half is ordinary POSIX and builds and tests on Linux. Borrowing ghostty's best trick, build.zig translate-C's the header into the test build and src/macos.zig asserts every constant, struct layout, and exported function's arity and widths against it. That guard earned its place immediately: pardes_scroll grew a cell coordinate after the Swift view had been written against the older form. Skipped, and named as the upgrade path in docs/macos.md: the Xcode project, xcframework, lipo and codesigning ghostty needs. All four exist for distribution; a dev build is a swiftc invocation and a directory with a plist. The Swift app is a scaffold and says so — every uncertain API spelling carries an UNVERIFIED marker, and no part of it has been compiled. tty is unaffected: 75/75 snapshot scripts and both unit suites pass. --- src/macos.zig | 882 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 882 insertions(+) create mode 100644 src/macos.zig (limited to 'src/macos.zig') diff --git a/src/macos.zig b/src/macos.zig new file mode 100644 index 00000000..8eb6179a --- /dev/null +++ b/src/macos.zig @@ -0,0 +1,882 @@ +//! libpardes — the static library the native macOS app links against. +//! +//! The split, which is the whole design: Zig keeps the core, the ptys, every +//! effect and the worker threads; Swift owns NSApplication, the window, input +//! translation and drawing. src/macos/pardes.h is the contract between them and +//! docs/macos.md argues for the shape. +//! +//! This is deliberately src/web.zig's boundary with the wasm removed. Both +//! hosts are the same animal — someone else owns the clock, feeds events in +//! through flat functions and reads one packed cell buffer out — and the +//! browser already proved the shape works. The one real divergence is that the +//! browser has no processes, so it forwards every effect to JavaScript, whereas +//! forkpty is right here and this file performs them. +//! +//! Everything below is main-thread only. The single exception is the `wakeup` +//! callback, which a pty reader task calls; the host's job is to hop to the +//! main thread and call pardes_tick. +//! +//! The Zig half is ordinary POSIX and builds/tests on Linux — see the dev-loop +//! section of docs/macos.md. Only the Swift app needs a Mac. + +const std = @import("std"); +const builtin = @import("builtin"); +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 user_config = @import("user_config.zig"); + +extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; +extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; +extern "c" fn chdir(path: [*:0]const u8) c_int; +extern "c" fn _exit(status: c_int) noreturn; +extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; + +// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize). Same +// constant the tty and gui shells spell for the same reason. +const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); + +// A library linked into an AppKit process has no terminal to garble, but it +// does share the app's stderr with Console.app. Same filter as src/main.zig: +// ghostty-vt narrates every unimplemented escape a child writes, and nobody +// wants that in a crash report. PARDES_LOG=1 gets the real logger back. +pub const std_options: std.Options = .{ .logFn = logFn }; + +fn logFn( + comptime level: std.log.Level, + comptime scope: @EnumLiteral(), + comptime format: []const u8, + args: anytype, +) void { + if (scope != .macos and scope != .dump and std.c.getenv("PARDES_LOG") == null) return; + std.log.defaultLog(level, scope, format, args); +} + +const log = std.log.scoped(.macos); + +// ---------------------------------------------------------------- boundary + +/// Sync with: pardes_cell_s. The identical encoding is spelled a second time +/// for the browser as WebCell in src/web.zig. +/// +/// ponytail: two copies of a fifteen-line pure encoder, not a shared module. +/// The web ABI is snapshot-tested through a headless Chrome that does not run +/// here, so extracting it would refactor a backend I cannot exercise to save +/// thirty lines. Merge them the day a third host wants the same bytes. +pub const Cell = extern struct { + text: [8]u8, + fg: u32, + bg: u32, + attrs: u16, + len: u8, + flags: u8, +}; + +/// Sync with: pardes_runtime_s. Two callbacks, because everything else the +/// core asks for it already does itself — it owns the ptys, and look.openLink +/// hands URLs to /usr/bin/open. Both are optional at the ABI level: a host that +/// passes null simply does without, rather than trapping inside the library. +pub const Runtime = extern struct { + userdata: ?*anyopaque = null, + wakeup: ?*const fn (?*anyopaque) callconv(.c) void = null, + set_clipboard: ?*const fn (?*anyopaque, [*]const u8, usize) callconv(.c) void = null, +}; + +const color_default: u32 = 0x01000000; +const color_indexed: u32 = 0x02000000; +const cell_flag_default: u8 = 1; + +// ---------------------------------------------------------------- state + +/// One pty, and the task draining it. `gen` is the per-slot spawn generation: +/// the core reuses pane ids and has no close effect, so a respawned slot must +/// ignore the previous shell's late bytes rather than feed them to the new one. +const Pty = struct { + file: std.Io.File, + pid: posix.pid_t, + gen: u32, + reader: std.Io.Future(anyerror!void), +}; + +/// What a reader task hands the main thread. `gen` travels with the message so +/// a shell that was replaced while its read was in flight cannot have its +/// stragglers parsed into the pty that took its slot. +const Msg = union(enum) { + output: struct { pane: u8, gen: u32, bytes: []u8 }, + eof: struct { pane: u8, gen: u32 }, + + fn free(m: Msg, gpa: std.mem.Allocator) void { + switch (m) { + .output => |o| gpa.free(o.bytes), + .eof => {}, + } + } +}; + +/// 0.16 has no std.Thread.Mutex, and the gui shell's queue already settled +/// this: spin on the lock-free std.atomic.Mutex, and keep the critical section +/// to a pointer append. The reader allocates its chunk BEFORE taking the lock +/// for exactly that reason — a screenful of output must not make a keystroke +/// spin through a 64 KiB copy. +/// +/// ponytail: unbounded. `yes` in a pane can enqueue faster than the host +/// drains, and nothing throttles the reader — the tty shell gets that for free +/// from vaxis's 512-slot queue, whose post blocks when full, and the SDL shell +/// has the same hole this does. Bound it on queued bytes the day someone +/// watches RSS climb; the trap to avoid is a wait that teardown cannot cancel. +const Inbox = struct { + mutex: std.atomic.Mutex = .unlocked, + items: std.ArrayList(Msg) = .empty, + /// Set when a wakeup has been delivered and not yet answered by a tick. + /// Without it a busy shell posts one wakeup per 64 KiB chunk, and each one + /// is a block on the host's main queue — a screenful of output becomes + /// thousands of scheduled pumps that all find the same drained inbox. + wake_pending: std.atomic.Value(bool) = .init(false), + + fn lock(q: *Inbox) void { + // Bounded, unlike the gui shell's otherwise identical spin. AppKit's + // main thread runs at a higher QoS than these reader tasks and a raw + // CAS spin donates no priority, so a reader preempted inside the append + // (which can realloc) would have the highest-priority thread in the + // process spinning on it. Yielding hands the core back. + var spins: u8 = 0; + while (!q.mutex.tryLock()) { + spins +%= 1; + if (spins == 0) std.Thread.yield() catch {} else std.atomic.spinLoopHint(); + } + } + + fn push(q: *Inbox, gpa: std.mem.Allocator, m: Msg) void { + q.lock(); + defer q.mutex.unlock(); + q.items.append(gpa, m) catch m.free(gpa); + } +}; + +const State = struct { + gpa: std.mem.Allocator, + threaded: *std.Io.Threaded, + io: std.Io, + core: *pardes.Pardes, + arena: std.heap.ArenaAllocator, + runtime: Runtime, + cells: std.ArrayList(Cell) = .empty, + /// The grid `cells` actually holds. Not read back off the core: a render + /// can move screen_w/screen_h and then fail, and a host that sized its + /// loops from those would walk off the buffer. + frame_cols: u16 = 0, + frame_rows: u16 = 0, + ptys: [pardes.MAX_PANES]?Pty = @splat(null), + inbox: Inbox = .{}, + /// Per-slot spawn generation, owned by the main thread. A reader carries a + /// copy in every message it posts; anything that no longer matches belongs + /// to a shell this slot has already replaced. + gens: [pardes.MAX_PANES]u32 = @splat(0), + /// Sub-row wheel distance the core has not been told about yet. The core + /// moves a whole row at a time, so fractional trackpad travel accumulates + /// here and is spent as wheel presses — see pardes_scroll. + scroll_lag: f32 = 0, + /// Swapped with the inbox under the lock, so draining it costs one pointer + /// exchange and neither side reallocates once capacities have settled. + tick_msgs: std.ArrayList(Msg) = .empty, + /// Owns the bytes of the user config, which Options only borrows. + config_arena: std.heap.ArenaAllocator, +}; + +var state: ?State = null; + +// ---------------------------------------------------------------- lifecycle + +export fn pardes_init(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) c_int { + if (state != null) return 1; // already up; deinit first + initCore(runtime, cols_arg, rows_arg) catch |err| { + log.err("init failed: {t}", .{err}); + return 2; + }; + return 0; +} + +/// The body is split out purely so the cleanup below is real: `errdefer` fires +/// on an error return and nothing else, so writing this inside an export that +/// returns c_int would leave every one of these as dead code — and a half-built +/// init leaks an arena, leaves zstbi pointing at a dead allocator, and (because +/// Io.Threaded installs process-wide SIGIO/SIGPIPE handlers that only its +/// deinit restores) hands those handlers permanently to the host app. +fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { + const gpa = std.heap.smp_allocator; + + const threaded = try gpa.create(std.Io.Threaded); + errdefer gpa.destroy(threaded); + threaded.* = .init(gpa, .{}); + errdefer threaded.deinit(); + const io = threaded.io(); + + var config_arena: std.heap.ArenaAllocator = .init(gpa); + errdefer config_arena.deinit(); + + var opts: pardes.Options = .{ .tty_only = true }; + // Native shells opt into the user config, and every builtin in it must have + // run before the host can render a frame — so it is read here, before + // Pardes.init, exactly as src/main.zig does it. The env map is rebuilt from + // libc's environ because a library has no std.process.Init to inherit one. + if (captureEnv(config_arena.allocator())) |*env| + opts.startup_config = user_config.load(io, config_arena.allocator(), env); + + // stb_image's allocator shim, for image panes. + pardes.image.start(io, gpa); + errdefer pardes.image.stop(); + + const core = try pardes.Pardes.init(gpa, opts); + errdefer core.deinit(); + + // Shells emit OSC 133 prompt marks through these, which is what makes + // prompt hiding and click-to-move work. + writeFile(shell_bin.bash_rc_path, shell_bin.bash_rc); + writeFile(shell_bin.fish_rc_path, shell_bin.fish_rc); + // Apple's bash 3.2 prints the zsh-deprecation banner into every pane unless + // this is in the environment BEFORE bash starts — the rc file is too late. + if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); + + state = .{ + .gpa = gpa, + .threaded = threaded, + .io = io, + .core = core, + .arena = .init(gpa), + .config_arena = config_arena, + .runtime = if (runtime) |r| r.* else .{}, + }; + const st = &state.?; + + // The real grid, delivered as an EVENT and not as Options.cols/rows: the + // core defers each shell's greeting until it has seen a resize, and the + // first forkpty below takes its winsize straight off the core. + const cols = @max(1, cols_arg); + const rows = @max(1, rows_arg); + core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); + + // The initial spawns happen before any reader task exists, mirroring the + // tty shell. Note the difference in what that buys: tty.zig runs from + // main() and really is single-threaded there, whereas this is called from + // applicationDidFinishLaunching, by which point AppKit and libdispatch + // have long since spawned threads. What keeps the fork safe is the child + // 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); + for (&st.ptys, 0..) |*slot, id| if (slot.*) |*pt| startReader(st, pt, @intCast(id)); +} + +export fn pardes_deinit() void { + const st = &(state orelse return); + // 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. + for (0..pardes.MAX_PANES) |pane| reap(st, @intCast(pane)); + // Only now is the inbox quiet. Anything still queued owns gpa bytes and + // would show up as a leak rather than as the shutdown it actually is. + for (st.inbox.items.items) |msg| msg.free(st.gpa); + st.inbox.items.deinit(st.gpa); + for (st.tick_msgs.items) |msg| msg.free(st.gpa); + st.tick_msgs.deinit(st.gpa); + st.cells.deinit(st.gpa); + st.core.deinit(); + st.arena.deinit(); + st.config_arena.deinit(); + pardes.image.stop(); + st.threaded.deinit(); + st.gpa.destroy(st.threaded); + state = null; +} + +export fn pardes_should_quit() bool { + const st = &(state orelse return true); + return st.core.quit; +} + +export fn pardes_animating() bool { + const st = &(state orelse return false); + return st.core.themeAnimationActive(); +} + +/// Drain what the reader tasks collected into the core, then perform whatever +/// the core queued in response. Returns whether anything moved, so an idle +/// wakeup does not cost the host a repaint. +export fn pardes_tick() bool { + const st = &(state orelse return false); + // Cleared BEFORE the swap: a reader that pushes while this drain is running + // must be able to schedule the tick that will collect it. + st.inbox.wake_pending.store(false, .release); + st.inbox.lock(); + std.mem.swap(std.ArrayList(Msg), &st.inbox.items, &st.tick_msgs); + st.inbox.mutex.unlock(); + + var changed = st.tick_msgs.items.len > 0; + for (st.tick_msgs.items) |msg| { + defer msg.free(st.gpa); + switch (msg) { + .output => |o| { + if (st.gens[o.pane] != o.gen) continue; + st.core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); + }, + .eof => |e| { + if (st.gens[e.pane] != e.gen) continue; + // The shell is gone: join its reader (a completed future that + // is never awaited leaks its allocation), close the master and + // free the slot. Without this the slot is only ever reaped by a + // later spawn INTO it — and the core emits spawn from newPane + // alone, so a pane that becomes a file pane instead would hold + // the dead fd for the life of the app. + reap(st, e.pane); + st.core.update(.{ .eof = .{ .pane = e.pane } }); + }, + } + } + st.tick_msgs.clearRetainingCapacity(); + if (drainEffects(st, true)) changed = true; + // A live theme transition repaints on its own clock; say so, or the host + // stops ticking and the fade freezes half-applied. + if (st.core.themeAnimationActive()) changed = true; + return changed; +} + +// ---------------------------------------------------------------- events in + +export fn pardes_key(cp_arg: u32, text_ptr: ?[*]const u8, len: usize, mods: u32) void { + const st = &(state orelse return); + if (cp_arg > std.math.maxInt(u21)) return; + const text: []const u8 = if (text_ptr) |p| p[0..len] else ""; + st.core.update(.{ .key = .{ + .cp = @intCast(cp_arg), + .text = text, + .ctrl = mods & 1 != 0, + .alt = mods & 2 != 0, + .shift = mods & 4 != 0, + } }); +} + +export fn pardes_paste(text_ptr: ?[*]const u8, len: usize) void { + const st = &(state orelse return); + const text: []const u8 = if (text_ptr) |p| p[0..len] else ""; + st.core.update(.{ .paste = text }); +} + +/// Button and kind arrive as their boundary ordinals. An out-of-range value is +/// dropped rather than reaching an unchecked enum cast — same rule the browser +/// ABI keeps, for the same reason: the host is not part of this build. +export fn pardes_mouse(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, mods: u32) void { + const st = &(state orelse return); + const button: pardes.Mouse.Button = switch (button_arg) { + 0 => .left, + 1 => .middle, + 2 => .right, + 3 => .wheel_up, + 4 => .wheel_down, + 5 => .wheel_left, + 6 => .wheel_right, + 7 => .none, + else => return, + }; + const kind: pardes.Mouse.Kind = switch (kind_arg) { + 0 => .press, + 1 => .release, + 2 => .motion, + 3 => .drag, + else => return, + }; + st.core.update(.{ .mouse = .{ + .button = button, + .kind = kind, + .col = col, + .row = row, + .ctrl = mods & 1 != 0, + } }); +} + +export fn pardes_scroll(delta_rows: f32, col: u16, row: u16) void { + const st = &(state orelse return); + const ticks = takeScrollTicks(&st.scroll_lag, delta_rows); + var left = ticks; + while (left != 0) { + const down = left > 0; + left += if (down) -1 else 1; + st.core.update(.{ .mouse = .{ + .button = if (down) .wheel_down else .wheel_up, + .kind = .press, + .col = col, + .row = row, + } }); + } +} + +export fn pardes_resize(cols_arg: u16, rows_arg: u16, cell_w: u16, cell_h: u16) void { + const st = &(state orelse return); + const cols = @max(1, cols_arg); + const rows = @max(1, rows_arg); + st.core.update(.{ .resize = .{ + .cols = cols, + .rows = rows, + .cell_pixels = if (@hasField(pardes.CellPixels, "w")) + .{ .w = @max(1, cell_w), .h = @max(1, cell_h) } + else + .{}, + } }); +} + +// ---------------------------------------------------------------- frame out + +export fn pardes_frame() u32 { + const st = &(state orelse return 0); + _ = st.arena.reset(.retain_capacity); + // The three 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.cells.clearRetainingCapacity(); + st.frame_cols = 0; + st.frame_rows = 0; + const surface = st.core.render(st.arena.allocator()) catch |err| { + log.err("render failed: {t}", .{err}); + return 0; + }; + const count: usize = @as(usize, surface.cols) * surface.rows; + st.cells.resize(st.gpa, count) catch return 0; + st.frame_cols = surface.cols; + st.frame_rows = surface.rows; + for (surface.cells, st.cells.items) |cell, *out| { + out.* = .{ + .text = @splat(0), + .fg = encodeColor(cell.style.fg), + .bg = encodeColor(cell.style.bg), + .attrs = encodeAttrs(cell.style), + .len = if (cell.default) 1 else cell.len, + .flags = @intFromBool(cell.default), + }; + if (cell.default) out.text[0] = ' ' else @memcpy(out.text[0..cell.len], cell.grapheme()); + } + return @intCast(count); +} + +export fn pardes_frame_cells() ?[*]const Cell { + const st = &(state orelse return null); + return if (st.cells.items.len == 0) null else st.cells.items.ptr; +} + +export fn pardes_frame_cols() u16 { + const st = &(state orelse return 0); + return st.frame_cols; +} + +export fn pardes_frame_rows() u16 { + const st = &(state orelse return 0); + return st.frame_rows; +} + +export fn pardes_cursor_x() i32 { + const st = &(state orelse return -1); + return if (st.core.surface.cursor) |c| c.x else -1; +} + +export fn pardes_cursor_y() i32 { + const st = &(state orelse return -1); + return if (st.core.surface.cursor) |c| c.y else -1; +} + +export fn pardes_cursor_bar() bool { + const st = &(state orelse return false); + return if (st.core.surface.cursor) |c| c.bar else false; +} + +// ---------------------------------------------------------------- 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. +/// +/// ponytail: the lsp, pipe and watch effects are answered with nothing. Each +/// wants real machinery — a worker plus a snapshot of the pane's file for lsp +/// (src/tty/tty.zig:919), a job copy for pipe, and FSEvents for watch, since +/// inotify is Linux-only. The core is built to tolerate an unanswered effect: +/// the browser answers none of these either. Lift tty.zig's implementations +/// when the app is past first light. +fn drainEffects(st: *State, threads_ok: bool) bool { + 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.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). + 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); + // 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); + }, + .set_clipboard => { + const cb = st.runtime.set_clipboard orelse continue; + const y = core.yank orelse continue; + cb(st.runtime.userdata, y.ptr, y.len); + }, + .lsp, .pipe, .watch => {}, + .quit => {}, + } + } + return did; +} + +// ---------------------------------------------------------------- workers + +fn startReader(st: *State, pt: *Pty, id: u8) void { + pt.reader = st.io.concurrent(readPty, .{ st, st.io, pt.file, id, pt.gen }) catch |err| { + // No reader means the shell fills its pty buffer, blocks in write(2) + // and the pane silently freezes. Nothing recovers it, so at least say + // so — this is what PARDES_LOG exists for. + log.err("pane {d} has no reader ({t}); it will not show output", .{ id, err }); + return; + }; +} + +/// Release one pane's shell: join the reader, close the master, reap the child. +/// Order matters — cancel is what unblocks a task parked in read(2), and the fd +/// must not be closed under a live reader. Called on eof and again on a spawn +/// into the same slot, so it has to tolerate an empty slot. +fn reap(st: *State, pane: u8) void { + var pt = st.ptys[pane] orelse return; + st.ptys[pane] = null; + pt.reader.cancel(st.io) catch {}; + _ = libc.close(pt.file.handle); + // A library inside an app that runs for hours cannot leave these: the tty + // shell gets away with never reaping because the process exits seconds + // later, but here it would be one zombie per shell ever opened. NOHANG + // because the child may still be dying and the UI thread must not wait for + // it; the next reap or process exit collects whatever is left. + _ = libc.waitpid(pt.pid, null, posix.W.NOHANG); +} + +/// Drain one pty into its inbox and wake the host. The same shape as the tty +/// shell's reader, with the vaxis event queue replaced by a mutex and one +/// callback: do the blocking thing away from the loop, hand the bytes over, +/// leave the core a state machine that never waits. +fn readPty(st: *State, io: std.Io, pty: std.Io.File, id: u8, gen: u32) anyerror!void { + var read_buf: [0x10000]u8 = undefined; + var reader = pty.readerStreaming(io, &read_buf); + while (true) { + var buf: [0x10000]u8 = undefined; + var vec = [_][]u8{&buf}; + const n = reader.interface.readVec(&vec) catch break; + if (n == 0) break; + // Duped outside the lock on purpose — see Inbox. + const bytes = st.gpa.dupe(u8, buf[0..n]) catch break; + st.inbox.push(st.gpa, .{ .output = .{ .pane = id, .gen = gen, .bytes = bytes } }); + wake(st); + } + st.inbox.push(st.gpa, .{ .eof = .{ .pane = id, .gen = gen } }); + wake(st); +} + +/// Ask the host for a tick, at most once per tick. `pardes_tick` clears the +/// flag before it drains, so a push that lands mid-drain still wakes and no +/// message can be left sitting in the inbox with nobody scheduled to read it. +fn wake(st: *State) void { + const cb = st.runtime.wakeup orelse return; + if (st.inbox.wake_pending.swap(true, .acq_rel)) return; + cb(st.runtime.userdata); +} + +// ---------------------------------------------------------------- helpers + +fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } { + var master: c_int = undefined; + // Resolved BEFORE the fork, into this frame, which the child inherits: + // nothing between fork and exec may allocate, so a PATH search cannot + // happen there. + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const spawn = shell_bin.resolve(bin, &path_buf); + const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + const pid = forkpty(&master, null, null, &ws); + if (pid == 0) { + if (cwd) |c| _ = chdir(c); + _ = execv(spawn.path, &spawn.argv); + _exit(127); + } + return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid }; +} + +fn writeFd(fd: c_int, data: []const u8) void { + var off: usize = 0; + while (off < data.len) { + const n = libc.write(fd, data[off..].ptr, data.len - off); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + return; + } + // A zero-byte write makes no progress; looping on it would spin the + // main thread forever, which here means a beachball rather than the + // tty shell's hung terminal. + if (n == 0) return; + off += @intCast(n); + } +} + +fn writeFile(path: [*:0]const u8, contents: []const u8) void { + const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) return; + defer _ = libc.close(fd); + writeFd(fd, contents); +} + +/// Rebuild the process environment as a Map, because a library never sees the +/// std.process.Init that main() gets one from. Only the config-path lookup +/// reads it, and the arena owns the copies for the life of the process. +fn captureEnv(arena: std.mem.Allocator) ?std.process.Environ.Map { + var map: std.process.Environ.Map = .init(arena); + const environ = std.c.environ; + var i: usize = 0; + while (environ[i]) |entry| : (i += 1) { + const line = std.mem.span(entry); + const eq = std.mem.indexOfScalar(u8, line, '=') orelse continue; + map.put(line[0..eq], line[eq + 1 ..]) catch return null; + } + return map; +} + +fn encodeColor(color: pardes.Color) u32 { + return switch (color) { + .default => color_default, + .index => |index| color_indexed | @as(u32, index), + .rgb => |rgb| (@as(u32, rgb[0]) << 16) | (@as(u32, rgb[1]) << 8) | rgb[2], + }; +} + +fn encodeAttrs(style: pardes.CellStyle) u16 { + var attrs: u16 = 0; + attrs |= @as(u16, @intFromBool(style.bold)) << 0; + attrs |= @as(u16, @intFromBool(style.dim)) << 1; + attrs |= @as(u16, @intFromBool(style.italic)) << 2; + attrs |= @as(u16, @intFromBool(style.blink)) << 3; + attrs |= @as(u16, @intFromBool(style.reverse)) << 4; + attrs |= @as(u16, @intFromBool(style.invisible)) << 5; + attrs |= @as(u16, @intFromBool(style.strikethrough)) << 6; + attrs |= @as(u16, @intFromEnum(style.ul)) << 8; + return attrs; +} + +/// Spend accumulated sub-row travel as whole wheel notches, keeping the +/// remainder. The core has no fractional scroll — both other shells do this +/// same accumulation host-side (stepScroll in gui.zig, the drain loop in +/// web/app.mjs) — so it lives here and the Swift side stays a translator. +/// +/// The lag is clamped to one screen's worth so a nonsense delta (an inertial +/// fling reported in points, a NaN) cannot spin the emit loop. +fn takeScrollTicks(lag: *f32, delta_rows: f32) i32 { + if (!std.math.isFinite(delta_rows)) return 0; + const next = std.math.clamp(lag.* + delta_rows, -256, 256); + if (!std.math.isFinite(next)) return 0; + const whole: i32 = @intFromFloat(@trunc(next)); + lag.* = next - @as(f32, @floatFromInt(whole)); + return whole; +} + +// ---------------------------------------------------------------- ABI guard + +// The header is hand-written, so nothing but a test keeps it honest. build.zig +// translate-C's src/macos/pardes.h into this test build and every constant and +// layout below is asserted against the Zig side — ghostty's trick, and the +// cheapest possible insurance against a silent ABI skew. +/// Compare one declaration's arity and scalar widths against the header's. +/// Not a type equality — translate-C spells pointers `[*c]` and mints its own +/// struct types, so nothing here would ever match exactly. Arity and width are +/// what actually break: a parameter added on one side only (which is how the +/// Swift host first got pardes_scroll wrong), or a u16 that became a u32. +fn expectSameAbi(comptime C: type, comptime Z: type) !void { + const c_fn = @typeInfo(C).@"fn"; + const z_fn = @typeInfo(Z).@"fn"; + try std.testing.expectEqual(c_fn.params.len, z_fn.params.len); + inline for (c_fn.params, z_fn.params) |cp, zp| + try std.testing.expectEqual(@sizeOf(cp.type.?), @sizeOf(zp.type.?)); + try std.testing.expectEqual(@sizeOf(c_fn.return_type.?), @sizeOf(z_fn.return_type.?)); +} + +test "pardes.h declares every export the way it is defined" { + const c = @import("pardes.h"); + try expectSameAbi(@TypeOf(c.pardes_init), @TypeOf(pardes_init)); + try expectSameAbi(@TypeOf(c.pardes_deinit), @TypeOf(pardes_deinit)); + try expectSameAbi(@TypeOf(c.pardes_tick), @TypeOf(pardes_tick)); + try expectSameAbi(@TypeOf(c.pardes_should_quit), @TypeOf(pardes_should_quit)); + try expectSameAbi(@TypeOf(c.pardes_animating), @TypeOf(pardes_animating)); + try expectSameAbi(@TypeOf(c.pardes_key), @TypeOf(pardes_key)); + try expectSameAbi(@TypeOf(c.pardes_paste), @TypeOf(pardes_paste)); + try expectSameAbi(@TypeOf(c.pardes_mouse), @TypeOf(pardes_mouse)); + try expectSameAbi(@TypeOf(c.pardes_scroll), @TypeOf(pardes_scroll)); + try expectSameAbi(@TypeOf(c.pardes_resize), @TypeOf(pardes_resize)); + try expectSameAbi(@TypeOf(c.pardes_frame), @TypeOf(pardes_frame)); + try expectSameAbi(@TypeOf(c.pardes_frame_cells), @TypeOf(pardes_frame_cells)); + try expectSameAbi(@TypeOf(c.pardes_frame_cols), @TypeOf(pardes_frame_cols)); + try expectSameAbi(@TypeOf(c.pardes_frame_rows), @TypeOf(pardes_frame_rows)); + try expectSameAbi(@TypeOf(c.pardes_cursor_x), @TypeOf(pardes_cursor_x)); + try expectSameAbi(@TypeOf(c.pardes_cursor_y), @TypeOf(pardes_cursor_y)); + try expectSameAbi(@TypeOf(c.pardes_cursor_bar), @TypeOf(pardes_cursor_bar)); +} + +test "pardes.h matches the Zig boundary" { + const c = @import("pardes.h"); + const expectEqual = std.testing.expectEqual; + + try expectEqual(@sizeOf(c.pardes_cell_s), @sizeOf(Cell)); + try expectEqual(@offsetOf(c.pardes_cell_s, "text"), @offsetOf(Cell, "text")); + try expectEqual(@offsetOf(c.pardes_cell_s, "fg"), @offsetOf(Cell, "fg")); + try expectEqual(@offsetOf(c.pardes_cell_s, "bg"), @offsetOf(Cell, "bg")); + try expectEqual(@offsetOf(c.pardes_cell_s, "attrs"), @offsetOf(Cell, "attrs")); + try expectEqual(@offsetOf(c.pardes_cell_s, "len"), @offsetOf(Cell, "len")); + try expectEqual(@offsetOf(c.pardes_cell_s, "flags"), @offsetOf(Cell, "flags")); + try expectEqual(@sizeOf(c.pardes_runtime_s), @sizeOf(Runtime)); + + try expectEqual(@as(u32, c.PARDES_COLOR_DEFAULT), color_default); + try expectEqual(@as(u32, c.PARDES_COLOR_INDEXED), color_indexed); + try expectEqual(@as(u8, c.PARDES_CELL_DEFAULT), cell_flag_default); + + // Every key the host has a name for must be the codepoint the core reads. + try expectEqual(@as(u21, c.PARDES_KEY_ENTER), pardes.Key.enter); + try expectEqual(@as(u21, c.PARDES_KEY_ESCAPE), pardes.Key.escape); + try expectEqual(@as(u21, c.PARDES_KEY_TAB), pardes.Key.tab); + try expectEqual(@as(u21, c.PARDES_KEY_BACKSPACE), pardes.Key.backspace); + try expectEqual(@as(u21, c.PARDES_KEY_UP), pardes.Key.up); + try expectEqual(@as(u21, c.PARDES_KEY_DOWN), pardes.Key.down); + try expectEqual(@as(u21, c.PARDES_KEY_LEFT), pardes.Key.left); + try expectEqual(@as(u21, c.PARDES_KEY_RIGHT), pardes.Key.right); + try expectEqual(@as(u21, c.PARDES_KEY_HOME), pardes.Key.home); + try expectEqual(@as(u21, c.PARDES_KEY_END), pardes.Key.end); + try expectEqual(@as(u21, c.PARDES_KEY_PAGE_UP), pardes.Key.page_up); + try expectEqual(@as(u21, c.PARDES_KEY_PAGE_DOWN), pardes.Key.page_down); + try expectEqual(@as(u21, c.PARDES_KEY_DELETE), pardes.Key.delete); + + // The mouse ordinals the switch in pardes_mouse decodes are the enum's own + // declaration order; a reorder there is a silent remap of acme's buttons. + try expectEqual(c.PARDES_MOUSE_LEFT, @intFromEnum(pardes.Mouse.Button.left)); + try expectEqual(c.PARDES_MOUSE_MIDDLE, @intFromEnum(pardes.Mouse.Button.middle)); + try expectEqual(c.PARDES_MOUSE_RIGHT, @intFromEnum(pardes.Mouse.Button.right)); + try expectEqual(c.PARDES_MOUSE_WHEEL_UP, @intFromEnum(pardes.Mouse.Button.wheel_up)); + try expectEqual(c.PARDES_MOUSE_WHEEL_DOWN, @intFromEnum(pardes.Mouse.Button.wheel_down)); + try expectEqual(c.PARDES_MOUSE_WHEEL_LEFT, @intFromEnum(pardes.Mouse.Button.wheel_left)); + try expectEqual(c.PARDES_MOUSE_WHEEL_RIGHT, @intFromEnum(pardes.Mouse.Button.wheel_right)); + try expectEqual(c.PARDES_MOUSE_NONE, @intFromEnum(pardes.Mouse.Button.none)); + try expectEqual(c.PARDES_MOUSE_PRESS, @intFromEnum(pardes.Mouse.Kind.press)); + try expectEqual(c.PARDES_MOUSE_RELEASE, @intFromEnum(pardes.Mouse.Kind.release)); + try expectEqual(c.PARDES_MOUSE_MOTION, @intFromEnum(pardes.Mouse.Kind.motion)); + try expectEqual(c.PARDES_MOUSE_DRAG, @intFromEnum(pardes.Mouse.Kind.drag)); + + // The attribute bits the host decodes, against the encoder that writes them. + try expectEqual(@as(u16, c.PARDES_ATTR_BOLD), encodeAttrs(.{ .bold = true })); + try expectEqual(@as(u16, c.PARDES_ATTR_DIM), encodeAttrs(.{ .dim = true })); + try expectEqual(@as(u16, c.PARDES_ATTR_ITALIC), encodeAttrs(.{ .italic = true })); + try expectEqual(@as(u16, c.PARDES_ATTR_BLINK), encodeAttrs(.{ .blink = true })); + try expectEqual(@as(u16, c.PARDES_ATTR_REVERSE), encodeAttrs(.{ .reverse = true })); + try expectEqual(@as(u16, c.PARDES_ATTR_INVISIBLE), encodeAttrs(.{ .invisible = true })); + try expectEqual(@as(u16, c.PARDES_ATTR_STRIKETHROUGH), encodeAttrs(.{ .strikethrough = true })); + try expectEqual( + @as(u16, c.PARDES_UL_CURLY) << c.PARDES_ATTR_UL_SHIFT, + encodeAttrs(.{ .ul = .curly }), + ); +} + +test "colors encode to the three tags the host decodes" { + const expectEqual = std.testing.expectEqual; + try expectEqual(@as(u32, 0x01000000), encodeColor(.default)); + try expectEqual(@as(u32, 0x02000021), encodeColor(.{ .index = 33 })); + try expectEqual(@as(u32, 0x00112233), encodeColor(.{ .rgb = .{ 0x11, 0x22, 0x33 } })); +} + +test "sub-row scroll spends whole notches and keeps the remainder" { + const expectEqual = std.testing.expectEqual; + var lag: f32 = 0; + // Four quarter-row flicks are one row, and not before the fourth. + try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25)); + try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25)); + try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25)); + try expectEqual(@as(i32, 1), takeScrollTicks(&lag, 0.25)); + try expectEqual(@as(f32, 0), lag); + + // Direction reverses without the accumulated travel leaking across it. + try expectEqual(@as(i32, -2), takeScrollTicks(&lag, -2.5)); + try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25)); + + // Garbage moves nothing and leaves the accumulator usable; a fling far + // past the clamp spends at most one screen and does not spin the caller. + lag = 0; + try expectEqual(@as(i32, 0), takeScrollTicks(&lag, std.math.nan(f32))); + try expectEqual(@as(i32, 0), takeScrollTicks(&lag, std.math.inf(f32))); + try expectEqual(@as(f32, 0), lag); + try expectEqual(@as(i32, 256), takeScrollTicks(&lag, 1e9)); +} -- cgit v1.3