diff options
Diffstat (limited to 'src/macos.zig')
| -rw-r--r-- | src/macos.zig | 882 |
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)); +} |
