//! 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 nested = @import("nested.zig"); const fonts = if (pardes.font_picker) @import("fonts.zig") else struct { pub const want: ?[]const u8 = null; }; 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 }, /// One `Look ` line from a pardes launched inside this one. Arrives /// on the listener thread; runs, like everything else, on the main one. command: []u8, fn free(m: Msg, gpa: std.mem.Allocator) void { switch (m) { .output => |o| gpa.free(o.bytes), .eof => {}, .command => |c| gpa.free(c), } } }; const inbox_capacity = 512; const MessageBatch = struct { items: [inbox_capacity]Msg = undefined, len: usize = 0, fn slice(batch: *MessageBatch) []Msg { return batch.items[0..batch.len]; } }; const Inbox = struct { mutex: std.atomic.Mutex = .unlocked, items: [inbox_capacity]Msg = undefined, head: usize = 0, len: usize = 0, closed: bool = false, /// Set when a wakeup has been delivered and not yet answered by a tick. wake_pending: std.atomic.Value(bool) = .init(false), fn lock(q: *Inbox) void { // AppKit's main thread runs at a higher QoS than reader tasks, so yield // periodically rather than donating a full core to a preempted reader. var spins: u8 = 0; while (!q.mutex.tryLock()) { spins +%= 1; if (spins == 0) std.Thread.yield() catch {} else std.atomic.spinLoopHint(); } } fn removeAt(q: *Inbox, offset: usize) Msg { const removed = q.items[(q.head + offset) % q.items.len]; var i = offset; while (i + 1 < q.len) : (i += 1) q.items[(q.head + i) % q.items.len] = q.items[(q.head + i + 1) % q.items.len]; q.len -= 1; return removed; } /// Pty output is lossy under sustained backpressure. EOF is structural, and /// so is a nested `Look`: one is a reader that must be reaped, the other is /// a launch that already exited believing it was delivered. Admit both by /// evicting queued output. Every switch below is exhaustive on purpose — a /// new message kind has to say which of the two it is. fn push(q: *Inbox, gpa: std.mem.Allocator, m: Msg) void { q.lock(); defer q.mutex.unlock(); if (q.closed) { m.free(gpa); return; } if (q.len == q.items.len) { const lossy = switch (m) { .output => true, .eof, .command => false, }; if (lossy) { m.free(gpa); return; } var offset: usize = 0; while (offset < q.len) : (offset += 1) if (switch (q.items[(q.head + offset) % q.items.len]) { .output => true, .eof, .command => false, }) break; if (offset == q.len) return; q.removeAt(offset).free(gpa); } q.items[(q.head + q.len) % q.items.len] = m; q.len += 1; } fn take(q: *Inbox) MessageBatch { q.lock(); defer q.mutex.unlock(); var batch: MessageBatch = .{}; while (q.len > 0) { batch.items[batch.len] = q.items[q.head]; batch.len += 1; q.head = (q.head + 1) % q.items.len; q.len -= 1; } q.head = 0; return batch; } fn close(q: *Inbox, gpa: std.mem.Allocator) void { q.lock(); defer q.mutex.unlock(); q.closed = true; while (q.len > 0) { q.items[q.head].free(gpa); q.head = (q.head + 1) % q.items.len; q.len -= 1; } q.head = 0; } }; const State = struct { gpa: std.mem.Allocator, threaded: *std.Io.Threaded, io: std.Io, core: *pardes.Pardes, arena: std.heap.ArenaAllocator, runtime: Runtime, cells: []Cell = &.{}, frame_len: usize = 0, /// 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-cell wheel distance the core has not been told about yet, one /// accumulator per axis. The core moves a whole row or column at a time, /// so fractional trackpad travel banks here and is spent as wheel presses /// — see pardes_scroll. Separate axes because a diagonal drift must not /// let one direction's residue push the other over a notch. scroll_lag: f32 = 0, scroll_lag_x: f32 = 0, /// Degrees of trackpad rotation not yet spent as a search step — the same /// accumulate-and-keep-the-remainder shape as scroll_lag, see pardes_rotate. rotate_lag: f32 = 0, /// Panes whose shell has produced output since we last read its cwd. /// /// The cwd is wanted for pane tags and for resolving a relative Look, and /// asking libproc costs a syscall per pane. Polling it on a clock spends /// that forever to notice something that only ever changes when the shell /// runs a command — and a shell that ran a command always writes at least /// its next prompt. So the read is owed to output, not to time: mark here /// on the way past and settle it once at the end of the drain, however /// many chunks that burst arrived in. cwd_stale: [pardes.MAX_PANES]bool = @splat(false), /// The socket a pardes launched inside this app connects to (nested.zig), /// or -1 when it could not be bound and nested launches open their own /// window as they always did. sock_fd: c_int = -1, /// 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 allocs = pardes.allocators.init(gpa); errdefer pardes.allocators.deinit(); 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, .image_allocator = allocs.image, .pdf_allocator = allocs.pdf, .tree_sitter_allocator = allocs.tree_sitter, }; // 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); pardes.image.start(io, allocs.image); errdefer pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); errdefer if (comptime pardes.pdf_enabled) pardes.pdf.stop(); pardes.syntax.start(allocs.tree_sitter); errdefer pardes.syntax.stop(); const core = try pardes.Pardes.init(allocs.pardes, 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(allocs.frame), .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)); // Last, because it is the one thing here that publishes this process to // the outside: nothing may connect before the core can answer. The shells // above are already forked, which is why the listener's fd is CLOEXEC — // an orphaned bash holding it would keep the socket bound after we quit. st.sock_fd = nested.listen(); if (st.sock_fd >= 0) { const thread = std.Thread.spawn(.{}, lookServer, .{st}) catch |err| { // Bound but unattended would be worse than never bound: every // nested launch would connect, be believed, and vanish. log.warn("nested Look server did not start ({t})", .{err}); nested.unlisten(st.sock_fd); st.sock_fd = -1; return; }; thread.detach(); } } /// Accept `Look ` lines from pardes instances launched inside this app /// and post them where the main thread will run them. /// /// A detached thread around a call that never returns, exactly like the tty /// backend's: close(2) does not release a thread parked in accept(2), so this /// dies with the process rather than with the socket. The window that leaves /// is one connection accepted between the last tick and process exit posting /// into an inbox nobody drains — the same bound the pty readers have, and a /// self-pipe to close it would be more machinery than the window is worth. fn lookServer(st: *State) void { var buf: [nested.max_line]u8 = undefined; while (nested.acceptLine(st.sock_fd, &buf)) |line| { const owned = st.gpa.dupe(u8, line) catch continue; st.inbox.push(st.gpa, .{ .command = owned }); wake(st); } } export fn pardes_deinit() void { const st = &(state orelse return); // Before anything else: it is the only fd another process can reach us // through, and unlinking the file is what stops the next launch from // connecting to a session that is halfway through tearing itself down. // The thread parked in accept(2) is not released by this and dies with // the process, which is what its detach() already said. nested.unlisten(st.sock_fd); st.sock_fd = -1; // 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. st.inbox.close(st.gpa); if (st.cells.len > 0) st.gpa.free(st.cells); st.arena.deinit(); st.core.deinit(); pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.stop(); pardes.syntax.stop(); st.config_arena.deinit(); st.threaded.deinit(); st.gpa.destroy(st.threaded); pardes.allocators.deinit(); 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(); } /// Re-read the cwd of every shell that just spoke, and only those. /// /// A pane's tag shows this and a relative `Look` resolves against it, so it has /// to follow the shell around rather than stay at the directory the pane was /// spawned in. The tty and SDL hosts poll all of them every frame; here the /// drain has just said exactly which shells produced bytes, and nothing else /// can have changed one — a `cd` is a command, and a shell that ran a command /// writes at least its next prompt. So an idle session costs nothing at all, /// and a busy one costs one libproc call per pane per burst. fn refreshCwds(st: *State) void { for (&st.cwd_stale, 0..) |*stale, id| { if (!stale.*) continue; stale.* = false; const pt = st.ptys[id] orelse continue; var buf: [1024]u8 = undefined; if (look.shellCwd(pt.pid, &buf)) |wd| st.core.setCwd(id, wd); } } /// Drain what the reader tasks collected into the core, then perform whatever /// the core queued in response. Returns whether this tick did any IO. /// /// NOT a repaint signal, however tempting: the core changes the grid on its own /// for a cursor move, a selection, a mode change and a scroll, none of which /// queue an effect or read a pty, so all four return false here. The macOS host /// learned that the expensive way — see the comment on pump() in /// src/macos/Sources/AppDelegate.swift. export fn pardes_tick() bool { const st = &(state orelse return false); // Cleared before the drain: a reader that pushes during this tick must be // able to schedule the next one. st.inbox.wake_pending.store(false, .release); var batch = st.inbox.take(); var changed = batch.len > 0; for (batch.slice()) |msg| { defer msg.free(st.gpa); switch (msg) { .output => |o| { if (st.gens[o.pane] != o.gen) continue; st.cwd_stale[o.pane] = true; 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. reap(st, e.pane); st.core.update(.{ .eof = .{ .pane = e.pane } }); }, // Already filtered down to `Look ` by the accept side — this // socket may open things and that is all it may do. .command => |c| st.core.update(.{ .command = c }), } } refreshCwds(st); 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, delta_cols: f32, col: u16, row: u16) void { const st = &(state orelse return); var down_left = takeScrollTicks(&st.scroll_lag, delta_rows); while (down_left != 0) { const down = down_left > 0; down_left += if (down) -1 else 1; st.core.update(.{ .mouse = .{ .button = if (down) .wheel_down else .wheel_up, .kind = .press, .col = col, .row = row, } }); } // Horizontal after vertical, and through the same quantizer: the core's // own drift guard (config.wheelTick) is what decides whether a sideways // wobble during a vertical flick counts, so the shell must not second-guess // it by filtering here. var right_left = takeScrollTicks(&st.scroll_lag_x, delta_cols); while (right_left != 0) { const right = right_left > 0; right_left += if (right) -1 else 1; st.core.update(.{ .mouse = .{ .button = if (right) .wheel_right else .wheel_left, .kind = .press, .col = col, .row = row, } }); } } /// Spend a trackpad rotation as search steps. AppKit reports degrees since the /// last event, counterclockwise positive; the core has no rotation, so the /// dial is quantized into the keys a hand would otherwise press — clockwise is /// `n` (forward through the matches), counterclockwise `N`. export fn pardes_rotate(degrees: f32) void { const st = &(state orelse return); // A gesture beginning re-zeros the dial: leftover travel from the last // twist must not make the first degree of this one jump a match. if (degrees == 0) { st.rotate_lag = 0; return; } var left = takeRotationNotches(&st.rotate_lag, degrees); while (left != 0) { const back = left > 0; // counterclockwise left += if (back) -1 else 1; st.core.update(.{ .key = .{ .cp = if (back) 'N' else 'n' } }); } } export fn pardes_command(text_ptr: ?[*]const u8, len: usize) void { const st = &(state orelse return); const text: []const u8 = if (text_ptr) |p| p[0..len] else ""; if (text.len == 0) return; st.core.update(.{ .command = text }); } 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.frame_len = 0; 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; if (count != st.cells.len) { if (count == 0) { if (st.cells.len > 0) st.gpa.free(st.cells); st.cells = &.{}; } else { const resized = if (st.cells.len == 0) st.gpa.alloc(Cell, count) else st.gpa.realloc(st.cells, count); st.cells = resized catch return 0; } } st.frame_len = count; st.frame_cols = surface.cols; st.frame_rows = surface.rows; for (surface.cells, st.cells[0..count]) |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.frame_len == 0) null else st.cells.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; } /// The acme verb the core last performed, and clears it. Ordinals, not the /// enum: the host is not part of this build, so the boundary speaks integers /// and the ABI guard asserts they are the ones the header names. export fn pardes_take_haptic() c_int { const st = &(state orelse return 0); return switch (st.core.takeHaptic()) { .none => 0, .exec => 1, .look => 2, }; } /// The file the `Font` builtin asked for, and clears it — the same take-once /// shape as the haptic above, and the same one the SDL shell uses on this /// exact variable. /// /// A copy rather than the borrowed slice: `fonts.want` is a length and no /// terminator, and C wants a string. One static buffer because there is one /// core and the header promises the value only until the next call. var font_path_z: [4096:0]u8 = undefined; export fn pardes_font_take() ?[*:0]const u8 { _ = state orelse return null; if (comptime !pardes.font_picker) return null; const want = fonts.want orelse return null; fonts.want = null; if (want.len >= font_path_z.len) return null; @memcpy(font_path_z[0..want.len], want); font_path_z[want.len] = 0; return &font_path_z; } // ---------------------------------------------------------------- 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 do no work here. 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. Lift tty.zig's implementations when the app is past /// first light. Pipe and watch may simply be dropped; lsp may NOT, see below. 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); }, // An empty answer, immediately: the honest reply from a shell with // no worker, and the only safe one. Tab after a `.` DIVERTS to the // backend instead of indenting and indents late, when the answer // comes back empty (lspResponse); drop the effect and lsp_wait // stays armed, the retroactive indent never fires, and every // dot-Tab for the rest of the session does nothing at all. .lsp => |q| core.update(.{ .lsp_resp = .{ .id = q.id, .rows = "" } }), .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; } /// One search step per this many degrees of twist. A trackpad rotation runs /// tens of degrees before it feels deliberate, and every notch here is a jump /// to another match — coarse on purpose, so a thumb resettling cannot walk the /// cursor across the file. const rotation_notch_degrees: f32 = 20; /// Spend accumulated rotation as whole search steps, keeping the remainder. /// Same contract as takeScrollTicks, including the clamp: an absurd delta /// spends a bounded number of notches instead of spinning the emit loop. fn takeRotationNotches(lag: *f32, degrees: f32) i32 { if (!std.math.isFinite(degrees)) return 0; const limit = rotation_notch_degrees * 64; const next = std.math.clamp(lag.* + degrees, -limit, limit); if (!std.math.isFinite(next)) return 0; const whole: i32 = @intFromFloat(@trunc(next / rotation_notch_degrees)); lag.* = next - @as(f32, @floatFromInt(whole)) * rotation_notch_degrees; 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_rotate), @TypeOf(pardes_rotate)); try expectSameAbi(@TypeOf(c.pardes_command), @TypeOf(pardes_command)); 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)); try expectSameAbi(@TypeOf(c.pardes_take_haptic), @TypeOf(pardes_take_haptic)); try expectSameAbi(@TypeOf(c.pardes_font_take), @TypeOf(pardes_font_take)); } 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 haptic ordinals pardes_take_haptic returns, against the header's // names and the core's enum. Three places, checked as one. try expectEqual(c.PARDES_HAPTIC_NONE, @intFromEnum(pardes.Haptic.none)); try expectEqual(c.PARDES_HAPTIC_EXEC, @intFromEnum(pardes.Haptic.exec)); try expectEqual(c.PARDES_HAPTIC_LOOK, @intFromEnum(pardes.Haptic.look)); // 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)); } test "trackpad rotation spends whole search steps and keeps the remainder" { const expectEqual = std.testing.expectEqual; var lag: f32 = 0; // A twist under one notch moves nothing; crossing it moves exactly one, // and the overshoot is credited to the next. try expectEqual(@as(i32, 0), takeRotationNotches(&lag, 15)); try expectEqual(@as(i32, 1), takeRotationNotches(&lag, 10)); try expectEqual(@as(f32, 5), lag); // Reversing spends the residue first, so a twist back is not amplified by // travel the other direction already banked. try expectEqual(@as(i32, -1), takeRotationNotches(&lag, -25)); try expectEqual(@as(f32, 0), lag); // One deliberate half-turn is several matches, not a hundred. lag = 0; try expectEqual(@as(i32, 9), takeRotationNotches(&lag, 180)); // Garbage moves nothing and leaves the dial usable; an absurd delta is // clamped rather than spinning the emit loop. lag = 0; try expectEqual(@as(i32, 0), takeRotationNotches(&lag, std.math.nan(f32))); try expectEqual(@as(i32, 0), takeRotationNotches(&lag, -std.math.inf(f32))); try expectEqual(@as(f32, 0), lag); try expectEqual(@as(i32, 64), takeRotationNotches(&lag, 1e9)); }