summaryrefslogtreecommitdiff
path: root/src/macos.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-01 14:34:19 -0300
committerGabriel Schneider <[email protected]>2026-09-02 16:35:21 -0300
commit3f2d6f43199d0e230490396deb50f8dc49c7b8b0 (patch)
tree309d464fe96487545082920b4080639576644bdf /src/macos.zig
parentcce6b18a49870086982f9a0e1fda90ed170b9fba (diff)
downloadpardes-3f2d6f43199d0e230490396deb50f8dc49c7b8b0.tar.gz
pardes-3f2d6f43199d0e230490396deb50f8dc49c7b8b0.zip
hosts: the effects three shells kept a copy of become one, and the mac's own bugs go with them
Nine read-only scouts compared every host-side concern across `src/macos.zig`, `src/tty/tty.zig`, `src/gui/gui.zig` and `src/detached/server.zig`. What they found was not a style problem: each duplicated body had drifted, and in every case the drift WAS a bug the users of that shell could see. So the fixes and the deduplication are the same change. **One PATH, adopted before the first fork.** LaunchServices hands a bundle launchd's environment, whose `PATH` is `/usr/bin:/bin:/usr/sbin:/sbin`. Every pty shell, `|` filter and language server the app forked inherited it, so `yazi` in `/opt/homebrew/bin` was absent from a Dock launch and present in the identical binary run from a terminal — the "it worked briefly" window was simply the sessions started from a shell. `shell_bin.adoptSystemPath` composes `/etc/paths` then `/etc/paths.d/*` in the order `path_helper` reads them, deduplicating on first occurrence, and runs once at startup in all four native hosts. It APPENDS: an entry already present keeps its position, so running it over a real session cannot demote a mise shim behind `/usr/bin` and silently change which `node` runs. A `PATH` that was configured is left byte-for-byte alone; only one nobody configured is repaired. `prepareForFork` folds that adoption together with the prompt-rc staging and the `BASH_SILENCE_DEPRECATION_WARNING` setenv the five hand-copied prefork sites had between them — `server.zig` had none of it, which is why every detached pane opened with Apple's zsh banner. **The LSP protocol client never worked on macOS.** It opened its control socket with `libc.SOCK.CLOEXEC`; Zig defines that constant for Linux and Darwin answers `socketpair` with `EPROTONOSUPPORT`, so the call failed before any fork, `ensure` returned `error.NoServer`, and every row in the spec table — rust-analyzer, clangd, gopls — was unreachable in every macOS build. The in-process ZLS backend kept answering, which is what made it read as "only Zig is supported". It is a plain socket plus `fcntl(FD_CLOEXEC)` now, the route `fuse.zig:943` and `nested.zig:95` already took for the same reason. The snapshot suite that covered this path had never run natively on a Mac: the harness targets defaulted to x86_64-linux. **One LSP host worker.** `src/lsp_host.zig` is the snapshot, the worker body and the job lifetime that `tty.zig` and `gui.zig` carried verbatim — `gui.zig` said so in a comment — and that `macos.zig` did not carry at all: `lsp` and `pipe` were absent from its `Host.VTable`, so the core answered its own empty answer, `SPC l i` rendered a blank panel and a `|` filter silently did nothing. All three shells share the module, and the AppKit host implements both effects. Its status sink is now REGISTERED as well as defined, so unsolicited server news reaches the message row instead of nowhere. **The animation clock measures time.** `pardes_animation_tick` advanced one scene frame per callback and published `frame_count / 60`, so scene time was a count of callbacks rather than elapsed seconds — and `AppDelegate` re-armed `asyncAfter(.now() + 0.016)` only after the previous frame's work had finished, making the true period 16 ms plus all of it. Motion ran at about three quarters of wall clock and unevenly. The tick now spends measured monotonic time in whole `frame_ns` steps and banks the remainder, so a late callback advances two frames instead of stretching one; `spendTickTime` is that arithmetic as a pure function with its own tests and no display attached. On macOS 14+ the animating run is one `CADisplayLink` phase-locked to vsync rather than a chain rebuilt after every frame; macOS 13 keeps the old chain. **Three more single definitions.** `panel_animation.paintOrder` is the moving-then-opening-then-closing composite order as a rule the core applies once in `Pardes.render` — `macos.zig` was re-sorting an already-sorted list. `selection_pipe.Tasks` is the bounded in-flight pipe table `tty.zig` and `gui.zig` each declared. `boxContains` was a fourth copy of the half-open cell test and is now an alias of `Box.contains`. **A filtered terminal stops asking libm per cell.** `Filter`'s legibility stage called `RGB.contrast` for every painted cell, and that ends in `std.math.pow` up to six times, re-deriving a ratio against a background that had not moved; the existing memo cache covered the palette reduction beside it and never this. The indexed path's input is a `u8`, so all 256 answers are enumerated once per pass — after the default roles are fixed, before the first cell is read — and what a cell names becomes an array index. Only truecolour still reduces. ReleaseFast, 190x56, Tracy: recolour 3.09 ms -> 0.130 ms, frame 3.37 ms -> 0.299 ms. The comptime luminance table is pinned to `RGB.luminance` and `RGB.contrast` by exact-equality test over every channel value and all 65 536 palette pairs, because the decision is a threshold comparison where one ULP is a different colour. A `filterInit` Tracy zone records the part that is still per-pass: 2.9 us warm against a 117 us pass, which is the measurement that says not to cache it across frames. Released as 0.0.2. `build.zig.zon` carries the version into `pardes --version` and into the `Changelog` pane through `@embedFile`, so the entries above open a `## 0.0.2` section and `## 0.0.1` closes with the tagline work of the parent commit. Two bugs here were mine, caught by review rather than by me: a double free in the macOS pipe drain arm (`Msg.free` already owns the response) that segfaulted the app on the first `|`, and a proposed `getRowAndCell` optimisation that targeted 2 of 43 draw samples while the contrast math beside it took 12 — and would not have compiled. The profile that justified it was a Debug build, which `build.zig:1160` already documents as ~5x slower than release. Native and -Dplatform=macos suites: 0 failures. All targets build with Tracy on and off; the shipped release binary contains no `___tracy_emit_zone_begin`. App reinstalled, signature verified, dmg regenerated, launched with 0 crash reports; installed binaries verified byte-identical to a fresh build.
Diffstat (limited to 'src/macos.zig')
-rw-r--r--src/macos.zig565
1 files changed, 474 insertions, 91 deletions
diff --git a/src/macos.zig b/src/macos.zig
index 4d59fb90..4c7eb97f 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -36,6 +36,11 @@ const file_watch = @import("file_watch.zig");
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
extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
@@ -164,10 +169,20 @@ 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;
-/// Keep the float-valued time and noise frame precise, then repeat after a
-/// little over an hour. None of the effects has state across this boundary.
-const scene_frame_wrap: u32 = 4096 * scene_frame_hz;
+
+/// 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 theme_watch_pane: u8 = @intCast(pardes.MAX_PANES);
@@ -380,16 +395,34 @@ const Msg = union(enum) {
/// 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_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_status => |t| gpa.free(t),
+ .pipe => |r| {
+ var response = r;
+ response.deinit(gpa);
+ },
}
}
};
+
const inbox_capacity = 512;
const MessageBatch = struct {
@@ -443,8 +476,8 @@ const Inbox = struct {
}
if (q.len == q.items.len) {
const lossy = switch (m) {
- .output => true,
- .eof, .command => false,
+ .output, .lsp_status => true,
+ .eof, .command, .lsp_done, .pipe => false,
};
if (lossy) {
m.free(gpa);
@@ -453,8 +486,8 @@ const Inbox = struct {
var offset: usize = 0;
while (offset < q.len) : (offset += 1)
if (switch (q.items[(q.head + offset) % q.items.len]) {
- .output => true,
- .eof, .command => false,
+ .output, .lsp_status => true,
+ .eof, .command, .lsp_done, .pipe => false,
}) break;
if (offset == q.len) return;
q.removeAt(offset).free(gpa);
@@ -523,6 +556,13 @@ const State = struct {
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.
+ 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
@@ -548,9 +588,25 @@ const State = struct {
/// 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,
- /// Display-clock time for the persistent Core Image scene pass. Input and
- /// pty pumps never spend it; pardes_animation_tick is the only writer.
- scene_frame: u32 = 0,
+ /// 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
@@ -641,13 +697,11 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
// capability, because there it is a question rather than a fact.
core.native_images = true;
- // Complete private files before any fork; State retains their path buffers
- // for every later shell spawn and removes the files at app teardown.
- var prompt_rcs = shell_bin.PromptRcs.init();
+ // 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();
errdefer prompt_rcs.deinit();
- // Apple's bash 3.2 prints the zsh-deprecation banner into every pane unless
- // this is in the environment BEFORE bash starts — the rc file is too late.
- if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1);
state = .{
.gpa = gpa,
@@ -683,6 +737,13 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
st.started = true;
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 —
@@ -728,6 +789,17 @@ export fn pardes_deinit() void {
// 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.
+ pardes.lsp.setStatusSink(null, null);
+ if (st.lsp_task) |*t| {
+ t.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.
@@ -781,8 +853,75 @@ fn currentSceneFlags(st: *const State) u32 {
return encodeSceneEffects(st.core.settings.scene_effects);
}
-fn advanceSceneFrame(frame: *u32) void {
- frame.* = (frame.* + 1) % scene_frame_wrap;
+/// 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
+ 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;
+ 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 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) |_| {
+ const partial = spendTickTime(1_000, 1_000 + 6 * std.time.ns_per_ms, bank);
+ bank = partial.bank_ns;
+ steps += partial.steps;
+ }
+ 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
@@ -846,6 +985,30 @@ 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
@@ -858,6 +1021,47 @@ 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;
+}
+
+export fn pardes_fallback_font_name(index: u32, len: *u32) ?[*]const u8 {
+ if (index >= fonts.fallback_names.len) {
+ len.* = 0;
+ return null;
+ }
+ const name = fonts.fallback_names[index];
+ len.* = @intCast(name.len);
+ return name.ptr;
+}
+
test "tagline percent falls back before init and follows live core state" {
try std.testing.expectEqual(pardes.config.gui_tagline_font_percent, taglineFontPercent(null));
@@ -868,15 +1072,42 @@ test "tagline percent falls back before init and follows live core state" {
try std.testing.expectEqual(changed, taglineFontPercent(core));
}
+test "the fallback preference order crosses the ABI intact and ends at the boundary" {
+ 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
-/// frame based, just like pane/theme transitions: it advances on the scheduled
-/// display callback and never on an input or pty drain.
+/// 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 = @as(f32, @floatFromInt(st.scene_frame)) / @as(f32, @floatFromInt(scene_frame_hz)),
- .frame = st.scene_frame,
+ .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))),
};
}
@@ -889,7 +1120,7 @@ export fn pardes_postprocessor_unavailable() void {
st.core.disableSceneEffects();
st.core.settings.panel_transition = .off;
st.core.abandonPanelAnimations();
- st.scene_frame = 0;
+ st.scene_ns = 0;
}
/// One transient postprocess submission failed and the host will draw the
@@ -1141,6 +1372,38 @@ fn drainInbox(st: *State) bool {
// 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 {};
+ 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.setMessage(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,
+ .success = value.success,
+ .outputs = value.outputs,
+ } });
+ st.pipe_tasks.finish(st.io, value.id);
+ },
}
}
for (0..pardes.MAX_PANES) |pane| {
@@ -1187,34 +1450,59 @@ export fn pardes_tick() bool {
return did;
}
-/// Advance exactly one display-clock frame. Event pumps deliberately never
-/// call this: a burst of key, mouse, or pty notifications is work to drain,
-/// not elapsed animation time.
+/// 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.
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;
+ st.tick_bank_ns = spend.bank_ns;
+
var changed = false;
- if (st.core.animationActive()) {
- st.core.update(.tick);
- changed = true;
- }
if (currentSceneFlags(st) != 0) {
- advanceSceneFrame(&st.scene_frame);
+ // 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;
}
- // ...and the dial, for the same reason and off the same clock: one frame
- // of coast per tick, decayed, until it is slower than a notch a second.
- if (st.rotate_coasting) {
- spendRotation(st, st.rotate_velocity * rotation_fling_step);
- st.rotate_velocity *= rotation_fling_decay;
- 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;
+
+ // 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);
+ changed = true;
}
- changed = true;
+ if (st.rotate_coasting) {
+ spendRotation(st, st.rotate_velocity * rotation_fling_step);
+ st.rotate_velocity *= rotation_fling_decay;
+ 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;
}
return changed;
}
@@ -1430,11 +1718,16 @@ export fn pardes_resize(cols_arg: u16, rows_arg: u16, cell_w: u16, cell_h: u16)
/// 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();
st.core.pump(hostFor(st)) catch |err| {
log.err("render failed: {t}", .{err});
clearFrame(st);
return 0;
};
+ tracy.frameMark();
return @intCast(st.frame_len);
}
@@ -1454,6 +1747,8 @@ fn clearFrame(st: *State) void {
/// 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");
+ defer tz.end();
clearFrame(st);
const count: usize = @as(usize, surface.cols) * surface.rows;
if (count != st.cells.len) {
@@ -1471,29 +1766,33 @@ fn presentFrame(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
st.frame_len = count;
st.frame_cols = surface.cols;
st.frame_rows = surface.rows;
- for (surface.cells, st.cells[0..count]) |cell, *out| out.* = encodeCell(cell);
+ {
+ // 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);
+ }
collectPanelDiff(st, surface, count);
collectImages(st, surface);
collectPanelTracks(st, surface);
}
-/// Flatten tracks into the C-visible order the shader composites them. Pane
-/// slots are stable tie-breakers because Surface publishes them in slot order.
+/// 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 {
- st.panel_tracks_len = copyPanelTracksInPaintOrder(surface.panelTracks(), &st.panel_tracks);
-}
-
-fn copyPanelTracksInPaintOrder(source: []const PanelTrack, out: []PanelTrack) usize {
- var len: usize = 0;
- for ([_]panel_animation.Phase{ .moving, .opening, .closing }) |phase| {
- for (source) |track| {
- if (!track.active() or track.phase != phase) continue;
- std.debug.assert(len < out.len);
- out[len] = track;
- len += 1;
- }
- }
- return len;
+ const source = surface.panelTracks();
+ const len = @min(source.len, st.panel_tracks.len);
+ @memcpy(st.panel_tracks[0..len], source[0..len]);
+ st.panel_tracks_len = len;
}
/// Copy the old/new semantic transition data as one all-or-nothing snapshot.
@@ -1775,18 +2074,19 @@ fn activeFilePath(st: *State) ?[]const u8 {
/// * `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.
-/// * `lsp` — the core's own empty answer is exactly what this host replied,
-/// and for the same reason: a dropped request leaves lsp_wait armed and
-/// every later dot-Tab dead.
-/// * `pipe` — no worker to hand a job to. 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.
+/// * `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.
///
-/// ponytail: lsp and pipe still do no work. Each wants real machinery — a
-/// worker plus a snapshot of the pane's file for lsp (src/tty/tty.zig:919), and
-/// a job copy for pipe. Watch is deliberately different: 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.
+/// `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,
@@ -1803,12 +2103,83 @@ const vtable: pardes.Host.VTable = .{
.push_set_clipboard = setClipboard,
.pull_read_clipboard = readClipboard,
.push_open_link = openLink,
+ .pull_lsp = lspRequest,
+ .pull_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 {
+ 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.
+ if (st.lsp_task) |*old| {
+ old.cancel(st.io) catch {};
+ st.lsp_task = null;
+ }
+ st.lsp_task = st.io.concurrent(lspWorker, .{ st, job }) catch {
+ job.free(st.gpa);
+ return;
+ };
+}
+
+/// 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()) {
+ st.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } });
+ 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 {
+ job.deinit(st.gpa);
+ return;
+ };
+ std.debug.assert(st.pipe_tasks.add(.{ .id = id, .future = future }));
+}
+
+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 });
+ wake(st);
+}
+
+fn lspWorker(st: *State, job: *lsp_host.Job) anyerror!void {
+ lsp_host.work(st.gpa, job, st, deliverLspRows);
+}
+
+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 } });
+ 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 });
+ wake(st);
+}
+
fn hostState(ctx: ?*anyopaque) *State {
return @ptrCast(@alignCast(ctx.?));
}
@@ -2173,7 +2544,12 @@ test "pardes.h declares every export the way it is defined" {
try expectSameAbi(@TypeOf(c.pardes_gui_tagline_font_percent), @TypeOf(pardes_gui_tagline_font_percent));
try expectSameAbi(@TypeOf(c.pardes_tagline_band_offset), @TypeOf(pardes_tagline_band_offset));
try expectSameAbi(@TypeOf(c.pardes_topbar_pane_border_px), @TypeOf(pardes_topbar_pane_border_px));
+ try expectSameAbi(@TypeOf(c.pardes_tagline_origin_col), @TypeOf(pardes_tagline_origin_col));
+ try expectSameAbi(@TypeOf(c.pardes_grid_col_at), @TypeOf(pardes_grid_col_at));
try expectSameAbi(@TypeOf(c.pardes_topbar_pane_border_rgb), @TypeOf(pardes_topbar_pane_border_rgb));
+ try expectSameAbi(@TypeOf(c.pardes_tagline_bg), @TypeOf(pardes_tagline_bg));
+ try expectSameAbi(@TypeOf(c.pardes_fallback_font_count), @TypeOf(pardes_fallback_font_count));
+ try expectSameAbi(@TypeOf(c.pardes_fallback_font_name), @TypeOf(pardes_fallback_font_name));
try expectSameAbi(@TypeOf(c.pardes_scene), @TypeOf(pardes_scene));
try expectSameAbi(@TypeOf(c.pardes_postprocessor_unavailable), @TypeOf(pardes_postprocessor_unavailable));
try expectSameAbi(@TypeOf(c.pardes_panel_animation_failed), @TypeOf(pardes_panel_animation_failed));
@@ -2336,32 +2712,39 @@ test "scene effect flags and display clock are compact and independent" {
encodeSceneEffects(.{ .crt = true, .ripple = true, .glitch = true }),
);
- var frame: u32 = scene_frame_wrap - 1;
- advanceSceneFrame(&frame);
- try expectEqual(@as(u32, 0), frame);
- advanceSceneFrame(&frame);
- try expectEqual(@as(u32, 1), frame);
+ // 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);
+ try expectEqual(@as(u64, 0), st.scene_ns);
+ advanceSceneClock(&st, std.time.ns_per_ms * 7);
+ try expectEqual(@as(u64, std.time.ns_per_ms * 7), st.scene_ns);
}
-test "mac panel ABI paint order includes closing tombstones after live panes" {
+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 = 11, .pane = 3, .phase = .opening, .effect = .slide },
.{ .serial = 12, .pane = 1, .phase = .moving, .effect = .zoom },
- .{ .serial = 13, .pane = 0, .phase = .moving, .effect = .off },
- .{ .serial = 14, .pane = 5, .phase = .opening, .effect = .ascii },
.{ .serial = 15, .pane = 2, .phase = .moving, .effect = .dissolve },
+ .{ .serial = 11, .pane = 3, .phase = .opening, .effect = .slide },
.{ .serial = 16, .pane = 2, .phase = .closing, .effect = .vertical },
};
- var ordered: [source.len]PanelTrack = undefined;
- const len = copyPanelTracksInPaintOrder(&source, &ordered);
- try std.testing.expectEqual(@as(usize, 5), len);
- try std.testing.expectEqualSlices(u32, &.{ 12, 15, 11, 14, 16 }, &.{
- ordered[0].serial,
- ordered[1].serial,
- ordered[2].serial,
- ordered[3].serial,
- ordered[4].serial,
- });
+ var st: State = undefined;
+ st.panel_tracks = undefined;
+ st.panel_tracks_len = 0;
+ var surface: pardes.Surface = std.mem.zeroes(pardes.Surface);
+ @memcpy(surface.panel_tracks[0..source.len], &source);
+ surface.npanel_tracks = source.len;
+
+ collectPanelTracks(&st, &surface);
+ try std.testing.expectEqual(source.len, st.panel_tracks_len);
+ for (source, st.panel_tracks[0..st.panel_tracks_len]) |want, got|
+ try std.testing.expectEqual(want.serial, got.serial);
}
test "mac panel mask is a literal normalized grayscale texture" {