diff options
Diffstat (limited to 'src/macos.zig')
| -rw-r--r-- | src/macos.zig | 1598 |
1 files changed, 609 insertions, 989 deletions
diff --git a/src/macos.zig b/src/macos.zig index ddab78e9..36c70f86 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -1,23 +1,5 @@ -//! 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 filesystem = @import("fs.zig"); +const ninep_io = @import("9p_io.zig"); const std = @import("std"); const builtin = @import("builtin"); @@ -25,29 +7,16 @@ const posix = std.posix; const libc = std.c; const pardes = @import("pardes.zig"); const look = @import("look.zig"); -const shell_bin = @import("shell_bin.zig"); -const message = @import("message.zig"); -const nested = @import("nested.zig"); -const panel_animation = @import("panel_animation.zig"); +const message = pardes.Pardes.Message; +const layout = @import("layout.zig"); const file_watch = @import("file_watch.zig"); -/// The geometry types the pixel-attachment ABI carries. Behind the same -/// comptime gate the placements themselves are: a build without MuPDF emits no -/// attachments, so nothing here is analysed. const image = if (pardes.pdf_enabled) @import("image.zig") else struct {}; -const user_config = @import("user_config.zig"); const host_io = @import("host_io.zig"); const fonts = @import("fonts.zig"); // the shared fallback preference order -const lsp_host = @import("lsp_host.zig"); // the shared snapshot + worker body -const host_api = @import("host.zig"); // LspRequest and the vtable's own types const tracy = @import("tracy.zig"); // no-op unless -Dtracy names a checkout const selection_pipe = @import("selection_pipe.zig"); // Job, runJob and Tasks const crash = @import("crash.zig"); -/// This file is the ROOT of the macOS build (build.zig: the AppKit shell is a -/// library whose host owns main()), so `std.builtin.panic` resolves here and -/// not in src/main.zig — a handler written only there would never run in the -/// app, which is the shell with the least useful stderr of the four. No -/// terminal to restore either, which is the rest of what main.zig's does. pub const panic = std.debug.FullPanic(struct { fn call(msg: []const u8, ret_addr: ?usize) noreturn { crash.record(msg); @@ -57,9 +26,6 @@ pub const panic = std.debug.FullPanic(struct { extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; -/// Implemented by FileWatcher.swift in the app and e2e host. Zig-only unit -/// tests have no AppKit runloop and compile this call away; the shipped static -/// library leaves the symbol for its Swift executable to satisfy directly. extern "c" fn pardes_host_watch_file( pane: u8, generation: u32, @@ -72,14 +38,8 @@ fn hostWatchFile(pane: u8, generation: u32, path: ?[*]const u8, path_len: usize) pardes_host_watch_file(pane, generation, path, path_len); } -// 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( @@ -94,15 +54,6 @@ fn logFn( 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, @@ -112,40 +63,21 @@ pub const Cell = extern struct { flags: u8, }; -/// Sync with: pardes_scene_s. Persistent full-window effects share one host -/// postprocess, so one plain snapshot carries both its switches and clock. pub const Scene = extern struct { flags: u32 = 0, time_seconds: f32 = 0, frame: u32 = 0, }; -/// Sync with pardes_panel_{box,track}_s. The core's backend-neutral Track is -/// already an extern POD record, so the native boundary can publish it without -/// translating the easing vocabulary into a second representation. -pub const PanelBox = panel_animation.Box; -pub const PanelTrack = panel_animation.Track; +pub const PanelBox = layout.Box; +pub const PanelTrack = layout.Track; -/// Sync with: pardes_image_s. One rasterized attachment — a PDF page, or an -/// image pane's pixels — and where on the grid it goes. -/// -/// Geometry travels in PHYSICAL PIXELS, because that is the space the core -/// already computed it in (pardes_resize hands it the physical cell). `cell_x` -/// and `cell_y` are the pane BODY's origin in cells and the only thing the -/// host has to multiply out; `dst` is relative to that origin, and `src` is -/// the crop of the raster to take. The core has already clipped both to the -/// viewport, which is what lets a host draw a continuous-scroll page without -/// inventing an overflow clip of its own. pub const Image = extern struct { - /// pane lifetime, page and raster generation: together the cache key. A - /// host keeps its decoded texture while all three hold still, and `fit`, - /// panning and scrolling deliberately do not move them. serial: u32, page: u32, revision: u32, cell_x: u16, cell_y: u16, - /// the body this attachment may not paint outside of, in cells cell_w: u16, cell_h: u16, dst_x: u32, @@ -156,18 +88,12 @@ pub const Image = extern struct { src_y: u32, src_w: u32, src_h: u32, - /// subpixel vertical displacement a proportional wheel kept offset_y: f32, iw: u32, ih: u32, - /// iw * ih * 4 bytes, RGBA8. Borrowed until the next pardes_frame. rgba: [*]const u8, }; -/// Sync with: pardes_runtime_s. Three 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. All 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, @@ -182,30 +108,14 @@ const cell_flag_tagline: u8 = 2; const scene_flag_crt: u32 = 1 << 0; const scene_flag_ripple: u32 = 1 << 1; const scene_flag_glitch: u32 = 1 << 2; -/// The nominal display cadence the SHADER's `frame` field is expressed in. It -/// is a unit of that field and nothing else now: the animation clock below is -/// driven by measured elapsed time, not by counting callbacks. const scene_frame_hz: u32 = 60; -/// The scene clock wraps here so `time_seconds` never grows large enough for an -/// f32 to lose sub-millisecond resolution. 4096 seconds, the same span the old -/// 4096-frames-per-hz counter covered. const scene_wrap_ns: u64 = 4096 * std.time.ns_per_s; -/// The most elapsed time one tick may cash in. A window that was occluded, a -/// laptop that slept or a debugger breakpoint all produce an enormous dt, and -/// spending it would fast-forward an animation instead of resuming it. -const max_tick_catch_up_ns: u64 = 4 * pardes.animation.frame_ns; -/// FileWatcher.swift keys sources by an opaque u8. Pane ids occupy 0..15; -/// the next value is the one process-global ThemeFile source. +const max_tick_catch_up_ns: u64 = 4 * pardes.layout.Animation.frame_ns; const theme_watch_pane: u8 = @intCast(pardes.MAX_PANES); const watch_slot_count = pardes.MAX_PANES + 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, @@ -213,18 +123,11 @@ const Pty = struct { reader: std.Io.Future(anyerror!void), }; -/// Main-thread ownership for the host's per-pane vnode sources. A path is -/// copied rather than borrowed from Pane: a queued callback may outlive the -/// effect which replaced that pane slot, and exact path equality is the final -/// guard before any bytes reach the core. const WatchedFile = struct { path: []u8, serial: u32, generation_on_disk: file_watch.Generation, generation: u32, - /// One self-scheduled reconciliation after a transient read/reopen race. - /// A real host edge replenishes it; a malformed stable file therefore - /// tries twice and then sleeps rather than becoming an idle busy loop. retries_left: u8 = 1, }; @@ -247,9 +150,6 @@ const FileWatches = struct { serial: u32, generation_on_disk: file_watch.Generation, ) !u32 { - // Allocate first. If memory is tight, the caller can explicitly stop - // the old source; silently retaining a watch for a reused pane would be - // worse than having no watch at all. const owned = try gpa.dupe(u8, path); if (watches.entries[pane]) |old| gpa.free(old.path); const generation = watches.nextGeneration(pane); @@ -270,10 +170,6 @@ const FileWatches = struct { return watches.nextGeneration(pane); } - /// Coalesce any number of vnode events into one main-thread re-read. - /// The Swift side already debounces a burst; this bit is the second, cheap - /// edge which prevents two queued callbacks from applying one snapshot - /// twice. A stale generation can never dirty a reused pane slot. fn notify(watches: *FileWatches, pane: u8, generation: u32) bool { const watched = if (watches.entries[pane]) |*entry| entry else return false; if (watched.generation != generation) return false; @@ -350,8 +246,6 @@ test "mac file watch generations own paths, coalesce, and reject stale callbacks try std.testing.expect(watches.retry(3, second)); try std.testing.expect(!watches.retry(3, second)); try std.testing.expect(watches.takeDirty(3)); - // A stable malformed file cannot self-wake forever, but a later real vnode - // edge replenishes exactly one retry for the new external transaction. try std.testing.expect(watches.notify(3, second)); try std.testing.expectEqual(@as(u8, 1), watches.entries[3].?.retries_left); try std.testing.expect(watches.takeDirty(3)); @@ -373,10 +267,87 @@ test "mac file watch generations own paths, coalesce, and reject stale callbacks const file_watcher_swift = @embedFile("macos/Sources/FileWatcher.swift"); +test "mac queued results never cancel a newer LSP request" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + var st: State = .{ + .gpa = gpa, + .threaded = undefined, + .io = std.testing.io, + .core = core, + .runtime = .{}, + .config_arena = .init(gpa), + .prompt_rcs = .{}, + }; + defer st.config_arena.deinit(); + defer st.inbox.close(gpa, std.testing.io); + core.lspRequest(0, .status, ""); + const old_id = core.lsp_wait.?.id; + core.lspRequest(0, .status, ""); + const current_id = core.lsp_wait.?.id; + st.lsp_task = .{ .id = current_id, .future = .{ .any_future = null, .result = {} } }; + st.inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = old_id, .rows = try gpa.dupe(u8, "stale result\n") } }); + try std.testing.expect(drainInbox(&st)); + try std.testing.expect(st.lsp_task != null); + try std.testing.expectEqual(current_id, st.lsp_task.?.id); + try std.testing.expectEqual(current_id, core.lsp_wait.?.id); + st.inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = current_id, .rows = try gpa.dupe(u8, "") } }); + try std.testing.expect(drainInbox(&st)); + try std.testing.expect(st.lsp_task == null); + try std.testing.expect(core.lsp_wait == null); +} + +test "mac worker setup failures finish matching LSP and pipe requests" { + const gpa = std.testing.allocator; + var failing_vtable = std.testing.io.vtable.*; + failing_vtable.concurrent = std.Io.failingConcurrent; + const failing_io: std.Io = .{ .userdata = std.testing.io.userdata, .vtable = &failing_vtable }; + for (0..2) |failure| { + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + const pane = try core.setTestFile("abc"); + var failing = std.testing.FailingAllocator.init(gpa, .{ + .fail_index = if (failure == 0) 0 else std.math.maxInt(usize), + }); + var st: State = .{ + .gpa = failing.allocator(), + .threaded = undefined, + .io = failing_io, + .core = core, + .runtime = .{}, + .config_arena = .init(gpa), + .prompt_rcs = .{}, + }; + defer st.config_arena.deinit(); + defer st.inbox.close(gpa, std.testing.io); + core.lspRequest(core.active, .status, ""); + const req: host_io.Lsp.Request = .{ + .id = core.lsp_wait.?.id, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = "", + }; + lspRequest(&st, req); + try std.testing.expect(core.lsp_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + core.update(.{ .key = .{ .cp = '|' } }); + core.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); + core.update(.{ .key = .{ .cp = pardes.Key.enter } }); + const pipe_id = core.pipe_wait.?.id; + pipeRequest(&st, pipe_id); + try std.testing.expect(core.pipe_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + try std.testing.expect(st.lsp_task == null); + try std.testing.expectEqual(@as(usize, 0), st.pipe_tasks.len); + } +} + test "mac host watcher covers file and directory vnode events, debounce, and generation callback" { - // Linux cannot compile AppKit/Dispatch Swift. Keep the critical architecture - // check reachable there: atomic saves need the parent, in-place writes need - // a rearmed file source, and all mutation returns through the generation ABI. try std.testing.expect(std.mem.indexOf( u8, file_watcher_swift, @@ -399,33 +370,18 @@ test "mac host watcher covers file and directory vnode events, debounce, and gen ) != null); } -/// 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 <path>` line from a pardes launched inside this one. Arrives - /// on the listener thread; runs, like everything else, on the main one. - command: []u8, - /// A language query finished on a worker; `rows` are gpa-owned. NOT lossy: - /// the core is holding a request id open for exactly this, and dropping it - /// leaves `lsp_wait` armed and every later query dead. - lsp_done: struct { id: u32, rows: []u8 }, - /// Unsolicited server state — "rust-analyzer indexing 45%" — for the - /// transient message row. Periodic news, so it IS lossy: a dropped line is - /// repriced by the next one. + lsp_done: struct { id: u32, rows: ?[]u8 }, lsp_status: []u8, - /// A `|` filter finished on a worker. NOT lossy for the same reason - /// `lsp_done` is not: the core is holding a request id open for it. pipe: selection_pipe.Response, fn free(m: Msg, gpa: std.mem.Allocator) void { switch (m) { .output => |o| gpa.free(o.bytes), .eof => {}, - .command => |c| gpa.free(c), - .lsp_done => |d| gpa.free(d.rows), + .lsp_done => |d| if (d.rows) |rows| gpa.free(rows), .lsp_status => |t| gpa.free(t), .pipe => |r| { var response = r; @@ -435,8 +391,8 @@ const Msg = union(enum) { } }; - const inbox_capacity = 512; +const inbox_output_limit = inbox_capacity - pardes.MAX_PANES - selection_pipe.Tasks.capacity - 2; const MessageBatch = struct { items: [inbox_capacity]Msg = undefined, @@ -448,202 +404,272 @@ const MessageBatch = struct { }; const Inbox = struct { - mutex: std.atomic.Mutex = .unlocked, + mutex: std.Io.Mutex = .init, + space: std.Io.Condition = .init, 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]; + const removed = q.items[offset]; + std.mem.copyForwards(Msg, q.items[offset .. q.len - 1], q.items[offset + 1 .. q.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(); + fn pushOutput(q: *Inbox, gpa: std.mem.Allocator, io: std.Io, output: @FieldType(Msg, "output")) std.Io.Cancelable!void { + errdefer gpa.free(output.bytes); + q.mutex.lockUncancelable(io); + defer q.mutex.unlock(io); + while (q.len >= inbox_output_limit and !q.closed) try q.space.wait(io, &q.mutex); + if (q.closed) return error.Canceled; + q.items[q.len] = .{ .output = output }; + q.len += 1; + } + + fn push(q: *Inbox, gpa: std.mem.Allocator, io: std.Io, m: Msg) void { + std.debug.assert(m != .output); + q.mutex.lockUncancelable(io); + defer q.mutex.unlock(io); if (q.closed) { m.free(gpa); return; } - if (q.len == q.items.len) { - const lossy = switch (m) { - .output, .lsp_status => true, - .eof, .command, .lsp_done, .pipe => false, - }; - if (lossy) { - m.free(gpa); - return; - } + if (m != .pipe) { var offset: usize = 0; - while (offset < q.len) : (offset += 1) - if (switch (q.items[(q.head + offset) % q.items.len]) { - .output, .lsp_status => true, - .eof, .command, .lsp_done, .pipe => false, - }) break; - if (offset == q.len) return; - q.removeAt(offset).free(gpa); + while (offset < q.len) : (offset += 1) { + const old = q.items[offset]; + const replaced = switch (m) { + .eof => |end| old == .eof and old.eof.pane == end.pane, + .lsp_done => old == .lsp_done, + .lsp_status => old == .lsp_status, + .output, .pipe => unreachable, + }; + if (replaced) { + q.removeAt(offset).free(gpa); + break; + } + } } - q.items[(q.head + q.len) % q.items.len] = m; + std.debug.assert(q.len < q.items.len); + q.items[q.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; + fn take(q: *Inbox, io: std.Io) MessageBatch { + q.mutex.lockUncancelable(io); + defer q.mutex.unlock(io); + var batch: MessageBatch = .{ .len = q.len }; + @memcpy(batch.items[0..q.len], q.items[0..q.len]); + q.len = 0; + q.space.broadcast(io); return batch; } - fn close(q: *Inbox, gpa: std.mem.Allocator) void { - q.lock(); - defer q.mutex.unlock(); + fn close(q: *Inbox, gpa: std.mem.Allocator, io: std.Io) void { + q.mutex.lockUncancelable(io); + defer q.mutex.unlock(io); 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; + for (q.items[0..q.len]) |msg| msg.free(gpa); + q.len = 0; + q.space.broadcast(io); } }; +test "mac inbox coalesces completions at the tail without reordering terminal output" { + const gpa = std.testing.allocator; + var inbox: Inbox = .{}; + defer inbox.close(gpa, std.testing.io); + inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = std.math.maxInt(u32), .rows = try gpa.dupe(u8, "old result") } }); + inbox.push(gpa, std.testing.io, .{ .lsp_status = try gpa.dupe(u8, "old status") }); + try inbox.pushOutput(gpa, std.testing.io, .{ .pane = 0, .gen = std.math.maxInt(u32), .bytes = try gpa.dupe(u8, "old output") }); + inbox.push(gpa, std.testing.io, .{ .eof = .{ .pane = 0, .gen = std.math.maxInt(u32) } }); + try inbox.pushOutput(gpa, std.testing.io, .{ .pane = 0, .gen = 0, .bytes = try gpa.dupe(u8, "new output") }); + inbox.push(gpa, std.testing.io, .{ .eof = .{ .pane = 0, .gen = 0 } }); + inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = 0, .rows = try gpa.dupe(u8, "new result") } }); + inbox.push(gpa, std.testing.io, .{ .lsp_status = try gpa.dupe(u8, "new status") }); + var batch = inbox.take(std.testing.io); + defer for (batch.slice()) |msg| msg.free(gpa); + try std.testing.expectEqual(@as(usize, 5), batch.len); + try std.testing.expectEqualStrings("old output", batch.items[0].output.bytes); + try std.testing.expectEqualStrings("new output", batch.items[1].output.bytes); + try std.testing.expectEqual(@as(u32, 0), batch.items[2].eof.gen); + try std.testing.expectEqual(@as(u32, 0), batch.items[3].lsp_done.id); + try std.testing.expectEqualStrings("new result", batch.items[3].lsp_done.rows.?); + try std.testing.expectEqualStrings("new status", batch.items[4].lsp_status); + inbox.close(gpa, std.testing.io); + inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = 1, .rows = try gpa.dupe(u8, "closed") } }); + try std.testing.expectEqual(@as(usize, 0), inbox.len); +} + +test "mac inbox admits every retained task completion when output fills the queue" { + const gpa = std.testing.allocator; + var inbox: Inbox = .{}; + defer inbox.close(gpa, std.testing.io); + var tasks: selection_pipe.Tasks = .{}; + defer tasks.cancelAll(std.testing.io); + for (0..inbox_output_limit) |_| try inbox.pushOutput(gpa, std.testing.io, .{ + .pane = 0, + .gen = 1, + .bytes = try gpa.dupe(u8, "output"), + }); + for (0..pardes.MAX_PANES) |pane| inbox.push(gpa, std.testing.io, .{ .eof = .{ .pane = @intCast(pane), .gen = 1 } }); + inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = 1, .rows = try gpa.dupe(u8, "result") } }); + for (0..selection_pipe.Tasks.capacity) |index| { + const id: u32 = @intCast(index); + try std.testing.expect(tasks.add(.{ .id = id, .future = .{ .any_future = null, .result = {} } })); + if (index == 0) { + const output = try gpa.dupe(u8, "filtered"); + const outputs = gpa.dupe([]u8, &.{output}) catch |err| { + gpa.free(output); + return err; + }; + inbox.push(gpa, std.testing.io, .{ .pipe = .{ .id = id, .success = true, .outputs = outputs } }); + } else inbox.push(gpa, std.testing.io, .{ .pipe = .{ + .id = id, + .success = false, + .outputs = &.{}, + .failure = .{ .kind = .exit, .code = 1, .stderr = try gpa.dupe(u8, "no match") }, + } }); + } + inbox.push(gpa, std.testing.io, .{ .lsp_status = try gpa.dupe(u8, "current status") }); + try std.testing.expectEqual(inbox_capacity, inbox.len); + var batch = inbox.take(std.testing.io); + defer for (batch.slice()) |msg| msg.free(gpa); + var eof_count: usize = 0; + var lsp_count: usize = 0; + var pipe_count: usize = 0; + var output_count: usize = 0; + for (batch.slice()) |msg| switch (msg) { + .eof => eof_count += 1, + .lsp_done => lsp_count += 1, + .pipe => |result| { + pipe_count += 1; + tasks.finish(std.testing.io, result.id); + }, + .output => |output| { + output_count += 1; + try std.testing.expectEqualStrings("output", output.bytes); + }, + .lsp_status => |status| try std.testing.expectEqualStrings("current status", status), + }; + try std.testing.expectEqual(pardes.MAX_PANES, eof_count); + try std.testing.expectEqual(@as(usize, 1), lsp_count); + try std.testing.expectEqual(selection_pipe.Tasks.capacity, pipe_count); + try std.testing.expectEqual(inbox_output_limit, output_count); + try std.testing.expectEqual(@as(usize, 0), tasks.len); +} + +test "mac inbox preserves output under backpressure and wakes on take cancel and close" { + const gpa = std.testing.allocator; + const io = std.testing.io; + const Producer = struct { + fn run(q: *Inbox, done: *std.Io.Event) !void { + defer done.set(std.testing.io); + defer q.push(std.testing.allocator, std.testing.io, .{ .eof = .{ .pane = 0, .gen = 1 } }); + try q.pushOutput(std.testing.allocator, std.testing.io, .{ + .pane = 0, + .gen = 1, + .bytes = try std.testing.allocator.dupe(u8, "tail\x1b[0m"), + }); + try q.pushOutput(std.testing.allocator, std.testing.io, .{ + .pane = 0, + .gen = 1, + .bytes = try std.testing.allocator.dupe(u8, "é😀"), + }); + } + }; + for ([_]enum { take, cancel, close }{ .take, .cancel, .close }) |action| { + var inbox: Inbox = .{}; + defer inbox.close(gpa, io); + for (0..inbox_output_limit) |_| try inbox.pushOutput(gpa, io, .{ + .pane = 0, + .gen = 1, + .bytes = try gpa.dupe(u8, "before"), + }); + var done: std.Io.Event = .unset; + var future = try io.concurrent(Producer.run, .{ &inbox, &done }); + defer future.cancel(io) catch {}; + for (0..1000) |_| { + if (inbox.space.state.load(.acquire).waiters != 0) break; + try io.sleep(.fromMilliseconds(1), .awake); + } + try std.testing.expectEqual(@as(u16, 1), inbox.space.state.load(.acquire).waiters); + switch (action) { + .take => { + var before = inbox.take(io); + defer for (before.slice()) |msg| msg.free(gpa); + try std.testing.expectEqual(inbox_output_limit, before.len); + for (before.slice()) |msg| try std.testing.expectEqualStrings("before", msg.output.bytes); + try done.waitTimeout(io, .{ .duration = .{ .raw = .fromSeconds(2), .clock = .awake } }); + try future.await(io); + var after = inbox.take(io); + defer for (after.slice()) |msg| msg.free(gpa); + try std.testing.expectEqual(@as(usize, 3), after.len); + try std.testing.expectEqualStrings("tail\x1b[0m", after.items[0].output.bytes); + try std.testing.expectEqualStrings("é😀", after.items[1].output.bytes); + try std.testing.expect(after.items[2] == .eof); + }, + .cancel => { + try std.testing.expectError(error.Canceled, future.cancel(io)); + var batch = inbox.take(io); + defer for (batch.slice()) |msg| msg.free(gpa); + try std.testing.expectEqual(inbox_output_limit + 1, batch.len); + for (batch.slice()[0..inbox_output_limit]) |msg| try std.testing.expectEqualStrings("before", msg.output.bytes); + try std.testing.expect(batch.items[inbox_output_limit] == .eof); + }, + .close => { + inbox.close(gpa, io); + try done.waitTimeout(io, .{ .duration = .{ .raw = .fromSeconds(2), .clock = .awake } }); + try std.testing.expectError(error.Canceled, future.await(io)); + try std.testing.expectEqual(@as(usize, 0), inbox.len); + }, + } + } +} + const State = struct { + ninep: ?*ninep_io.Listener = null, gpa: std.mem.Allocator, threaded: *std.Io.Threaded, io: std.Io, core: *pardes.Pardes, - /// False for the one effect drain inside pardes_init and nothing else: no - /// reader task exists yet, and the first theme file must land without a fade. started: bool = false, runtime: Runtime, cells: []Cell = &.{}, - /// Frozen canonical grid paired with an encoded change mask while a content or - /// lifecycle transition is active. Both are encoded at frame time so the - /// native renderer never borrows core-owned Cell layout across the ABI. previous_cells: []Cell = &.{}, changed_cells: []u8 = &.{}, panel_diff_len: usize = 0, 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, - /// This frame's pixel attachments, flattened out of Surface.images. Grown - /// and reused like `cells`, and emptied by the same failure path — the - /// accessors must never describe a different frame than the cell count. images: []Image = &.{}, images_len: usize = 0, - /// This frame's panel transitions, copied out of Surface in deterministic - /// paint order: moving, opening, then frozen closing tombstones. panel_tracks: [pardes.MAX_PANES * 2]PanelTrack = undefined, panel_tracks_len: usize = 0, ptys: [pardes.MAX_PANES]?Pty = @splat(null), inbox: Inbox = .{}, - /// The single in-flight language query. ONE slot, like the tty shell's: - /// replacing it cancels the previous worker, which is right because the - /// only query anyone is waiting for is the one they just asked for. - lsp_task: ?std.Io.Future(anyerror!void) = null, - /// Filters running off the main thread. Bounded by the shared table; a full - /// one answers the request as failed rather than queueing it. + lsp_task: ?host_io.Lsp.Task = null, pipe_tasks: selection_pipe.Tasks = .{}, file_watches: FileWatches = .{}, - /// 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, - /// The dial's angular velocity, in degrees per second. While fingers are - /// down this is a running estimate off the event stream; when they lift it - /// becomes the fling that `coasting` spends. Zero is a dial at rest. rotate_velocity: f32 = 0, - /// When the last rotation event arrived, so the estimate above has a dt. rotate_last_ns: i128 = 0, - /// Fingers are off and the dial is still turning. Separate from a nonzero - /// velocity because during the gesture that velocity is a MEASUREMENT — - /// spending it then would double every twist under the hand making it. rotate_coasting: bool = false, - /// Real elapsed time for the persistent Core Image scene pass, in - /// nanoseconds. Input and pty pumps never spend it; pardes_animation_tick - /// is the only writer. - /// - /// TIME, not a callback count. It used to be a frame counter divided by an - /// assumed 60 Hz, and the callbacks do not arrive at 60 Hz — the pump - /// re-arms `asyncAfter(0.016)` only after the previous frame's work, so the - /// real period is 16 ms PLUS a tick, a drain and a draw. Shader time - /// therefore advanced at roughly three quarters of wall clock, unevenly, - /// which is what a scene effect looks like when it stutters. scene_ns: u64 = 0, - /// Monotonic stamp of the previous tick, and the leftover time that was not - /// yet worth a whole fixed animation step. The core's transitions count - /// FRAMES, so real elapsed time is banked here and spent in whole - /// `animation.frame_ns` steps: a late callback advances two frames instead - /// of stretching one, which is what keeps a transition's duration the same - /// on a busy machine as on an idle one. last_tick_ns: u64 = 0, tick_bank_ns: u64 = 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, - /// Private prompt snippets borrowed by every child argv until exec. - prompt_rcs: shell_bin.PromptRcs, + prompt_rcs: host_io.Shell.PromptFiles, }; 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| { @@ -653,17 +679,11 @@ export fn pardes_init(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) c_ 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 allocs = pardes.memory.init(gpa); + errdefer pardes.memory.deinit(); const threaded = try gpa.create(std.Io.Threaded); errdefer gpa.destroy(threaded); @@ -676,25 +696,16 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { var opts: pardes.Options = .{ .tty_only = true, - // The purpose-built 16 MiB stack-fallback buffer this host has always - // rendered out of; the core builds its per-frame Surface arena on it. .frame_allocator = allocs.frame, .image_allocator = allocs.image, .pdf_allocator = allocs.pdf, .tree_sitter_allocator = allocs.tree_sitter, }; - // 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| { - const found = user_config.load(io, config_arena.allocator(), env); + const found = pardes.config.User.load(io, config_arena.allocator(), env); opts.startup_config = found.bytes; opts.startup_config_path = found.path; opts.config_dir = found.dir; - // This host has no terminal at all, so the panic trace stderr gets goes - // to a Console.app nobody has open. `panic` above writes it beside the - // init file too, and this is where it learns the directory. if (found.dir) |d| crash.setDir(d); } @@ -707,17 +718,9 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { const core = try pardes.Pardes.init(allocs.pardes, opts); errdefer core.deinit(); - // This host draws pixels. Without it the core assumes a terminal that - // cannot, and a PDF pane degrades to counted page turns with nothing on - // screen at all — which is exactly what it did. The SDL shell sets the - // same flag; the tty one sets it from the terminal's kitty-graphics - // capability, because there it is a question rather than a fact. core.native_images = true; - // PATH, the bash banner and the prompt rc files, in the one order that - // works. State retains the path buffers for every later spawn and removes - // the files at app teardown. - var prompt_rcs = shell_bin.prepareForFork(); + var prompt_rcs = host_io.Shell.prepare(); errdefer prompt_rcs.deinit(); state = .{ @@ -730,96 +733,38 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { .runtime = if (runtime) |r| r.* else .{}, }; const st = &state.?; - // Every capability this host has, including the tty pull the core makes at - // the Exec that cares rather than at the cwd read above. Assigned here and - // not left to `pump`, because the spawns below happen outside one. + st.ninep = ninep_io.start(gpa, core); core.host = hostFor(st); - // The real grid, delivered as an EVENT and not as Options.cols/rows: the - // core defers an integrated shell's greeting until this resize and OSC - // 133 B; 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. while (core.nextEffect()) |effect| core.perform(effect); st.started = true; + if (st.ninep) |listener| listener.wakeThread(st, wakeNinep) catch |err| core.reportError(0, "9p wake", err); for (&st.ptys, 0..) |*slot, id| if (slot.*) |*pt| startReader(st, pt, @intCast(id)); - // Server-state narration onto the transient message row. Registered HERE - // and not at the `state = .{...}` assignment because the sink is called - // from the protocol client's reader threads and must not fire before the - // inbox is reachable. Without this the sink existed and nothing ever called - // it, so "rust-analyzer: indexing 45%" never appeared in this shell. pardes.lsp.setStatusSink(st, lspStatusSink); - - // 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 <path>` 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); - } +fn wakeNinep(ctx: ?*anyopaque) void { + const st: *State = @ptrCast(@alignCast(ctx orelse return)); + 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; - // The protocol client's reader threads call the sink, and the State it is - // handed is about to become null: unregister before the inbox goes away, - // and cancel the one query that may still be running against it. + if (st.ninep) |listener| { + listener.deinit(st.gpa); + st.ninep = null; + } pardes.lsp.setStatusSink(null, null); if (st.lsp_task) |*t| { - t.cancel(st.io) catch {}; + t.future.cancel(st.io) catch {}; st.lsp_task = null; } - // ...and every filter still running against it. A future nobody cancels is - // a thread writing into a State that is about to be null. st.pipe_tasks.cancelAll(st.io); - // Cancel host directory sources while their generation table still exists. - // A debounce block already queued on the main runloop may call back later; - // state=null below and the bumped generation each make that callback inert. for (0..pardes.MAX_PANES) |pane| if (st.file_watches.entries[pane] != null) { const id: u8 = @intCast(pane); const generation = st.file_watches.stop(st.gpa, id); @@ -829,13 +774,8 @@ export fn pardes_deinit() void { const generation = st.file_watches.stop(st.gpa, theme_watch_pane); hostWatchFile(theme_watch_pane, generation, null, 0); } - // 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); + st.inbox.close(st.gpa, st.io); st.file_watches.deinit(st.gpa); if (st.cells.len > 0) st.gpa.free(st.cells); if (st.previous_cells.len > 0) st.gpa.free(st.previous_cells); @@ -849,7 +789,7 @@ export fn pardes_deinit() void { st.prompt_rcs.deinit(); st.threaded.deinit(); st.gpa.destroy(st.threaded); - pardes.allocators.deinit(); + pardes.memory.deinit(); state = null; } @@ -858,7 +798,7 @@ export fn pardes_should_quit() bool { return st.core.quit; } -fn encodeSceneEffects(effects: panel_animation.SceneEffect) u32 { +fn encodeSceneEffects(effects: layout.SceneEffect) u32 { var flags: u32 = 0; if (effects.crt) flags |= scene_flag_crt; if (effects.ripple) flags |= scene_flag_ripple; @@ -870,56 +810,39 @@ fn currentSceneFlags(st: *const State) u32 { return encodeSceneEffects(st.core.settings.scene_effects); } -/// Advance the scene clock by real elapsed time, wrapping so an f32 -/// `time_seconds` keeps sub-millisecond resolution forever. fn advanceSceneClock(st: *State, elapsed_ns: u64) void { st.scene_ns = (st.scene_ns +| elapsed_ns) % scene_wrap_ns; } -/// How much real time this tick may spend, and how many whole fixed steps that -/// buys. Pure arithmetic, split out of `pardes_animation_tick` so the clock the -/// whole feel of the app rides on can be asserted without a display attached. -/// -/// `previous` of zero means "no sample yet" — the first tick of a run, or a -/// monotonic clock that refused to answer — and spends exactly one step rather -/// than the entire uptime. const TickSpend = struct { elapsed_ns: u64, steps: u32, bank_ns: u64 }; fn spendTickTime(previous_ns: u64, now_ns: u64, bank_ns: u64) TickSpend { const measured = if (previous_ns == 0 or now_ns <= previous_ns) - pardes.animation.frame_ns + pardes.layout.Animation.frame_ns else now_ns - previous_ns; const elapsed = @min(measured, max_tick_catch_up_ns); var bank = bank_ns +| elapsed; var steps: u32 = 0; - while (bank >= pardes.animation.frame_ns) : (steps += 1) bank -= pardes.animation.frame_ns; + while (bank >= pardes.layout.Animation.frame_ns) : (steps += 1) bank -= pardes.layout.Animation.frame_ns; return .{ .elapsed_ns = elapsed, .steps = steps, .bank_ns = bank }; } test "the animation clock spends real time, not callbacks" { - const frame = pardes.animation.frame_ns; + const frame = pardes.layout.Animation.frame_ns; const expectEqual = std.testing.expectEqual; - // First tick of a run has nothing to measure from and spends exactly one - // step — never the whole uptime. const first = spendTickTime(0, 999 * std.time.ns_per_s, 0); try expectEqual(@as(u32, 1), first.steps); try expectEqual(frame, first.elapsed_ns); - // A callback that lands ON time buys one step and banks nothing. const on_time = spendTickTime(1_000, 1_000 + frame, 0); try expectEqual(@as(u32, 1), on_time.steps); try expectEqual(@as(u64, 0), on_time.bank_ns); - // THE BUG THIS FIXES. A callback that lands late used to still count as one - // frame, so an animation stretched and ran slow. Two frames' worth of real - // time now buys two steps. const late = spendTickTime(1_000, 1_000 + 2 * frame, 0); try expectEqual(@as(u32, 2), late.steps); - // ...and time too short for a step is BANKED, not discarded: three 6 ms - // callbacks are worth one 16 ms frame, not zero and not three. var bank: u64 = 0; var steps: u32 = 0; for (0..3) |_| { @@ -930,44 +853,19 @@ test "the animation clock spends real time, not callbacks" { try expectEqual(@as(u32, 1), steps); try expectEqual(@as(u64, 2 * std.time.ns_per_ms), bank); - // A stall — occluded window, sleep, breakpoint — is CLAMPED. Resuming an - // animation must not fast-forward it by however long nobody was looking. const stall = spendTickTime(1_000, 1_000 + 10 * std.time.ns_per_s, 0); try expectEqual(max_tick_catch_up_ns, stall.elapsed_ns); try expectEqual(@as(u32, @intCast(max_tick_catch_up_ns / frame)), stall.steps); - // A monotonic clock that refuses to answer, or that goes backwards, spends - // one step rather than a garbage dt. try expectEqual(@as(u32, 1), spendTickTime(5_000, 4_000, 0).steps); } -/// Something on screen moves on its own and wants ~60 Hz ticks: a finite core -/// transition, a persistent scene shader, or the rotation dial coasting after -/// a flick. All are spent only by pardes_animation_tick, so input and pty pumps -/// cannot make frame-count animation run faster than the display clock. export fn pardes_animating() bool { const st = &(state orelse return false); return st.core.animationActive() or st.rotate_coasting; } -/// The colour the host should paint everything the grid does not: the window -/// background behind the titlebar, and behind every pixel of a live resize the -/// view has not caught up with yet. -/// -/// The theme's OWN background, not the chrome's, and so not animated — the -/// same split every other shell draws. Chrome (taglines, the move box, the -/// scrollbar) fades between themes over a handful of frames; document -/// backgrounds switch the instant the theme does, and this is one of those. -/// -/// PARDES_COLOR_DEFAULT means the active theme declares NO background of its -/// own (`bg = null`: the curated `dark`, and every vendored `*_transparent`). -/// In a terminal that means "wear whatever the terminal is wearing"; a window -/// has nothing to wear, so the host lets its own backdrop through — see the -/// NSVisualEffectView in AppDelegate. export fn pardes_theme_bg() u32 { - // Before pardes_init there is no session, but there IS a theme: the ring's - // first entry is what the core boots wearing, so answering with it keeps - // the window from opening one colour and flipping to another a frame later. const th = if (state) |*st| st.core.theme() else &pardes.themes[0]; const bg = th.bg orelse return color_default; return @as(u32, bg[0]) << 16 | @as(u32, bg[1]) << 8 | bg[2]; @@ -977,23 +875,10 @@ fn taglineFontPercent(core: ?*const pardes.Pardes) u8 { return if (core) |p| p.settings.font.tagline_percent else pardes.config.gui_tagline_font_percent; } -/// The smaller face used for pane taglines, as a percentage of the body face. -/// Grid geometry always comes from the body face. Before init the compiled -/// default lets the host construct its metrics; afterwards it pulls the live -/// core value so a TaglineSize command is visible on the next host read. export fn pardes_gui_tagline_font_percent() u8 { return taglineFontPercent(if (state) |*st| st.core else null); } -/// Where that smaller band sits inside its body-sized row, and the rule between -/// the topbar band and the first pane-tag band. Both answers come from the core -/// rather than being reimplemented here, because a second copy of this geometry -/// is exactly what left the native shell centring every band while the SDL -/// shell joined them (`pardes.taglineBandOffset`). -/// -/// PHYSICAL PIXELS, like the SDL shell's: a host working in points multiplies -/// by its backing scale on the way in and divides on the way out, which is the -/// same snapping it already does for the cell itself. export fn pardes_tagline_band_offset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { return pardes.taglineBandOffset(row, canvas_h, cell_h, tagline_h); } @@ -1002,34 +887,16 @@ export fn pardes_topbar_pane_border_px(cell_h: u32, tagline_h: u32) u32 { return pardes.topbarPaneBorderPixels(cell_h, tagline_h); } -/// ...and the HORIZONTAL half of the same story: the column a compact tagline -/// band anchors at, so a tag row advances on the tagline face's own pitch -/// instead of dropping a smaller glyph into the middle of every body cell. -/// Without it this shell tracked its tags visibly looser than the SDL window -/// beside it at the same percentage. -/// -/// CELLS, not pixels: the caller already knows both cell widths, and an -/// animating panel's origin is fractional. export fn pardes_tagline_origin_col(col: u16, row: u16) f32 { const st = &(state orelse return @floatFromInt(col)); return pardes.taglineOriginColForFrame(st.core, col, row); } -/// ...and its inverse, for the pointer. A tag row whose glyphs were compacted -/// but whose clicks were not is a click that drifts one word further right for -/// every word along the row, so the layout and the hit test are one feature. -/// -/// `x` and both widths in the SAME unit — this shell measures in POINTS and -/// passes points; only their ratio is read. export fn pardes_grid_col_at(x: f32, row: u16, body_w: f32, tagline_w: f32) u16 { const st = &(state orelse return pardes.gridColAt(null, x, row, body_w, tagline_w)); return pardes.gridColAt(st.core, x, row, body_w, tagline_w); } -/// Colour of that rule: the compiled override when a build pins one, otherwise -/// the active theme's scrollbar track — the same resolution the SDL shell does -/// at `src/gui/gui.zig:3813`. PARDES_COLOR_DEFAULT before there is a session to -/// ask, which the host reads as "do not draw the rule yet". export fn pardes_topbar_pane_border_rgb() u32 { const rgb = pardes.config.gui_topbar_pane_border_rgb orelse fromTheme: { const st = state orelse return color_default; @@ -1038,33 +905,12 @@ export fn pardes_topbar_pane_border_rgb() u32 { return @as(u32, rgb[0]) << 16 | @as(u32, rgb[1]) << 8 | rgb[2]; } -/// The tag band's own background — `chromeTheme().tag_bg`, the same value the -/// SDL shell builds its `tagline_base` cell from. -/// -/// A host needs it because a compact tag row is painted in two passes: the -/// pane-wide band in THIS colour on the body grid, then each cell's own -/// background on the narrower grid the glyphs use. Without the split, a -/// highlighted word's box lands on body pitch while its letters sit on tagline -/// pitch, and the box drifts further from the word the further along the row -/// it is. PARDES_COLOR_DEFAULT before there is a session to ask. export fn pardes_tagline_bg() u32 { const st = state orelse return color_default; const rgb = st.core.chromeTheme().tag_bg; return @as(u32, rgb[0]) << 16 | @as(u32, rgb[1]) << 8 | rgb[2]; } -/// The shared fallback PREFERENCE ORDER — `fonts.fallback_names`, the same list -/// the SDL shell walks. Only the order is shared; resolving a name is each -/// host's own business, and has to be: SDL matches file stems while walking the -/// font directories itself, and CoreText matches PostScript and family names, -/// which for the same face are routinely different strings. "Mononoki Nerd -/// Font Mono" ships as `MononokiNerdFontMono-Regular.ttf` and answers to -/// `MononokiNFM-Regular`, and a by-stem lookup on this platform silently -/// resolves to Helvetica rather than failing. -/// -/// Returned as pointer + length rather than NUL-terminated because these are -/// Zig string literals and a sentinel copy of each would exist only to be -/// dropped again by the caller. export fn pardes_fallback_font_count() u32 { return fonts.fallback_names.len; } @@ -1093,45 +939,27 @@ test "the fallback preference order crosses the ABI intact and ends at the bound try std.testing.expectEqual(@as(u32, fonts.fallback_names.len), pardes_fallback_font_count()); try std.testing.expect(pardes_fallback_font_count() > 0); - // Every name arrives byte for byte and in the SAME ORDER, which is the - // whole of what is shared: the AppKit shell seeds its CoreText cascade from - // this list and the SDL shell walks the font directories for it, and a - // reordering here would silently give one window a different fallback than - // the other at the same codepoint. for (fonts.fallback_names, 0..) |want, i| { var len: u32 = 0; const got = pardes_fallback_font_name(@intCast(i), &len) orelse return error.MissingFallbackName; try std.testing.expectEqualStrings(want, got[0..len]); } - // Past the end is null AND a zero length: a host that ignores the count and - // walks until null must not read a stale length and copy from a null - // pointer. var len: u32 = 12345; try std.testing.expect(pardes_fallback_font_name(pardes_fallback_font_count(), &len) == null); try std.testing.expectEqual(@as(u32, 0), len); } -/// One coherent snapshot for the host's single scene postprocess. The clock is -/// REAL ELAPSED TIME, advanced only on the scheduled display callback and never -/// on an input or pty drain — so a burst of typing cannot fast-forward a scene -/// effect, and a slow callback no longer slows one down either. export fn pardes_scene() Scene { const st = &(state orelse return .{}); const seconds = @as(f64, @floatFromInt(st.scene_ns)) / @as(f64, std.time.ns_per_s); return .{ .flags = currentSceneFlags(st), .time_seconds = @floatCast(seconds), - // The shader's frame counter is that time expressed in nominal display - // frames; it is a UNIT of the clock now, not the clock itself. .frame = @intFromFloat(seconds * @as(f64, @floatFromInt(scene_frame_hz))), }; } -/// The host could not construct or repeatedly submit the shared Metal/Core -/// Image pass. Stop claiming effects are enabled when only the canonical grid -/// can be presented, stop its otherwise-unbounded display-clock wakeups, and -/// snap any current panels before the direct canonical fallback is drawn. export fn pardes_postprocessor_unavailable() void { const st = &(state orelse return); st.core.disableSceneEffects(); @@ -1140,27 +968,11 @@ export fn pardes_postprocessor_unavailable() void { st.scene_ns = 0; } -/// One transient postprocess submission failed and the host will draw the -/// canonical grid for this frame. A later retry may keep scene effects, but it -/// must not resume a half-finished panel transition after that canonical frame. export fn pardes_panel_animation_failed() void { const st = &(state orelse return); st.core.abandonPanelAnimations(); } -/// The core's per-frame poll: re-read the cwd of every shell that just spoke, -/// and only those. -/// -/// A pane's tag shows this and a relative `Look` resolves against it, so it has -/// to follow the shell around rather than stay at the directory the pane was -/// 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. -/// -/// Whether a shell's tty is still that shell is NOT refreshed here: nothing -/// draws it, so the core pulls it instead (see `ttyTaken`). fn refreshCwds(ctx: ?*anyopaque) void { const st = hostState(ctx); for (&st.cwd_stale, 0..) |*stale, id| { @@ -1168,7 +980,7 @@ fn refreshCwds(ctx: ?*anyopaque) void { 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); + if (host_io.shellCwd(pt.pid, &buf)) |wd| st.core.setCwd(id, wd); } } @@ -1188,9 +1000,8 @@ fn watchInitialGeneration(st: *State, pane: u8, path: []const u8) ?file_watch.Ge return null; } -/// Start watching the path the core resolved for this pane. Turning a watch off -/// is the caller's business (`watchFile`); everything here is the start. -fn setFileWatch(st: *State, pane: u8, path: []const u8) void { +fn setFileWatch(st: *State, pane: u8, path: []const u8, mode: pardes.WatchMode) void { + const native = filesystem.localPath(path) orelse return; const value = st.core.panes[pane] orelse return; const generation_on_disk = watchInitialGeneration(st, pane, path) orelse return; const generation = st.file_watches.replace( @@ -1204,12 +1015,13 @@ fn setFileWatch(st: *State, pane: u8, path: []const u8) void { hostWatchFile(pane, stopped, null, 0); return; }; - const watched = st.file_watches.entries[pane].?; - hostWatchFile(pane, generation, watched.path.ptr, watched.path.len); - // The document was opened before this source existed. Reconcile once only - // AFTER source.activate() so a replacement in that gap is either observed - // here or produces a later directory edge; there is no open-before-watch - // window in which both mechanisms can miss it. + hostWatchFile(pane, generation, native.ptr, native.len); + if (mode == .baseline_disk and value.file != null) { + const bytes = filesystem.read(st.core, path) catch return; + defer st.core.gpa.free(bytes); + st.file_watches.restampText(pane, path, std.hash.Wyhash.hash(0, bytes)); + return; + } _ = reloadWatchedFile(st, pane, false); } @@ -1218,7 +1030,7 @@ fn setThemeFileWatch(st: *State, request_generation: u32, on: bool, animate: boo hostWatchFile(theme_watch_pane, stopped, null, 0); if (!on) return; const request = st.core.themeFileRequest(request_generation) orelse return; - const bytes = look.readFile(st.gpa, request.path) catch |err| { + const bytes = filesystem.readFile(st.gpa, request.path) catch |err| { st.core.failThemeFile(request_generation, err); return; }; @@ -1233,8 +1045,6 @@ fn setThemeFileWatch(st: *State, request_generation: u32, on: bool, animate: boo ) catch return; const watched = st.file_watches.entries[theme_watch_pane].?; hostWatchFile(theme_watch_pane, callback_generation, watched.path.ptr, watched.path.len); - // Read-before-watch has the same rename-over gap as document panes. One - // immediate reconciliation after Swift activates the source closes it. _ = reloadWatchedTheme(st, false); } @@ -1242,7 +1052,7 @@ fn reloadWatchedTheme(st: *State, announce: bool) bool { const watched = if (st.file_watches.entries[theme_watch_pane]) |*entry| entry else return false; const request = st.core.themeFileRequest(watched.serial) orelse return false; if (!std.mem.eql(u8, watched.path, request.path)) return false; - const bytes = look.readFile(st.gpa, watched.path) catch |err| { + const bytes = filesystem.readFile(st.gpa, watched.path) catch |err| { st.core.failThemeFile(watched.serial, err); return false; }; @@ -1264,11 +1074,6 @@ fn reloadWatchedTheme(st: *State, announce: bool) bool { return true; } -/// Read and apply on the main thread. Swift only says that this path or its -/// parent changed. Text hashes an exact bounded snapshot; PDFs may be much -/// larger than that bound and MuPDF reopens the path itself, so they compare a -/// cheap inode/size/time identity instead. Both transactions enter through the -/// same success-reporting core seam and only then advance their baseline. const WatchReload = enum { no_change, committed, changed_uncommitted }; fn retryWatchedFile(st: *State, pane: u8, generation: u32) void { @@ -1286,11 +1091,11 @@ fn reloadWatchedFile(st: *State, pane: u8, announce: bool) bool { const result: WatchReload = switch (watched.generation_on_disk) { .text => |old_hash| text: { if (current.file == null) break :text .no_change; - const bytes = look.readFile(st.gpa, watched.path) catch { + const bytes = filesystem.read(st.core, watched.path) catch { retryWatchedFile(st, pane, generation); break :text .no_change; }; - defer st.gpa.free(bytes); + defer st.core.gpa.free(bytes); const hash = std.hash.Wyhash.hash(0, bytes); if (hash == old_hash) break :text .no_change; if (!st.core.reloadWatchedFile(pane, bytes)) { @@ -1298,9 +1103,6 @@ fn reloadWatchedFile(st: *State, pane: u8, announce: bool) bool { break :text .no_change; } - // The call is synchronous, but retain the same lifetime guards as - // the async edge: future refactors cannot bless a reused slot just - // because it happens to carry the same pathname. const after = st.core.panes[pane] orelse break :text .no_change; const active = if (st.file_watches.entries[pane]) |*entry| entry else break :text .no_change; if (active.generation != generation or after.serial != active.serial) @@ -1331,10 +1133,6 @@ fn reloadWatchedFile(st: *State, pane: u8, announce: bool) bool { retryWatchedFile(st, pane, generation); break :pdf .changed_uncommitted; }; - // The identity must bracket the complete synchronous MuPDF - // transaction. If the path moved during it, leave the old baseline - // in place and spend one bounded retry from the already-armed - // source; correctness does not depend on receiving a second edge. if (!before.eql(after_identity)) { retryWatchedFile(st, pane, generation); break :pdf .changed_uncommitted; @@ -1354,21 +1152,14 @@ fn reloadWatchedFile(st: *State, pane: u8, announce: bool) bool { return result != .no_change; } -/// FileWatcher.swift calls this from DispatchQueue.main after its short quiet -/// period. Do not touch the core here: schedule the ordinary pump so all file -/// IO and state mutation stay in pardes_tick with pty/nested messages. export fn pardes_watch_changed(pane: u8, generation: u32) void { const st = &(state orelse return); if (pane >= watch_slot_count) return; if (st.file_watches.notify(pane, generation)) wake(st); } -/// What arrived off the loop thread since the last tick: pty output, a reaped -/// shell, a nested `Look`, and the file-watch edges Swift debounced. Every one -/// of them carries borrowed bytes, so they go straight into `update` rather -/// than through the core's event queue. fn drainInbox(st: *State) bool { - var batch = st.inbox.take(); + var batch = st.inbox.take(st.io); var did = batch.len > 0; for (batch.slice()) |msg| { defer msg.free(st.gpa); @@ -1380,39 +1171,20 @@ fn drainInbox(st: *State) bool { }, .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 }), - // The rows the worker produced, back into the request the core is - // still holding open. Joining the future here is what keeps a - // completed task from leaking its allocation. .lsp_done => |d| { st.core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); - if (st.lsp_task) |*t| { - t.cancel(st.io) catch {}; + if (st.lsp_task) |*t| if (t.id == d.id) { + t.future.await(st.io) catch {}; st.lsp_task = null; - } + }; }, - // "rust-analyzer: cargo check 88%" onto the transient message row, - // on the ACTIVE pane: server state is session news, not a fact - // about whichever pane happened to ask. .lsp_status => |text| { var mbuf: [256]u8 = undefined; st.core.setStatus(st.core.active, message.stamp(&mbuf, "lsp", text)); }, - // The filter's answer, then join the worker that produced it. - // - // NO deinit here: this loop's `defer msg.free(st.gpa)` owns the - // response, and `Msg.free` deinits it. The SDL shell frees inside - // its arm because its queue has no blanket free — copying that arm - // across without the surrounding contract is a double free, which - // is exactly what it was until the first `|` crashed the app. .pipe => |value| { st.core.update(.{ .pipe_resp = .{ .id = value.id, @@ -1435,51 +1207,225 @@ fn drainInbox(st: *State) bool { return did; } -/// Hand the core what arrived off-thread, then perform whatever it queued in -/// response. Returns whether this tick had IO to do, which is what bounds the -/// app's "pump until quiet" drain loop. -/// -/// It deliberately does NOT render. AppKit wants to be TOLD the view is dirty -/// and to draw once per display refresh: a pty burst is a dozen wakeups and a -/// dozen ticks, and rendering inside each of them would encode eleven grids -/// nobody ever sees. The render is `pardes_frame`, which the draw callback -/// calls at display cadence — the coalescing this whole boundary is shaped -/// around, and what src/macos/pardes.h has always said pardes_frame is. -/// -/// NOT a repaint signal, however tempting: the core changes the grid on its own -/// for a cursor move, a selection, a mode change and a scroll, none of which -/// queue an effect or read a pty, so all four return false here. The macOS host -/// learned that the expensive way — see the comment on pump() in -/// src/macos/Sources/AppDelegate.swift. export fn pardes_tick() bool { const st = &(state orelse return false); - // Cleared before the drain: a reader that pushes during this tick must be - // able to schedule the next one. st.inbox.wake_pending.store(false, .release); var did = drainInbox(st); - // Straight to `perform`, not through `pump`: the effects are the IO half of - // a tick and the render is not. `core.host` was seated once at init and is - // this host for the life of the session, so both this loop and the - // `tty_taken` pull the next keystroke makes land here. + if (st.ninep) |listener| { + const drained = listener.tick(st.core); + did = did or drained.count != 0; + if (drained.pending) wake(st); + } while (st.core.nextEffect()) |effect| { did = true; st.core.perform(effect); } + if (restoreCore(st)) did = true; return did; } -/// Spend the real time elapsed since the previous tick. Event pumps deliberately -/// never call this: a burst of key, mouse, or pty notifications is work to -/// drain, not elapsed animation time. +fn restoreCore(st: *State) bool { + if (st.core.quit) return false; + const path = st.core.takeRestore() orelse return false; + const bytes = filesystem.readFile(st.gpa, path) catch |err| { + st.core.reportError(st.core.active, "Restore", err); + return false; + }; + defer st.gpa.free(bytes); + const replacement = st.core.restore(bytes) catch |err| { + st.core.reportError(st.core.active, "Restore", err); + return false; + }; + if (st.lsp_task) |*task| { + task.future.cancel(st.io) catch {}; + st.lsp_task = null; + } + st.pipe_tasks.cancelAll(st.io); + for (0..pardes.MAX_PANES) |pane| { + reap(st, @intCast(pane)); + st.gens[pane] +%= 1; + } + var stale = st.inbox.take(st.io); + for (stale.slice()) |msg| msg.free(st.gpa); + for (0..watch_slot_count) |pane| if (st.file_watches.entries[pane] != null) { + const id: u8 = @intCast(pane); + const generation = st.file_watches.stop(st.gpa, id); + hostWatchFile(id, generation, null, 0); + }; + if (st.ninep) |listener| listener.reset(st.core); + replacement.host = hostFor(st); + st.core.deinit(); + st.core = replacement; + clearFrame(st); + st.cwd_stale = @splat(false); + st.scroll_lag = 0; + st.scroll_lag_x = 0; + st.rotate_lag = 0; + st.rotate_velocity = 0; + st.rotate_last_ns = 0; + st.rotate_coasting = false; + st.scene_ns = 0; + st.last_tick_ns = 0; + st.tick_bank_ns = 0; + return true; +} + +test "mac Restore keeps host state and rejects callbacks from the old core" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 16 }); + var st: State = .{ + .gpa = gpa, + .threaded = undefined, + .io = std.testing.io, + .core = core, + .runtime = .{}, + .config_arena = .init(gpa), + .prompt_rcs = .{}, + .frame_len = 7, + .rotate_coasting = true, + }; + defer st.core.deinit(); + defer st.config_arena.deinit(); + defer st.file_watches.deinit(gpa); + defer st.inbox.close(gpa, std.testing.io); + const marker = try st.config_arena.allocator().dupe(u8, "host configuration"); + _ = try core.setTestFile("saved body\n"); + const old_serial = core.panes[0].?.serial; + st.gens[0] = 23; + const old_watch = try st.file_watches.replace(gpa, 0, "/test.txt", old_serial, .{ .text = 0 }); + try core.dumpState(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "restore.zon", .data = core.dump_out.? }); + while (core.nextEffect()) |_| {} + + var command: [512]u8 = undefined; + core.update(.{ .command = try std.fmt.bufPrint(&command, "Restore .zig-cache/tmp/{s}/missing.zon", .{tmp.sub_path}) }); + try std.testing.expect(!restoreCore(&st)); + try std.testing.expectEqual(core, st.core); + try std.testing.expectEqual(old_watch, st.file_watches.entries[0].?.generation); + try std.testing.expect(st.rotate_coasting); + + core.lspRequest(0, .status, ""); + const old_request = core.lsp_wait.?.id; + st.lsp_task = .{ .id = old_request, .future = .{ .any_future = null, .result = {} } }; + st.inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = old_request, .rows = try gpa.dupe(u8, "old result") } }); + for (0..selection_pipe.Tasks.capacity) |index| { + const id: u32 = @intCast(index); + try std.testing.expect(st.pipe_tasks.add(.{ .id = id, .future = .{ .any_future = null, .result = {} } })); + st.inbox.push(gpa, std.testing.io, .{ .pipe = .{ + .id = id, + .success = false, + .outputs = &.{}, + .failure = .{ .kind = .exit, .code = 1, .stderr = try gpa.dupe(u8, "old filter failure") }, + } }); + } + try st.inbox.pushOutput(gpa, std.testing.io, .{ .pane = 0, .gen = 23, .bytes = try gpa.dupe(u8, "old PTY output") }); + core.update(.{ .command = try std.fmt.bufPrint(&command, "Restore .zig-cache/tmp/{s}/restore.zon", .{tmp.sub_path}) }); + try std.testing.expect(restoreCore(&st)); + try std.testing.expect(st.core != core); + try std.testing.expect(st.core.panes[0].?.serial > old_serial); + try std.testing.expectEqualStrings("saved body\n", st.core.panes[0].?.file.?.content); + try std.testing.expectEqualStrings("host configuration", marker); + try std.testing.expectEqual(@as(usize, 0), st.frame_len); + try std.testing.expect(!st.rotate_coasting); + try std.testing.expectEqual(@as(u32, 24), st.gens[0]); + try std.testing.expect(!st.file_watches.notify(0, old_watch)); + try std.testing.expect(st.file_watches.generations[0] > old_watch); + try std.testing.expectEqual(@as(usize, 0), st.inbox.len); + try std.testing.expectEqual(@as(usize, 0), st.pipe_tasks.len); + try std.testing.expect(st.lsp_task == null); + try std.testing.expect(!drainInbox(&st)); + try std.testing.expect(!st.cwd_stale[0]); + + st.core.lspRequest(0, .status, ""); + const new_request = st.core.lsp_wait.?.id; + try std.testing.expect(new_request > old_request); + st.lsp_task = .{ .id = new_request, .future = .{ .any_future = null, .result = {} } }; + st.inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = new_request, .rows = &.{} } }); + try std.testing.expect(drainInbox(&st)); + try std.testing.expect(st.lsp_task == null); + try std.testing.expect(st.core.lsp_wait == null); + + const pane = st.core.panes[0].?; + pane.cur_col = 4; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + st.core.update(.{ .key = .{ .cp = '|' } }); + st.core.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); + st.core.update(.{ .key = .{ .cp = pardes.Key.enter } }); + const pipe_id = st.core.pipe_wait.?.id; + try std.testing.expect(st.pipe_tasks.add(.{ .id = pipe_id, .future = .{ .any_future = null, .result = {} } })); + const output = try gpa.dupe(u8, "FRESH"); + const outputs = gpa.dupe([]u8, &.{output}) catch |err| { + gpa.free(output); + return err; + }; + st.inbox.push(gpa, std.testing.io, .{ .pipe = .{ .id = pipe_id, .success = true, .outputs = outputs } }); + try std.testing.expect(drainInbox(&st)); + try std.testing.expect(st.core.pipe_wait == null); + try std.testing.expectEqual(@as(usize, 0), st.pipe_tasks.len); + try std.testing.expectEqualStrings("FRESH body\n", pane.file.?.content); +} + +test "mac Restore cancels a PTY reader waiting for output capacity" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var st: State = .{ + .gpa = gpa, + .threaded = undefined, + .io = io, + .core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }), + .runtime = .{}, + .config_arena = .init(gpa), + .prompt_rcs = .{}, + }; + defer st.core.deinit(); + defer st.config_arena.deinit(); + defer st.file_watches.deinit(gpa); + defer st.inbox.close(gpa, io); + try st.core.dumpState(); + try tmp.dir.writeFile(io, .{ .sub_path = "restore.zon", .data = st.core.dump_out.? }); + while (st.core.nextEffect()) |_| {} + for (0..inbox_output_limit) |_| try st.inbox.pushOutput(gpa, io, .{ + .pane = 0, + .gen = 1, + .bytes = try gpa.dupe(u8, "retained output"), + }); + const child = try host_io.forkShell(null, 0, &st.prompt_rcs, "/bin/sh", "", 12, 40, null); + st.gens[0] = 1; + st.ptys[0] = .{ .file = child.file, .pid = child.pid, .gen = 1, .reader = .{ .any_future = null, .result = {} } }; + defer { + if (st.ptys[0]) |pt| if (pt.pid == child.pid) reap(&st, 0); + if (libc.waitpid(child.pid, null, posix.W.NOHANG) == 0) { + _ = libc.kill(child.pid, libc.SIG.KILL); + _ = libc.waitpid(child.pid, null, 0); + } + } + startReader(&st, &st.ptys[0].?, 0); + try std.testing.expect(host_io.writeFd(child.file.handle, "printf 'after-full'; exit\n")); + for (0..1000) |_| { + if (st.inbox.space.state.load(.acquire).waiters != 0) break; + try io.sleep(.fromMilliseconds(1), .awake); + } + try std.testing.expectEqual(@as(u16, 1), st.inbox.space.state.load(.acquire).waiters); + var command: [512]u8 = undefined; + st.core.update(.{ .command = try std.fmt.bufPrint(&command, "Restore .zig-cache/tmp/{s}/restore.zon", .{tmp.sub_path}) }); + try std.testing.expect(restoreCore(&st)); + try std.testing.expect(st.ptys[0] == null); + try std.testing.expectEqual(@as(usize, 0), st.inbox.len); + try std.testing.expectEqual(@as(u16, 0), st.inbox.space.state.load(.acquire).waiters); + try st.inbox.pushOutput(gpa, io, .{ .pane = 0, .gen = st.gens[0], .bytes = try gpa.dupe(u8, "fresh output") }); + var batch = st.inbox.take(io); + defer for (batch.slice()) |msg| msg.free(gpa); + try std.testing.expectEqual(@as(usize, 1), batch.len); + try std.testing.expectEqualStrings("fresh output", batch.items[0].output.bytes); +} + export fn pardes_animation_tick() bool { const st = &(state orelse return false); - // MEASURED elapsed time, not one assumed frame. The scheduler re-arms only - // after the previous frame's tick, drain and draw have finished, so on the - // fallback clock the callbacks land slower than 60 Hz and unevenly. - // Counting each as one frame made every animation run slow AND stutter; - // spending real time makes cadence a question of smoothness only, and no - // longer a question of speed. const now: u64 = @intCast(@max(0, monotonicNs())); const spend = spendTickTime(st.last_tick_ns, now, st.tick_bank_ns); st.last_tick_ns = now; @@ -1487,15 +1433,10 @@ export fn pardes_animation_tick() bool { var changed = false; if (currentSceneFlags(st) != 0) { - // Shader time is wall-clock seconds, so a scene effect runs at the same - // rate whatever the callback cadence turns out to be. advanceSceneClock(st, spend.elapsed_ns); changed = true; } - // The core's transitions and the dial's coast are FIXED-STEP: they count - // frames. The banked time is spent in whole steps, so a late callback - // advances two frames rather than stretching one over 32 ms. for (0..spend.steps) |_| { if (st.core.animationActive()) { st.core.update(.tick); @@ -1507,17 +1448,11 @@ export fn pardes_animation_tick() bool { if (@abs(st.rotate_velocity) < rotation_fling_stop) { st.rotate_velocity = 0; st.rotate_coasting = false; - // The remainder dies with the gesture: a banked half-notch - // surviving into the next twist is the hysteresis `rotate 0` - // exists to clear. st.rotate_lag = 0; } changed = true; } } - // Nothing is animating any more: drop the banked remainder so the next run - // starts on a whole step instead of jumping however far this one stopped - // short, and forget the stamp so its first dt is not the idle gap. if (!changed) { st.tick_bank_ns = 0; st.last_tick_ns = 0; @@ -1525,8 +1460,6 @@ export fn pardes_animation_tick() bool { 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; @@ -1546,9 +1479,6 @@ export fn pardes_paste(text_ptr: ?[*]const u8, len: usize) void { 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) { @@ -1596,10 +1526,6 @@ export fn pardes_scroll(delta_rows: f32, delta_cols: f32, col: u16, row: u16) vo .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; @@ -1613,16 +1539,8 @@ export fn pardes_scroll(delta_rows: f32, delta_cols: f32, col: u16, row: u16) vo } } -/// 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 — and it - // catches a fling still coasting, because a finger back down is how a hand - // catches a dial. if (degrees == 0) { st.rotate_lag = 0; st.rotate_velocity = 0; @@ -1634,19 +1552,11 @@ export fn pardes_rotate(degrees: f32) void { spendRotation(st, degrees); } -/// The fingers lifted. What happens next is decided entirely by how fast they -/// were moving when they did: `rotationFling` subtracts the floor, so a slow -/// twist stops dead where it was put and a flick keeps going in proportion to -/// how hard it was thrown. export fn pardes_rotate_end() void { const st = &(state orelse return); const last = st.rotate_last_ns; st.rotate_last_ns = 0; st.rotate_coasting = false; - // A hand that turned the dial, STOPPED, and then lifted has released at - // rest however fast it was moving before — and the last sample is still - // sitting there saying otherwise. Without this the most deliberate twist - // of all (turn, look at it, let go) is the one that flings. if (last == 0 or monotonicNs() - last > 90 * std.time.ns_per_ms) { st.rotate_velocity = 0; return; @@ -1655,23 +1565,12 @@ export fn pardes_rotate_end() void { st.rotate_coasting = st.rotate_velocity != 0; } -/// Monotonic nanoseconds, the clock lsp_zls.zig already times with. Monotonic -/// and not REALTIME on purpose: a dial that flung because NTP stepped the wall -/// clock backwards would be a bug nobody ever reproduces. -/// -/// Zero on failure, which is also the "no sample yet" sentinel — so a clock -/// that will not answer makes the dial refuse to fling rather than fling on a -/// garbage dt. fn monotonicNs() i128 { var ts: libc.timespec = undefined; if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0; return @as(i128, ts.sec) * std.time.ns_per_s + ts.nsec; } -/// One event's contribution to the velocity estimate, in degrees per second. -/// Smoothed, because a single 120 Hz sample of a human wrist is mostly noise -/// and the fling would otherwise be decided by whichever one happened to land -/// last. fn noteRotationVelocity(st: *State, degrees: f32) void { const now = monotonicNs(); const last = st.rotate_last_ns; @@ -1679,8 +1578,6 @@ fn noteRotationVelocity(st: *State, degrees: f32) void { st.rotate_coasting = false; if (last == 0 or now == 0) return; const dt_ns = now - last; - // A gap this long is a gesture nobody announced the start of, not a slow - // one: dividing by it would report a crawl and eat a real fling. if (dt_ns <= 0 or dt_ns > 200 * std.time.ns_per_ms) return; const seconds: f32 = @floatCast(@as(f64, @floatFromInt(dt_ns)) / @as(f64, std.time.ns_per_s)); const sample = degrees / seconds; @@ -1688,9 +1585,6 @@ fn noteRotationVelocity(st: *State, degrees: f32) void { st.rotate_velocity = st.rotate_velocity * 0.35 + sample * 0.65; } -/// Turn degrees into whole search steps, keeping the remainder. The one place -/// the dial reaches the core, so a hand-turned notch and a coasted one are the -/// same keystroke by construction. fn spendRotation(st: *State, degrees: f32) void { var left = takeRotationNotches(&st.rotate_lag, degrees); while (left != 0) { @@ -1721,36 +1615,27 @@ export fn pardes_resize(cols_arg: u16, rows_arg: u16, cell_w: u16, cell_h: u16) } }); } -// ---------------------------------------------------------------- frame out - -/// Render one frame, and the only place this host renders: AppKit's draw -/// callback, which is the one call it coalesces. A burst of input or pty output -/// marks the view dirty many times and is drawn once, so however much work the -/// ticks above drained, the grid is encoded once per display refresh. -/// -/// It is the core's whole loop iteration — drain, perform, poll, render, -/// present — and it cannot block: `wait_input` is null, because AppKit -/// delivered the events before it called us and sleeping inside a run-loop -/// callback is a beachball. `present` copies the result into the flat buffers -/// the accessors below describe (presentFrame); returns their cell count, or 0 -/// if the render failed. export fn pardes_frame() u32 { const st = &(state orelse return 0); - // The macOS host had NO zones at all, so every capture attributed its whole - // frame to the core. This is the boundary the AppKit `draw(_:)` calls into. const tz = tracy.zone(@src(), "pardes_frame"); defer tz.end(); + _ = restoreCore(st); st.core.pump(hostFor(st)) catch |err| { log.err("render failed: {t}", .{err}); clearFrame(st); return 0; }; + if (restoreCore(st)) { + st.core.pump(hostFor(st)) catch |err| { + log.err("render failed: {t}", .{err}); + clearFrame(st); + return 0; + }; + } tracy.frameMark(); return @intCast(st.frame_len); } -/// Everything the accessors below describe is emptied together, so a failure -/// can never leave last frame's buffer behind a fresh cols/rows. fn clearFrame(st: *State) void { st.frame_len = 0; st.frame_cols = 0; @@ -1760,9 +1645,6 @@ fn clearFrame(st: *State) void { st.panel_diff_len = 0; } -/// Copy one rendered frame into the flat buffers the native renderer reads. -/// Core-owned Cell layout is never borrowed across the ABI, so the grid, the -/// panel diff, the attachments and the tracks are all encoded here. fn presentFrame(ctx: ?*anyopaque, surface: *const pardes.Surface) void { const st = hostState(ctx); const tz = tracy.zone(@src(), "presentFrame"); @@ -1785,9 +1667,6 @@ fn presentFrame(ctx: ?*anyopaque, surface: *const pardes.Surface) void { st.frame_cols = surface.cols; st.frame_rows = surface.rows; { - // One encode per cell, every frame, whether or not the cell changed. - // If this is the hot zone the answer is a dirty-range copy, not a - // faster encodeCell. const tz_cells = tracy.zone(@src(), "encodeCells"); defer tz_cells.end(); for (surface.cells, st.cells[0..count]) |cell, *out| out.* = encodeCell(cell); @@ -1797,15 +1676,6 @@ fn presentFrame(ctx: ?*anyopaque, surface: *const pardes.Surface) void { collectPanelTracks(st, surface); } -/// Flatten tracks into the C-visible array the shader composites from. -/// -/// A plain copy, and that is the point. This used to re-sort by phase into -/// moving/opening/closing — which is EXACTLY the order `Pardes.render` already -/// publishes them in ("Moving panes first, then new panes, then inert closing -/// tombstones on top", src/pardes.zig), and it re-filtered `active()` the core -/// had already filtered. A second ordering rule that happens to agree is not -/// free: it is the thing that silently stops agreeing. The core's order is the -/// contract; every host receives the same dense record set. fn collectPanelTracks(st: *State, surface: *const pardes.Surface) void { const source = surface.panelTracks(); const len = @min(source.len, st.panel_tracks.len); @@ -1813,9 +1683,6 @@ fn collectPanelTracks(st: *State, surface: *const pardes.Surface) void { st.panel_tracks_len = len; } -/// Copy the old/new semantic transition data as one all-or-nothing snapshot. -/// A missing allocation disables the optional diff for this frame; it never -/// leaves a previous grid paired with a mask from another render. fn collectPanelDiff(st: *State, surface: *const pardes.Surface, count: usize) void { if (!surface.hasPanelDiff() or count == 0) return; if (st.previous_cells.len != count) { @@ -1843,11 +1710,6 @@ fn encodeChanged(diff: pardes.PanelCellDiff) u8 { return if (diff.changed()) 255 else 0; } -/// Flatten Surface.images into the flat C array the host walks. -/// -/// A dropped attachment is a page that does not draw, never a wrong one, so -/// every failure here just stops collecting: the frame is still valid, it -/// simply has fewer pictures in it than the core offered. fn collectImages(st: *State, surface: *const pardes.Surface) void { if (comptime !pardes.pdf_enabled) return; if (surface.nimages == 0) return; @@ -1861,10 +1723,6 @@ fn collectImages(st: *State, surface: *const pardes.Surface) void { for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (place.iw == 0 or place.ih == 0 or place.rgba.len == 0) continue; - // Continuous documents hand over geometry the core already clipped to - // the viewport. Anything else (a static image pane) is the whole - // raster scaled into the whole body, which is the same two rectangles - // spelled without a crop. const geometry = place.native.geometry orelse image.NativeGeometry{ .src = .{ .x = 0, .y = 0, .w = @intCast(place.iw), .h = @intCast(place.ih) }, .dst = .{ @@ -1921,10 +1779,6 @@ export fn pardes_frame_panel_track_list() ?[*]const PanelTrack { return if (st.panel_tracks_len == 0) null else st.panel_tracks[0..].ptr; } -/// AppKit calls this only after its destination context has accepted the -/// frame. The boolean keeps the ABI POD-only: animated presentation uses the -/// borrowed records from `pardes_frame`, while a direct fallback commits the -/// canonical grid with an empty snapshot. export fn pardes_frame_presented(animated_panels: bool) bool { const st = &(state orelse return false); const was_animating = st.core.animationActive(); @@ -1981,9 +1835,6 @@ export fn pardes_cursor_bar() bool { 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()) { @@ -1993,13 +1844,6 @@ export fn pardes_take_haptic() c_int { }; } -/// 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 State slice: C wants a terminator. 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 { @@ -2012,8 +1856,6 @@ export fn pardes_font_take() ?[*:0]const u8 { return &font_path_z; } -/// Observe the face already on screen without resolving an unrelated Font -/// request. Initial state, host-only zoom and display-scale changes use this. export fn pardes_font_observe( effective_name: ?[*]const u8, len: usize, @@ -2025,7 +1867,6 @@ export fn pardes_font_observe( return st.core.observeFont(ptr[0..len], point_hundredths, .points); } -/// Commit what CoreText accepted for the request returned by font_take. export fn pardes_font_ack( effective_name: ?[*]const u8, len: usize, @@ -2037,21 +1878,11 @@ export fn pardes_font_ack( return st.core.acknowledgeFont(ptr[0..len], point_hundredths, .points); } -/// Resolve a taken request which CoreText could not load without claiming the -/// fallback/previous face was the requested one. export fn pardes_font_reject() void { const st = &(state orelse return); st.core.rejectFont(); } -/// The FILE behind the focused pane, or null when there is none — a terminal, -/// an output buffer (`+Search` names a directory, not a document), or nothing -/// focused at all. A PDF and an image both count: they are real paths on disk, -/// and the titlebar's proxy icon is about the file, not about who can edit it. -/// -/// A copy into a static buffer for the reason pardes_font_take keeps one: the -/// core owns a length and no terminator, C wants a string, and there is one -/// core. Valid until the next call. var active_path_z: [4096:0]u8 = undefined; export fn pardes_active_path() ?[*:0]const u8 { @@ -2063,10 +1894,6 @@ export fn pardes_active_path() ?[*:0]const u8 { return &active_path_z; } -/// Does the focused pane hold edits that are not on disk? False for everything -/// that cannot be saved in the first place, which is the same set -/// pardes_active_path answers null for minus the PDFs and images — those have -/// a path but no buffer, so they are never dirty. export fn pardes_active_dirty() bool { const st = &(state orelse return false); const pane = st.core.panes[st.core.active] orelse return false; @@ -2083,78 +1910,48 @@ fn activeFilePath(st: *State) ?[]const u8 { return null; } -// ---------------------------------------------------------------- host seam - -/// What this host can do, for the core's own loop to call. What it deliberately -/// cannot: -/// * `wait_input` — AppKit delivered the events before it called us and owns -/// the sleep; blocking inside a run-loop callback is a beachball. -/// * `post_present` — presentation is acknowledged when the destination -/// context has accepted the frame (pardes_frame_presented), which is a -/// later callback, not the moment the cells were encoded. -/// * `pipe` — no worker to hand a job to yet, so a `|` filter does nothing -/// in this shell. Teardown is not a method at all: pardes_deinit is the -/// app's own call, made after AppKit's loop rather than from inside one. -/// -/// `lsp` USED to be on that list, and the entry claimed the core's empty answer -/// was "exactly what this host replied". It was not a considered trade: it -/// meant every language query in the shipped Mac app did nothing, silently, and -/// looked from the outside like a backend with no answer rather than a host -/// with no method. It is now `lspRequest` over the shared `lsp_host` worker. -/// -/// Watch is deliberately different again: FileWatcher.swift owns its -/// per-directory DispatchSource and only returns a debounced hint; these -/// main-thread methods own the bytes, hash and shared text/PDF core event. const vtable: pardes.Host.VTable = .{ - .push_present = presentFrame, - .push_poll_frame = refreshCwds, - .push_spawn = spawnShell, - .push_pty_write = ptyWrite, - .push_pty_resize = ptyResize, - .push_pty_signal = ptySignal, - .pull_tty_taken = ttyTaken, - .push_write_file = writeFile, - .push_write_dump = writeDump, - .push_watch_file = watchFile, - .push_watch_theme = watchTheme, - .push_dump_themes = dumpThemes, - .push_set_clipboard = setClipboard, - .pull_read_clipboard = readClipboard, - .push_open_link = openLink, - .pull_lsp = lspRequest, - .pull_pipe = pipeRequest, + .present = presentFrame, + .poll_frame = refreshCwds, + .spawn = spawnShell, + .pty_write = ptyWrite, + .pty_resize = ptyResize, + .pty_signal = ptySignal, + .tty_taken = ttyTaken, + .write_file = writeFile, + .write_dump = writeDump, + .watch_file = watchFile, + .watch_theme = watchTheme, + .dump_themes = dumpThemes, + .set_clipboard = setClipboard, + .read_clipboard = readClipboard, + .open_link = openLink, + .lsp = lspRequest, + .pipe = pipeRequest, }; fn hostFor(st: *State) pardes.Host { return .{ .ctx = st, .vtable = &vtable }; } -/// Answer a language query off the main thread and post the rows back. The -/// snapshot and the worker body are `lsp_host`'s, shared with the tty and SDL -/// shells; what is left here is the only part that is actually this host's — -/// which allocator, and how a finished job reaches the main thread. -fn lspRequest(ctx: ?*anyopaque, req: host_api.LspRequest) void { +fn lspRequest(ctx: ?*anyopaque, req: host_io.Lsp.Request) void { const st = hostState(ctx); - const job = lsp_host.snapshot(st.gpa, st.core, req) orelse return; - // One in flight. Replacing it cancels the previous worker, which is right: - // the only answer anyone is waiting for is the one just asked for. + const job = host_io.Lsp.snapshot(st.gpa, st.core, req) catch |err| { + st.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); + return st.core.reportError(req.pane, "lsp", err); + }; if (st.lsp_task) |*old| { - old.cancel(st.io) catch {}; + old.future.cancel(st.io) catch {}; st.lsp_task = null; } - st.lsp_task = st.io.concurrent(lspWorker, .{ st, job }) catch { + const future = st.io.concurrent(lspWorker, .{ st, job }) catch |err| { job.free(st.gpa); - return; + st.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); + return st.core.reportError(req.pane, "lsp", err); }; + st.lsp_task = .{ .id = req.id, .future = future }; } -/// Run a `|` filter off the main thread. The job copy, the subprocess and the -/// response all belong to `selection_pipe`; what is here is this host's inbox -/// and its bounded in-flight table. -/// -/// This shell had no `pull_pipe` at all, so `pardes.zig` self-answered every -/// filter as failed — a `|` in the Mac app silently did nothing, the same shape -/// of gap `pull_lsp` was. fn pipeRequest(ctx: ?*anyopaque, id: u32) void { const st = hostState(ctx); if (st.pipe_tasks.full()) { @@ -2162,10 +1959,14 @@ fn pipeRequest(ctx: ?*anyopaque, id: u32) void { return; } const view = st.core.pipeRequest(id) orelse return; - const job = selection_pipe.Job.copy(st.gpa, view) catch return; - const future = st.io.concurrent(pipeWorker, .{ st, job }) catch { + const job = selection_pipe.Job.copy(st.gpa, view) catch |err| { + st.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return st.core.reportError(st.core.active, "pipe", err); + }; + const future = st.io.concurrent(pipeWorker, .{ st, job }) catch |err| { job.deinit(st.gpa); - return; + st.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return st.core.reportError(st.core.active, "pipe", err); }; std.debug.assert(st.pipe_tasks.add(.{ .id = id, .future = future })); } @@ -2173,28 +1974,24 @@ fn pipeRequest(ctx: ?*anyopaque, id: u32) void { fn pipeWorker(st: *State, job: *selection_pipe.Job) anyerror!void { defer job.deinit(st.gpa); const response = selection_pipe.runJob(st.gpa, st.io, job); - st.inbox.push(st.gpa, .{ .pipe = response }); + st.inbox.push(st.gpa, st.io, .{ .pipe = response }); wake(st); } -fn lspWorker(st: *State, job: *lsp_host.Job) anyerror!void { - lsp_host.work(st.gpa, job, st, deliverLspRows); +fn lspWorker(st: *State, job: *host_io.Lsp.Job) anyerror!void { + host_io.Lsp.work(st.gpa, job, st, deliverLspRows); } -fn deliverLspRows(ctx: ?*anyopaque, id: u32, rows: []u8) void { +fn deliverLspRows(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { const st: *State = @ptrCast(@alignCast(ctx orelse return)); - st.inbox.push(st.gpa, .{ .lsp_done = .{ .id = id, .rows = rows } }); + st.inbox.push(st.gpa, st.io, .{ .lsp_done = .{ .id = id, .rows = rows } }); wake(st); } -/// The registered `lsp.setStatusSink` target, called from the protocol client's -/// READER threads. Thread-safe and non-blocking only: a dupe and an inbox push, -/// which is lossy for this message kind by design — the sink's lock is held -/// around this call and server state is periodic news. fn lspStatusSink(ctx: ?*anyopaque, text: []const u8) void { const st: *State = @ptrCast(@alignCast(ctx orelse return)); const copy = st.gpa.dupe(u8, text) catch return; - st.inbox.push(st.gpa, .{ .lsp_status = copy }); + st.inbox.push(st.gpa, st.io, .{ .lsp_status = copy }); wake(st); } @@ -2205,35 +2002,19 @@ fn hostState(ctx: ?*anyopaque) *State { fn spawnShell(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { const st = hostState(ctx); const core = st.core; - // The core reuses pane ids and has no close effect, so a deleted pane's - // shell lives in its slot until a respawn lands here. Reap it: cancel joins - // the reader, and the generation bump makes its late bytes and eof - // unreadable. reap(st, pane); st.gens[pane] +%= 1; const gen = st.gens[pane]; - var cwd_buf: [256:0]u8 = undefined; - var cwd_z: ?[*:0]const u8 = null; - // <= because writing the sentinel slot of a [N:0]u8 is legal, and Effect's - // cwd buffer is exactly 256: `<` would silently drop a maximal path and - // start the shell wherever the app bundle was launched from instead. - if (cwd.len > 0 and cwd.len <= cwd_buf.len) { - @memcpy(cwd_buf[0..cwd.len], cwd); - cwd_buf[cwd.len] = 0; - cwd_z = @ptrCast(&cwd_buf); - } - const child = host_io.forkShell(core, pane, &st.prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w, null); + const child = host_io.forkShell(core, pane, &st.prompt_rcs, core.shellBin(), cwd, core.screen_h, core.screen_w, st.ninep) catch |err| return core.reportError(pane, "shell", err); st.ptys[pane] = .{ .file = child.file, .pid = child.pid, .gen = gen, .reader = .{ .any_future = null, .result = {} }, }; - // Report the pane's starting directory back to the core (tags); the slot - // needs no occupancy reset, nothing is remembered. var lbuf: [1024]u8 = undefined; - if (look.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(pane, wd); + if (host_io.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(pane, wd); if (st.started) if (st.ptys[pane]) |*pt| startReader(st, pt, pane); } @@ -2249,37 +2030,22 @@ fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws)); } -/// `pty/ctl`'s `sig`. Unlike `ttyTaken` above this is NOT degraded on darwin: -/// `tcgetpgrp` on the master and `kill` are both POSIX, and neither needs the -/// libproc descendant walk `look.ttyTaken` is still waiting for. fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const st = hostState(ctx); - if (st.ptys[pane]) |pt| look.signalTty(pt.pid, pt.file.handle, sig); + if (st.ptys[pane]) |pt| host_io.signalTty(pt.pid, pt.file.handle, sig); } -/// Asked only where a command line is about to be typed: is a program holding -/// this pane's tty instead of the prompt we forked? `look.ttyTaken` answers -/// `false` on darwin until it grows a libproc implementation, so this host -/// behaves exactly as it did — the wiring is here so it cannot rot, and it -/// costs nothing until then. fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { const st = hostState(ctx); const pt = st.ptys[pane] orelse return false; - return look.ttyTaken(pt.pid, pt.file.handle); + return host_io.ttyTaken(pt.pid, pt.file.handle); } -/// A file pane's save and a scrollback's both land here; the core has already -/// resolved which path and which bytes. fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const st = hostState(ctx); - host_io.writeFileBytes(path, bytes) catch |err| + filesystem.write(st.core, path, bytes) catch |err| return st.core.saveFailed(pane, "save", err); - // The directory source will observe our own close. Move its baseline first - // so that notification is a hash no-op instead of manufacturing an external - // reload and undo boundary. st.file_watches.restampText(pane, path, std.hash.Wyhash.hash(0, bytes)); - // After the write, not beside it: every early return above is a save that - // did not happen and must not be reported as one. var mbuf: [256]u8 = undefined; st.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); } @@ -2288,20 +2054,18 @@ fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { const st = hostState(ctx); var pbuf: [1024:0]u8 = undefined; const path = pardes.dump.outPath(&pbuf) orelse return; - host_io.writeFileBytes(path, bytes) catch |err| return st.core.reportError(0, "dump", err); + filesystem.write(st.core, path, bytes) catch |err| return st.core.reportError(0, "dump", err); st.core.setLastDump(path); } -fn watchFile(ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool) void { +fn watchFile(ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool, mode: pardes.WatchMode) void { const st = hostState(ctx); - // No path is a pane with nothing on disk to watch (an output buffer, an - // image), which is the same answer as being turned off. if (!on or path.len == 0) { const generation = st.file_watches.stop(st.gpa, pane); hostWatchFile(pane, generation, null, 0); return; } - setFileWatch(st, pane, path); + setFileWatch(st, pane, path, mode); } fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { @@ -2312,7 +2076,7 @@ fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const st = hostState(ctx); const config_dir = st.core.opts.config_dir orelse return; - const out_dir = user_config.dumpThemes(st.io, st.gpa, config_dir, pardes.themes) catch |err| { + const out_dir = pardes.config.User.dumpThemes(st.io, st.gpa, config_dir, pardes.themes) catch |err| { st.core.reportError(pane, "dump themes", err); return; }; @@ -2327,11 +2091,6 @@ fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { cb(st.runtime.userdata, text.ptr, text.len); } -/// The host answers with pardes_paste, which the AppDelegate calls straight -/// back inside this call: NSPasteboard reads are synchronous, so the paste -/// event lands mid-pump. That is safe and deliberate — pardes_paste only feeds -/// core.update, and whatever that queues is picked up by the same effect loop -/// rather than waiting a tick. A host with a null callback simply never pastes. fn readClipboard(ctx: ?*anyopaque) void { const st = hostState(ctx); const cb = st.runtime.read_clipboard orelse return; @@ -2342,39 +2101,21 @@ fn openLink(_: ?*anyopaque, url: []const u8) void { look.openLink(url); } -// ---------------------------------------------------------------- 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); @@ -2383,29 +2124,20 @@ fn readPty(st: *State, io: std.Io, pty: std.Io.File, id: u8, gen: u32) anyerror! 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 } }); + st.inbox.pushOutput(st.gpa, io, .{ .pane = id, .gen = gen, .bytes = bytes }) catch break; wake(st); } - st.inbox.push(st.gpa, .{ .eof = .{ .pane = id, .gen = gen } }); + st.inbox.push(st.gpa, st.io, .{ .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 - -/// 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; @@ -2460,10 +2192,6 @@ fn encodeCellFlags(default: bool, role: pardes.FontRole) u8 { @as(u8, @intFromBool(role == .tagline)) * cell_flag_tagline; } -/// Spend accumulated sub-row travel as whole wheel notches, keeping the -/// remainder. The core has no fractional scroll — both other shells do this -/// too — and the clamp is so that an absurd delta (a momentum-phase kinetic -/// 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); @@ -2473,55 +2201,20 @@ fn takeScrollTicks(lag: *f32, delta_rows: f32) i32 { return whole; } -/// One search step per this many degrees of twist. Every notch is a jump to -/// another match, so it stays coarse enough that a thumb resettling cannot -/// walk the cursor across the file — but 20 degrees was more than a wrist -/// gives without thinking about it, and the dial felt stuck. Ten is still a -/// deliberate twist, and 36 steps to a full turn. const rotation_notch_degrees: f32 = 10; -/// Where momentum STARTS, in degrees per second — and it starts at zero. -/// -/// The fling is the release speed MINUS this, so a slow twist coasts not a -/// little but not at all, and the faster the flick the more there is. A plain -/// threshold would hand out two free notches the instant it was crossed, which -/// is the one thing a dial must not do: the same gesture, a hair quicker, -/// jumping twice as far is how a control stops feeling like a control. const rotation_fling_floor: f32 = 70; -/// ...and the ceiling on what is left after that subtraction. AppKit reports a -/// thousand degrees a second for one frame of a twitch, and this cap is what -/// decides how far the hardest possible flick throws the list: 400 deg/s is -/// about 111 degrees of coast, so eleven matches. Twenty read as the list -/// getting away from you. const rotation_fling_max: f32 = 400; -/// One pump of coasting. Fixed rather than measured: the host re-pumps at -/// ~60 Hz for exactly as long as pardes_animating says to, and a fixed step -/// makes one fling spend the same travel every time — which is what lets a -/// golden assert it instead of asserting the machine's timer jitter. const rotation_fling_step: f32 = 1.0 / 60.0; -/// Per-step decay. 0.94 at 60 Hz is a little over half a second of coast, the -/// same order as the trackpad's own inertial scrolling. const rotation_fling_decay: f32 = 0.94; -/// Below this the dial is at rest: one notch a second is not momentum, it is a -/// list still stepping long after the hand has moved on. const rotation_fling_stop: f32 = 18; -/// The velocity a release at `speed` degrees/second actually coasts at, after -/// the floor is subtracted and the remainder capped. Zero means the twist was -/// a placement, not a throw — which is most of them. -/// -/// Total travel follows from it and the decay as a geometric series: -/// `v * step / (1 - decay)`, i.e. about 0.28 degrees per degree/second. A -/// 200 deg/s release therefore coasts ~36 degrees, three or four notches. fn rotationFling(speed: f32) f32 { const excess = @min(@abs(speed) - rotation_fling_floor, rotation_fling_max); if (excess < rotation_fling_stop) return 0; return std.math.copysign(excess, speed); } -/// 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; @@ -2532,17 +2225,6 @@ fn takeRotationNotches(lag: *f32, degrees: f32) i32 { 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"; @@ -2631,9 +2313,6 @@ test "pardes.h matches the Zig boundary" { field.name; try expectEqual(@offsetOf(c.pardes_panel_track_s, c_name), @offsetOf(PanelTrack, field.name)); } - // The attachment struct is a wide one and every field is read by hand on - // the Swift side, so its layout is checked at both ends rather than at the - // two that happen to be easy. try expectEqual(@sizeOf(c.pardes_image_s), @sizeOf(Image)); inline for (@typeInfo(Image).@"struct".fields) |field| try expectEqual(@offsetOf(c.pardes_image_s, field.name), @offsetOf(Image, field.name)); @@ -2647,23 +2326,22 @@ test "pardes.h matches the Zig boundary" { try expectEqual(@as(u32, c.PARDES_SCENE_CRT), scene_flag_crt); try expectEqual(@as(u32, c.PARDES_SCENE_RIPPLE), scene_flag_ripple); try expectEqual(@as(u32, c.PARDES_SCENE_GLITCH), scene_flag_glitch); - try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(panel_animation.Phase.opening)); - try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(panel_animation.Phase.moving)); - try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(panel_animation.Phase.closing)); - try expectEqual(@as(u8, c.PARDES_PANEL_OFF), @intFromEnum(panel_animation.Transition.off)); - try expectEqual(@as(u8, c.PARDES_PANEL_SLIDE), @intFromEnum(panel_animation.Transition.slide)); - try expectEqual(@as(u8, c.PARDES_PANEL_ZOOM), @intFromEnum(panel_animation.Transition.zoom)); - try expectEqual(@as(u8, c.PARDES_PANEL_DISSOLVE), @intFromEnum(panel_animation.Transition.dissolve)); - try expectEqual(@as(u8, c.PARDES_PANEL_ASCII), @intFromEnum(panel_animation.Transition.ascii)); - try expectEqual(@as(u8, c.PARDES_PANEL_VERTICAL), @intFromEnum(panel_animation.Transition.vertical)); - try expectEqual(@as(u8, c.PARDES_PANEL_EDGES), @intFromEnum(panel_animation.Transition.edges)); - try expectEqual(@as(u8, c.PARDES_PANEL_FALL), @intFromEnum(panel_animation.Transition.fall)); - try expectEqual(@as(u8, c.PARDES_PANEL_WAVE), @intFromEnum(panel_animation.Transition.wave)); - try expectEqual(@as(u8, c.PARDES_PANEL_CURTAIN), @intFromEnum(panel_animation.Transition.curtain)); - try expectEqual(@as(u8, c.PARDES_PANEL_SCRAMBLE), @intFromEnum(panel_animation.Transition.scramble)); - try expectEqual(@as(u8, c.PARDES_PANEL_TYPEWRITER), @intFromEnum(panel_animation.Transition.typewriter)); + try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(layout.Phase.opening)); + try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(layout.Phase.moving)); + try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(layout.Phase.closing)); + try expectEqual(@as(u8, c.PARDES_PANEL_OFF), @intFromEnum(layout.Transition.off)); + try expectEqual(@as(u8, c.PARDES_PANEL_SLIDE), @intFromEnum(layout.Transition.slide)); + try expectEqual(@as(u8, c.PARDES_PANEL_ZOOM), @intFromEnum(layout.Transition.zoom)); + try expectEqual(@as(u8, c.PARDES_PANEL_DISSOLVE), @intFromEnum(layout.Transition.dissolve)); + try expectEqual(@as(u8, c.PARDES_PANEL_ASCII), @intFromEnum(layout.Transition.ascii)); + try expectEqual(@as(u8, c.PARDES_PANEL_VERTICAL), @intFromEnum(layout.Transition.vertical)); + try expectEqual(@as(u8, c.PARDES_PANEL_EDGES), @intFromEnum(layout.Transition.edges)); + try expectEqual(@as(u8, c.PARDES_PANEL_FALL), @intFromEnum(layout.Transition.fall)); + try expectEqual(@as(u8, c.PARDES_PANEL_WAVE), @intFromEnum(layout.Transition.wave)); + try expectEqual(@as(u8, c.PARDES_PANEL_CURTAIN), @intFromEnum(layout.Transition.curtain)); + try expectEqual(@as(u8, c.PARDES_PANEL_SCRAMBLE), @intFromEnum(layout.Transition.scramble)); + try expectEqual(@as(u8, c.PARDES_PANEL_TYPEWRITER), @intFromEnum(layout.Transition.typewriter)); - // 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); @@ -2678,8 +2356,6 @@ test "pardes.h matches the Zig boundary" { 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)); @@ -2693,13 +2369,10 @@ test "pardes.h matches the Zig boundary" { 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 })); @@ -2712,8 +2385,6 @@ test "pardes.h matches the Zig boundary" { encodeAttrs(.{ .ul = .curly }), ); - // Font role is explicit ABI data, not something the host reconstructs - // from tag colours. Default and role occupy independent bits. try expectEqual(@as(u8, 0), encodeCellFlags(false, .body)); try expectEqual(cell_flag_tagline, encodeCellFlags(false, .tagline)); try expectEqual(cell_flag_default | cell_flag_tagline, encodeCellFlags(true, .tagline)); @@ -2731,9 +2402,6 @@ test "scene effect flags and display clock are compact and independent" { encodeSceneEffects(.{ .crt = true, .ripple = true, .glitch = true }), ); - // The clock is TIME now, so the wrap is a duration and the assertion is - // that it wraps without losing the remainder — an f32 `time_seconds` that - // grew without bound would lose sub-millisecond resolution within a day. var st: State = undefined; st.scene_ns = scene_wrap_ns - (std.time.ns_per_ms * 5); advanceSceneClock(&st, std.time.ns_per_ms * 5); @@ -2743,10 +2411,6 @@ test "scene effect flags and display clock are compact and independent" { } test "the mac panel ABI hands the shader the core's order verbatim" { - // The host used to re-sort by phase here. It does not any more: the order - // is `panel_animation.paintOrder`, applied once in `Pardes.render`, and - // asserted where it lives (src/pardes.zig). What this host still owes is - // that it copies FAITHFULLY and cannot overrun its fixed ABI array. const source = [_]PanelTrack{ .{ .serial = 12, .pane = 1, .phase = .moving, .effect = .zoom }, .{ .serial = 15, .pane = 2, .phase = .moving, .effect = .dissolve }, @@ -2796,19 +2460,15 @@ test "colors encode to the three tags the host decodes" { 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))); @@ -2819,23 +2479,16 @@ test "sub-row scroll spends whole notches and keeps the remainder" { 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, 7)); try expectEqual(@as(i32, 1), takeRotationNotches(&lag, 5)); try expectEqual(@as(f32, 2), 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, -12)); try expectEqual(@as(f32, 0), lag); - // One deliberate half-turn is several matches, not a hundred. lag = 0; try expectEqual(@as(i32, 18), 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))); @@ -2844,27 +2497,17 @@ test "trackpad rotation spends whole search steps and keeps the remainder" { } test "the dial flings in proportion to the release, and not at all when placed" { - // The whole point of the curve: momentum ramps UP FROM ZERO at the floor - // rather than switching on at it, so no release speed exists where the - // same gesture a hair quicker suddenly jumps several matches further. try std.testing.expectEqual(@as(f32, 0), rotationFling(0)); try std.testing.expectEqual(@as(f32, 0), rotationFling(40)); try std.testing.expectEqual(@as(f32, 0), rotationFling(rotation_fling_floor)); - // Just over the floor is still nothing: what is left has to beat the - // at-rest threshold before it is worth waking the pump for. try std.testing.expectEqual(@as(f32, 0), rotationFling(rotation_fling_floor + 5)); - // ...and past that it is linear in the release speed, both ways. try std.testing.expectEqual(@as(f32, 130), rotationFling(200)); try std.testing.expectEqual(@as(f32, -130), rotationFling(-200)); - // A twitch is capped rather than emptying the list. try std.testing.expectEqual(rotation_fling_max, rotationFling(100_000)); try std.testing.expectEqual(-rotation_fling_max, rotationFling(-100_000)); - // What that buys, in the units a hand feels: total coast is the geometric - // series v*step/(1-decay), so a brisk 200 deg/s release is a few matches - // and the hardest flick the cap allows is bounded well short of a hundred. const travel = struct { fn of(speed: f32) f32 { return @abs(rotationFling(speed)) * rotation_fling_step / (1 - rotation_fling_decay); @@ -2872,45 +2515,26 @@ test "the dial flings in proportion to the release, and not at all when placed" }.of; try std.testing.expect(travel(200) / rotation_notch_degrees < 5); try std.testing.expect(travel(200) / rotation_notch_degrees >= 3); - // ...and the hardest flick a trackpad can report is bounded at about a - // dozen matches. This is the number to change if the dial ever feels like - // it is getting away from the hand. try std.testing.expect(travel(100_000) / rotation_notch_degrees < 12); try std.testing.expect(travel(100_000) / rotation_notch_degrees > 8); } -// The loop, end to end, on the one machine that can run it: the core owns the -// iteration now, so the two things this file used to spell out by hand are -// exactly what a live session has to keep proving. A frame exists because the -// DRAW rendered one — ticks drain work and never render, which is what lets -// AppKit coalesce a burst into a single encoded grid — and elapsed animation -// time is spent only by the display clock, however many times the tick runs. -// -// It really boots: a shell is forked, an inbox drains, effects are performed -// through the vtable. Everything above it is the Swift app, which needs a Mac. test "a live session renders on the draw and animates only on the display clock" { try std.testing.expectEqual(@as(c_int, 0), pardes_init(null, 80, 24)); defer pardes_deinit(); const st = &state.?; - // The spawn effect reached forkpty rather than the core's silent fallback: - // init performs its own drain, before any reader task exists. try std.testing.expect(st.ptys[0] != null); - // A tick drains and performs. It publishes no frame, so ten of them in a - // pty burst cost one render and not ten. _ = pardes_tick(); _ = pardes_tick(); try std.testing.expectEqual(@as(u16, 0), pardes_frame_cols()); try std.testing.expect(pardes_frame_cells() == null); - // The draw is what renders and presents. try std.testing.expectEqual(@as(u32, 80 * 24), pardes_frame()); try std.testing.expectEqual(@as(u16, 80), pardes_frame_cols()); try std.testing.expectEqual(@as(u16, 24), pardes_frame_rows()); try std.testing.expect(pardes_frame_cells() != null); - // Two themes, so the second retarget is a real transition whatever the - // developer's config booted this session wearing. for ([_][]const u8{ "Theme dark", "Theme acme" }) |command| { pardes_command(command.ptr, command.len); _ = pardes_tick(); @@ -2918,16 +2542,12 @@ test "a live session renders on the draw and animates only on the display clock" } try std.testing.expect(pardes_animating()); const step = st.core.chrome_animation.step; - // Input and pty pumps drain work and draws encode it; neither spends a - // frame, which is what keeps a burst of keys from collapsing a ten-frame - // fade into one. _ = pardes_tick(); _ = pardes_frame(); _ = pardes_tick(); _ = pardes_frame(); try std.testing.expectEqual(step, st.core.chrome_animation.step); try std.testing.expectEqual(@as(usize, 0), st.core.in_len); - // Only the display clock spends it, and exactly one frame per call. try std.testing.expect(pardes_animation_tick()); try std.testing.expectEqual(step + 1, st.core.chrome_animation.step); _ = pardes_tick(); |
