summaryrefslogtreecommitdiff
path: root/src/macos.zig
diff options
context:
space:
mode:
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" {