summaryrefslogtreecommitdiff
path: root/src/macos.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/macos.zig')
-rw-r--r--src/macos.zig882
1 files changed, 882 insertions, 0 deletions
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));
+}