summaryrefslogtreecommitdiff
path: root/src/macos.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /src/macos.zig
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz
pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'src/macos.zig')
-rw-r--r--src/macos.zig1598
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();