diff options
Diffstat (limited to 'src/macos.zig')
| -rw-r--r-- | src/macos.zig | 565 |
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" { |
