From 3f2d6f43199d0e230490396deb50f8dc49c7b8b0 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 1 Sep 2026 14:34:19 -0300 Subject: hosts: the effects three shells kept a copy of become one, and the mac's own bugs go with them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/CHANGELOG.md | 75 +++++ src/config.zig | 29 ++ src/detached/server.zig | 9 +- src/gui/gui.zig | 221 ++++---------- src/lsp/lsp_client.zig | 87 +++++- src/lsp_host.zig | 206 +++++++++++++ src/macos.zig | 569 ++++++++++++++++++++++++++++++------ src/macos/Sources/AppDelegate.swift | 63 +++- src/macos/Sources/PardesView.swift | 237 +++++++++++++-- src/macos/icon.png | Bin 0 -> 1788282 bytes src/macos/icon.swift | 252 +++++----------- src/macos/pardes.h | 32 ++ src/panel_animation.zig | 38 +++ src/pardes.zig | 235 +++++++++++++-- src/selection_pipe.zig | 49 ++++ src/shell_bin.zig | 221 ++++++++++++++ src/term_pane.zig | 361 +++++++++++++++++++++-- src/tty/tty.zig | 152 ++-------- 18 files changed, 2194 insertions(+), 642 deletions(-) create mode 100644 src/lsp_host.zig create mode 100644 src/macos/icon.png (limited to 'src') diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 71e47f82..714497b6 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,5 +1,80 @@ # Changelog +## 0.0.2 + +- A filtered terminal costs what an unfiltered one does. `Filter`'s second + stage asked `RGB.contrast` for every cell it painted, and that call ends in + `std.math.pow` six times over — a libm round trip per cell, per frame, to + re-derive a ratio against a background that had not moved. The indexed path + takes a `u8`, so all 256 of its answers are now enumerated once per pass, + after the two default roles are fixed and before the first cell is read; + what a cell names is an array index into them. Only truecolour, whose + 16.7M inputs cannot be tabulated, still reduces, and the direct-mapped RGB + cache is what keeps that cheap. Tracy over the AppKit shell, ReleaseFast, + 190x56: the recolour pass falls 3.09 ms to 0.130 ms and the whole frame + 3.37 ms to 0.299 ms. The luminance table is comptime-evaluated from + ghostty's own expression and a test pins it to `RGB.luminance` and + `RGB.contrast` exactly, across every channel value and all 65 536 palette + pairs, because the filter's decision is a threshold comparison where one + ULP is a different colour on screen. +- A pardes launched from the Dock finds the same programs a pardes launched + from a terminal does. LaunchServices hands a bundle launchd's own + environment, whose `PATH` is `/usr/bin:/bin:/usr/sbin:/sbin`, and every pty + shell, `|` filter and language server inherited it — so `yazi` in + `/opt/homebrew/bin` was missing in the app and present in the same build run + from a shell, which reads as "the Dock build is broken". `shell_bin` + composes `/etc/paths` then `/etc/paths.d/*` in the order `path_helper` + reads them and adopts the result before the first fork in all four native + hosts. It appends, so an inherited entry is never demoted and a configured + `PATH` is left byte-for-byte alone: the rule is that only a `PATH` nobody + configured gets repaired. +- Language servers other than ZLS start on macOS. The protocol client opened + its control socket with `SOCK.CLOEXEC`, which Zig defines for Linux and + which Darwin answers with `EPROTONOSUPPORT` — so `socketpair` failed before + any fork, every spec in the table reported "no server", and rust-analyzer, + clangd and gopls were unreachable in every macOS build. It is a plain + socket plus `fcntl(FD_CLOEXEC)` now, the route `fuse.zig` and `nested.zig` + already used. Verified end to end against a 200-crate Rust workspace: + rust-analyzer indexes, and `gd`, `document_symbols` and `hover` answer. +- `SPC l i` reports what the language backends are doing in the macOS app, + and `|` filters a selection there. Both effects were unimplemented host + methods — `lsp` and `pipe` were absent from the AppKit vtable, so the core + answered its own empty answer and the panel rendered blank while a sort + filter silently did nothing. The snapshot-plus-worker body each shell had + its own copy of is one module (`src/lsp_host.zig`), and the in-flight pipe + table is `selection_pipe.Tasks`; all three native shells share both. + Unsolicited server news ("rust-analyzer indexing 45%") reaches the message + row because the status sink is registered rather than merely defined. +- Animation runs at the speed it claims. `pardes_animation_tick` advanced one + scene frame per callback and reported time as `frame_count / 60`, so + animation time was a count of how often the callback happened rather than + how much time had passed — and the AppKit chain re-armed `asyncAfter(.now() + + 0.016)` only AFTER the previous frame's work, making the real period 16 ms + plus all of it. Motion ran at roughly three quarters of wall clock, + unevenly. The tick measures elapsed monotonic time and spends it in whole + 16 ms steps, banking the remainder, so a late callback advances two frames + instead of stretching one; the arithmetic is a pure function with its own + tests, no display attached. On macOS 14 and later the run is driven by one + `CADisplayLink` phase-locked to vsync instead of a chain rebuilt per frame. +- A filtered terminal's colours are mapped in two stages, and the second one + keeps text off the page. `Filter` reduces every colour to its nearest + canonical xterm key and reads that key out of ghostty-vt's theme-derived + 256-colour projection — a comparison of RGB triples, which knows about hue + and nothing about the background. The projection is generated FROM the + default foreground and background, so its cube corners are those two + anchors, and the nearest key to a truecolour extreme was therefore the + background itself: `\x1b[38;2;255;255;255m` on acme's `#ffffea` paper + resolved to `#ffffea`, a WCAG ratio of 1.000, text painted the colour of the + page under it. Every curated theme owned such a key — 231 on the light one, + ANSI black on both dark ones, which is a bare `\x1b[30m`. The two default + roles are now mapped first and named as the anchors they are, and every + other FOREGROUND has to clear `config.tty_filter_min_contrast` (1.5) against + the mapped background; one that cannot is not mapped at all, and takes + whichever anchor is still visible there. Backgrounds are exempt, because a + background is the page the floor is measured against. Measured across the + curated three the floor refuses 12, 16 and 7 of 256 keys, where a WCAG + body-text 4.5 would refuse 61, 154 and 91 and flatten the palette. + ## 0.0.1 - Pane taglines sit where they do in the SDL window. The macOS shell had its own diff --git a/src/config.zig b/src/config.zig index a7d214b3..e6597881 100644 --- a/src/config.zig +++ b/src/config.zig @@ -433,6 +433,35 @@ pub const tty_paste_clipboard: []const Chord = &.{ /// terminal to read as output and nothing else. pub const tty_blank: enum { prompt, prompt_and_input } = .prompt; +/// The WCAG contrast ratio a filtered terminal foreground has to keep against +/// the default background before `Filter` will paint it in the theme colour +/// the projection chose. 1.0 is "the same colour"; 21.0 is black on white. +/// +/// `Filter` maps the default foreground and background roles FIRST — they are +/// the anchors Ghostty generates the 256-colour projection from — and every +/// other colour after them, by reducing it to its nearest canonical xterm key +/// and reading that key out of the projection. That reduction is a distance +/// between two RGB triples: it knows about hue and nothing about the page. The +/// cube's own corners ARE the two anchors, so the nearest key to a truecolour +/// extreme is the background itself — `\x1b[38;2;255;255;255m` on acme's +/// #ffffea paper resolved to #ffffea, ratio 1.000, text painted the colour of +/// the page under it. Every curated theme owns such a key: 231 on the light +/// one, 0 (ANSI black, which a shell writes with `\x1b[30m`) on both dark ones. +/// +/// A foreground that misses this floor is not mapped. It takes whichever of +/// the theme's own two anchors contrasts BETTER with the background actually +/// behind it, which is the choice the vendored renderer's `contrasted_color` +/// makes between white and black for the same reason. +/// +/// 1.5 is deliberately low: the point is legibility, not WCAG body text, and a +/// theme's comment and dim colours are MEANT to sit close to the page. Measured +/// across the curated three it rejects 12, 16 and 7 of 256 keys, where 3.0 +/// would reject 34, 92 and 41 and flatten a third of the dark palette. It also +/// has to stay below the contrast a theme's own pair achieves — 4.71 on `dark` +/// — or the fallback would fail the very test it answers. 1.0 accepts every +/// projected colour, collapses included. +pub const tty_filter_min_contrast: f64 = 1.5; + // ---- the tag line and the topbar ---- // The topbar is a HAND-PICKED subset in a fixed order, not a derivation: row 0 diff --git a/src/detached/server.zig b/src/detached/server.zig index 2c4df87b..bc293dce 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -2013,7 +2013,14 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void // Staged before the first fork and owned by the Session for exactly as // long as it can fork: `shell_bin.resolve` hands a child pointers into // these buffers, and the child holds them until it execs. - .prompt_rcs = .init(), + // + // `prepareForFork` and not `PromptRcs.init` alone: this host forks bash + // through the same `resolve` its siblings do and was the one that never + // silenced Apple's zsh-migration banner, so every pane in a detached + // session on macOS opened with it printed across the top. It also had + // no `adoptSystemPath`, which a daemon needs more than anyone — it is + // the host most likely to be started by launchd. + .prompt_rcs = shell_bin.prepareForFork(), }; defer session.deinit(); if (!session.listen(name)) { diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 8442c197..44e28125 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -930,25 +930,10 @@ const Msg = union(enum) { } }; -/// One language query, owned by the thread running it — the gui twin of -/// tty.zig's LspJob, and copied for the same reason: the core edits on. -const LspJob = struct { - id: u32, - kind: pardes.lsp.Kind, - offset: u32, - path: []u8, - source: [:0]u8, - arg: []u8, - root: []u8, - - fn free(j: *LspJob, gpa: std.mem.Allocator) void { - gpa.free(j.path); - gpa.free(j.source); - gpa.free(j.arg); - gpa.free(j.root); - gpa.destroy(j); - } -}; +/// The shared snapshot/worker pair. This file carried its own `LspJob` with +/// "tty.zig's LspJob, and copied for the same reason" over the top; both copies +/// are now one module, and the AppKit shell — which had neither — uses it too. +const lsp_host = @import("../lsp_host.zig"); const LspWorkers = struct { active: std.atomic.Value(usize) = .init(0), @@ -972,36 +957,10 @@ const LspWorkers = struct { const max_pipe_tasks = 16; -const PipeTask = struct { - id: u32, - future: std.Io.Future(anyerror!void), -}; - -const PipeTasks = struct { - items: [max_pipe_tasks]PipeTask = undefined, - len: usize = 0, - - fn add(tasks: *PipeTasks, task: PipeTask) bool { - if (tasks.len == tasks.items.len) return false; - tasks.items[tasks.len] = task; - tasks.len += 1; - return true; - } - - fn finish(tasks: *PipeTasks, io: std.Io, id: u32) void { - for (tasks.items[0..tasks.len], 0..) |*task, i| if (task.id == id) { - task.future.await(io) catch {}; - tasks.len -= 1; - std.mem.copyForwards(PipeTask, tasks.items[i..tasks.len], tasks.items[i + 1 .. tasks.len + 1]); - return; - }; - } - - fn cancelAll(tasks: *PipeTasks, io: std.Io) void { - for (tasks.items[0..tasks.len]) |*task| task.future.cancel(io) catch {}; - tasks.len = 0; - } -}; +/// Moved to `selection_pipe.Tasks`, beside the Job it tracks — tty.zig carried +/// this same table verbatim. +const PipeTask = selection_pipe.Tasks.Task; +const PipeTasks = selection_pipe.Tasks; const queue_capacity = 512; @@ -1192,27 +1151,18 @@ fn lookThread(gpa: std.mem.Allocator, fd: c_int, q: *Queue) void { } } -/// Answer a language query off the render loop and push the rows to the queue -/// — the async execution model, spelled in the plumbing this shell already has -/// (a detached thread and the mutex queue the pty readers use). -fn lspThread(lsp_allocator: std.mem.Allocator, workers: *LspWorkers, job: *LspJob, q: *Queue) void { +/// Answer a language query off the render loop and push the rows to the queue. +/// The snapshot and the query body are `lsp_host`'s; what stays here is this +/// shell's own plumbing — a detached thread, the refcount that teardown joins +/// on, and the mutex queue the pty readers already use. +fn lspThread(lsp_allocator: std.mem.Allocator, workers: *LspWorkers, job: *lsp_host.Job, q: *Queue) void { defer workers.finish(); - defer job.free(lsp_allocator); - var arena: std.heap.ArenaAllocator = .init(lsp_allocator); - defer arena.deinit(); - // the shell owns the result buffer; the backend only ever writes to it - var out: std.Io.Writer.Allocating = .init(lsp_allocator); - defer out.deinit(); - pardes.lsp.query(lsp_allocator, arena.allocator(), .{ - .kind = job.kind, - .path = job.path, - .source = job.source, - .offset = job.offset, - .arg = job.arg, - .root = job.root, - }, &out.writer); - const rows = lsp_allocator.dupe(u8, out.written()) catch return; - q.push(.{ .lsp = .{ .id = job.id, .rows = rows } }); + lsp_host.work(lsp_allocator, job, q, pushLspRows); +} + +fn pushLspRows(ctx: ?*anyopaque, id: u32, rows: []u8) void { + const q: *Queue = @ptrCast(@alignCast(ctx orelse return)); + q.push(.{ .lsp = .{ .id = id, .rows = rows } }); } /// Copy the query out of the core and hand it to a thread. A detached thread @@ -1220,38 +1170,7 @@ fn lspThread(lsp_allocator: std.mem.Allocator, workers: *LspWorkers, job: *LspJo /// already tolerates a late push after close. fn spawnLsp(core: *pardes.Pardes, q: *Queue, e: host_api.LspRequest) void { const lsp_allocator = q.lsp_allocator; - const pane = core.panes[e.pane] orelse return; - // a pane with no file still asks `status` (it is about the backend, not - // the buffer): empty path and source, root from the pane's cwd - const f = pane.file; - const job = lsp_allocator.create(LspJob) catch return; - job.* = .{ - .id = e.id, - .kind = e.kind, - .offset = e.offset, - .path = lsp_allocator.dupe(u8, if (f) |ff| ff.path else "") catch { - lsp_allocator.destroy(job); - return; - }, - .source = lsp_allocator.dupeZ(u8, if (f) |ff| ff.content else "") catch { - lsp_allocator.free(job.path); - lsp_allocator.destroy(job); - return; - }, - .arg = lsp_allocator.dupe(u8, e.arg) catch { - lsp_allocator.free(job.path); - lsp_allocator.free(job.source); - lsp_allocator.destroy(job); - return; - }, - .root = lsp_allocator.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch { - lsp_allocator.free(job.path); - lsp_allocator.free(job.source); - lsp_allocator.free(job.arg); - lsp_allocator.destroy(job); - return; - }, - }; + const job = lsp_host.snapshot(lsp_allocator, core, e) orelse return; q.lsp_workers.start(); const th = std.Thread.spawn(.{}, lspThread, .{ lsp_allocator, q.lsp_workers, job, q }) catch { q.lsp_workers.finish(); @@ -1582,13 +1501,19 @@ fn compactTaglineLayout(g: *const Gui, origin_col: f32) CellLayout { }; } -/// Where a tagline cell's compact band begins. `core` is null in an attached -/// window, and then EVERY tagline cell takes the last line's fallback: the -/// wire carries cells, not the pane rects that placed them, so there is no -/// band origin to compact against. That is the same answer `gridCellAtDimensions` -/// reaches for the same reason, which is what keeps the two honest — a click -/// lands on the glyph it was aimed at, because both sides map through the body -/// grid. The visible cost is one tagline row's worth of loose tracking. +/// Where a tagline cell's compact band begins. The origin rule itself is +/// `pardes.taglineOriginCol` — moved to the core so the AppKit shell can call +/// the SAME rule over the C ABI instead of advancing its tag rows on body +/// pitch, which is the second copy of this that already went wrong once (see +/// `taglineBandOffset`). +/// +/// `core` is null in an attached window, and then EVERY tagline cell takes the +/// last line's fallback: the wire carries cells, not the pane rects that placed +/// them, so there is no band origin to compact against. That is the same answer +/// `gridCellAtDimensions` reaches for the same reason, which is what keeps the +/// two honest — a click lands on the glyph it was aimed at, because both sides +/// map through the body grid. The visible cost is one tagline row's worth of +/// loose tracking. fn taglineLayoutForCell( g: *const Gui, core: ?*const pardes.Pardes, @@ -1597,33 +1522,15 @@ fn taglineLayoutForCell( track: ?pardes.panel_animation.Track, ) CellLayout { if (row < pardes.TOPBAR_H) return compactTaglineLayout(g, 0); - if (core) |p| { - if (track) |active| { - const box = active.contentBox(); - const tag_y = if (p.settings.tag_bottom) box.y + box.h - @as(f32, @floatFromInt(pardes.BOX_H)) else box.y; - if (@as(f32, @floatFromInt(row)) >= tag_y and - @as(f32, @floatFromInt(row)) < tag_y + @as(f32, @floatFromInt(pardes.BOX_H))) - return compactTaglineLayout(g, box.x); - } - for (p.panes, 0..) |slot, id| { - if (slot == null) continue; - const r = p.rects[id]; - const tag_y = if (p.settings.tag_bottom) r.y + r.h -| pardes.BOX_H else r.y; - if (row == tag_y and col >= r.x and col < r.x + r.w) - return compactTaglineLayout(g, @floatFromInt(r.x)); - } - } - // A stale/closing cell without a live pane should still remain legible, - // and so should every cell of an attached window. Its body-grid origin is - // the only safe fallback available. - return compactTaglineLayout(g, @floatFromInt(col)); + const p = core orelse return compactTaglineLayout(g, @floatFromInt(col)); + return compactTaglineLayout(g, pardes.taglineOriginCol(p, col, row, track)); } -fn boxContains(box: pardes.panel_animation.Box, col: u16, row: u16) bool { - const x: f32 = @floatFromInt(col); - const y: f32 = @floatFromInt(row); - return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; -} +/// `panel_animation.Box.contains` under this file's older name. Kept as an +/// alias rather than renamed at three call sites so the predicate has exactly +/// one definition — it was a fourth copy of the same half-open cell test the +/// core, `taglineOriginCol` and ScenePostprocessor.swift all make. +const boxContains = pardes.panel_animation.Box.contains; // EFFECT_CODE_PANEL_HOST_BEGIN const PaintBatch = struct { @@ -2077,13 +1984,10 @@ fn localSession( observeGuiFont(g, core); syncTaglineFont(g, core); - // Private, complete before any fork and retained until the last possible - // spawn; children borrow only these stable in-struct path buffers. - var prompt_rcs = shell_bin.PromptRcs.init(); + // PATH, the bash banner and the prompt rc files, in the one order that + // works. Children borrow only these stable in-struct path buffers. + var prompt_rcs = shell_bin.prepareForFork(); defer prompt_rcs.deinit(); - // macos: apple's bash 3.2 prints the zsh-deprecation banner into every - // pane unless this is in the env BEFORE bash starts (the rc is too late) - if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); // per-slot spawn generation: drops a dead shell's late output/eof when its @@ -2549,11 +2453,8 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { if (opts.load_path == null) core.update(.{ .resize = .{ .cols = grid_cols, .rows = grid_rows } }); - var prompt_rcs = shell_bin.PromptRcs.init(); + var prompt_rcs = shell_bin.prepareForFork(); defer prompt_rcs.deinit(); - // macos: apple's bash 3.2 prints the zsh-deprecation banner into every - // pane unless this is in the env BEFORE bash starts (the rc is too late) - if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); // per-slot spawn generation: drops a dead shell's late output/eof when its @@ -3420,11 +3321,13 @@ fn pixelCell(px: f32, cell: u32) u16 { return @intFromFloat(@min(idx, 10_000)); } -/// Which grid cell a physical point is in. `core` is null in an attached -/// window, and then the pane loop is skipped and the body grid answers — the -/// same fallback `taglineLayoutForCell` takes for the same missing fact, which -/// is what makes a click on an attached tagline land on the glyph it was aimed -/// at. +/// Which grid cell a physical point is in. The COLUMN rule is +/// `pardes.gridColAt` — the inverse of the compact tagline layout, and in the +/// core beside it so the two cannot be compacted independently. `core` is null +/// in an attached window, and then the pane loop inside it is skipped and the +/// body grid answers: the same fallback `taglineLayoutForCell` takes for the +/// same missing fact, which is what makes a click on an attached tagline land +/// on the glyph it was aimed at. fn gridCellAtDimensions( core: ?*const pardes.Pardes, x: f32, @@ -3433,25 +3336,8 @@ fn gridCellAtDimensions( body_h: f32, tagline_w: f32, ) MouseCell { - const safe_body_w = @max(body_w, 1); - const safe_body_h = @max(body_h, 1); - const tag_w = @max(tagline_w, 1); - const row: u16 = @intFromFloat(@min(@floor(@max(y, 0) / safe_body_h), 10_000)); - if (row < pardes.TOPBAR_H) - return .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / tag_w), 10_000)), .row = row }; - - if (core) |p| for (p.panes, 0..) |slot, id| { - if (slot == null) continue; - const r = p.rects[id]; - const tag_y = if (p.settings.tag_bottom) r.y + r.h -| pardes.BOX_H else r.y; - if (row != tag_y or r.w == 0) continue; - const left = @as(f32, @floatFromInt(r.x)) * safe_body_w; - const right = @as(f32, @floatFromInt(r.x + r.w)) * safe_body_w; - if (x < left or x >= right) continue; - const within: u16 = @intFromFloat(@min(@floor(@max(0, x - left) / tag_w), @as(f32, @floatFromInt(r.w - 1)))); - return .{ .col = r.x + within, .row = row }; - }; - return .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / safe_body_w), 10_000)), .row = row }; + const row: u16 = @intFromFloat(@min(@floor(@max(y, 0) / @max(body_h, 1)), 10_000)); + return .{ .col = pardes.gridColAt(core, x, row, body_w, tagline_w), .row = row }; } fn gridCellAtPixels(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) MouseCell { @@ -4159,6 +4045,7 @@ test "the headless grid host round-trips a yank back as a paste" { }; var pipe_tasks: PipeTasks = .{}; var watches: file_watch.Table = @splat(null); + shell_bin.adoptSystemPath(); var prompt_rcs = shell_bin.PromptRcs.init(); defer prompt_rcs.deinit(); var shell: Shell = .{ diff --git a/src/lsp/lsp_client.zig b/src/lsp/lsp_client.zig index eca2691d..b1be590b 100644 --- a/src/lsp/lsp_client.zig +++ b/src/lsp/lsp_client.zig @@ -1050,6 +1050,54 @@ fn flat(arena: std.mem.Allocator, s: []const u8) []const u8 { return std.mem.trim(u8, buf.items, " "); } +/// Close-on-exec by fcntl, the darwin route. Same three lines as fuse.zig's +/// and nested.zig's, and here for the same reason they have their own: this +/// file imports neither. +fn setCloexec(fd: c_int) void { + const FD_CLOEXEC: c_int = 1; + _ = libc.fcntl(fd, libc.F.SETFD, FD_CLOEXEC); +} + +/// The client's transport: an AF_UNIX stream pair with both ends close-on-exec +/// and, on darwin, the parent end opted out of SIGPIPE. False if the host +/// refused, which is a dead server and not a dead editor. +/// +/// A named function rather than nine lines inside `ensure` because the one +/// thing it encodes is a PLATFORM LIE, and a test has to be able to call +/// exactly what the spawn calls. SOCK_CLOEXEC is a LINUX flag; zig spells +/// `SOCK.CLOEXEC` for darwin too — as 0x10000000, with "does not exist on +/// darwin but is used in std.net" in the comment beside it — and darwin's +/// socketpair(2) validates `type` strictly, so asking for it there returns +/// EPROTONOSUPPORT. Every server spawn on macOS failed on that line, before +/// the fork: no binary probe, no handshake, no message row, just `NoServer` in +/// 100µs from a client that had never once run on the platform it was written +/// on. The end-to-end suite that would have caught it (test/snapshots/ +/// lsp-client.snap) only ever runs against the linux target, where the flag is +/// real. fuse.zig and nested.zig already took the plain-socket-plus-fcntl +/// route; this was the one caller that did not. +/// +/// THE WINDOW THIS LEAVES, the same one host_io.zig states for the pty master: +/// fcntl after socketpair is not atomic, so another thread that forks and +/// execs in between inherits both ends. Linux closes it with the flag; darwin +/// has no socketpair that takes one. +fn transportPair(sv: *[2]libc.fd_t) bool { + const sock_type = if (comptime builtin.os.tag.isDarwin()) + libc.SOCK.STREAM + else + libc.SOCK.STREAM | libc.SOCK.CLOEXEC; + if (libc.socketpair(libc.AF.UNIX, sock_type, 0, sv) != 0) return false; + if (comptime builtin.os.tag.isDarwin()) { + setCloexec(sv[0]); + // The child dup2s this onto 0 and 1, and dup2 CLEARS close-on-exec on + // the copy, so the server still gets the socket; this marks only the + // number itself, which the child's 3..1024 sweep closes anyway. + setCloexec(sv[1]); + const one: c_int = 1; + _ = libc.setsockopt(sv[0], libc.SOL.SOCKET, so_nosigpipe, @ptrCast(&one), @sizeOf(c_int)); + } + return true; +} + // ----------------------------------------------------------- the connection /// Spawn-or-return, and tell an existing server about a new project root. @@ -1106,19 +1154,17 @@ fn ensure(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, tr: *Trac const owned_root = sa.dupe(u8, root) catch return error.OutOfMemory; var sv: [2]libc.fd_t = undefined; - if (libc.socketpair(libc.AF.UNIX, libc.SOCK.STREAM | libc.SOCK.CLOEXEC, 0, &sv) != 0) { + if (!transportPair(&sv)) { sa.free(owned_root); + tr.note("STOP: socketpair for {s} failed", .{specs[si].name}); return error.NoServer; } - if (comptime builtin.os.tag.isDarwin()) { - const one: c_int = 1; - _ = libc.setsockopt(sv[0], libc.SOL.SOCKET, so_nosigpipe, @ptrCast(&one), @sizeOf(c_int)); - } const pid = libc.fork(); if (pid < 0) { _ = libc.close(sv[0]); _ = libc.close(sv[1]); sa.free(owned_root); + tr.note("STOP: fork for {s} failed", .{specs[si].name}); return error.NoServer; } if (pid == 0) { @@ -2125,3 +2171,34 @@ test "specFor routes extensions and honours the disable env" { try std.testing.expect(specFor("/x/README.md") == null); try std.testing.expectEqualStrings("rust-analyzer", specs[specFor("/x/main.rs").?].name); } + +test "the transport this host actually gives us is a pair, and both ends are close-on-exec" { + // The spawn's FIRST fallible step, and for one release on macOS its last: + // `SOCK.CLOEXEC` is spelled for darwin in zig's libc bindings and rejected + // by darwin's socketpair(2), so this returned EPROTONOSUPPORT and no + // language server was ever forked on that platform. Nothing above the + // transport can notice — `ensure` reports the same `NoServer` a missing + // binary does — so the check belongs here, on the real function, in a test + // that runs on the host rather than on the linux target the snapshot + // suite cross-compiles to. + var sv: [2]libc.fd_t = undefined; + try std.testing.expect(transportPair(&sv)); + defer { + _ = libc.close(sv[0]); + _ = libc.close(sv[1]); + } + + // close-on-exec on both ends, however the platform got there: the flag on + // linux, fcntl on darwin. Without it every pty shell forked afterwards + // inherits the server's socket, which is the bug host_io.zig fixed for the + // pty master. + const FD_CLOEXEC: c_int = 1; + for (sv) |fd| try std.testing.expect(libc.fcntl(fd, libc.F.GETFD, @as(c_int, 0)) & FD_CLOEXEC != 0); + + // and it is a connected PAIR, not two unrelated descriptors + const msg = "ping"; + try std.testing.expectEqual(@as(isize, msg.len), libc.write(sv[0], msg, msg.len)); + var got: [8]u8 = undefined; + try std.testing.expectEqual(@as(isize, msg.len), libc.read(sv[1], &got, got.len)); + try std.testing.expectEqualStrings(msg, got[0..msg.len]); +} diff --git a/src/lsp_host.zig b/src/lsp_host.zig new file mode 100644 index 00000000..803beb31 --- /dev/null +++ b/src/lsp_host.zig @@ -0,0 +1,206 @@ +//! Turning the core's `lsp` effect into work on a thread, and its rows back +//! into something a loop can deliver. Native-shell side, like host_io.zig and +//! shell_bin.zig, and here for the reason those are: all three native shells +//! need it and none of them needs a different one. +//! +//! It was two copies before it was this. tty.zig had it inline and gui.zig had +//! it again with `tty.zig's LspJob, and copied for the same reason` written +//! over the top — identical down to the comment about a pane with no file. The +//! AppKit shell had NEITHER, so its vtable left `pull_lsp` null, the core +//! answered its own requests with no rows, and every language query did nothing +//! at all in the shell most people run. None of that looked like a missing +//! feature from the outside: `gd` just moved no cursor. A third copy is what +//! this module exists instead of. +//! +//! What is genuinely per-host stays per-host, and it is small: which allocator, +//! how a finished job reaches the loop (a mutex queue, a vaxis event, an inbox +//! plus a wakeup), and what bounds the in-flight set (a refcount to join at +//! teardown, or one future to cancel). What is NOT per-host is everything +//! below: the core goes on editing the moment the effect is drained, so every +//! byte the backend may read has to be COPIED first, and getting that ladder +//! subtly different in three places is how one shell reads freed text one +//! keystroke later. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const host_api = @import("host.zig"); + +/// One language query, owned by the worker that runs it. +pub const Job = struct { + id: u32, + kind: pardes.lsp.Kind, + offset: u32, + path: []u8, + source: [:0]u8, + arg: []u8, + root: []u8, + + pub fn free(job: *Job, gpa: std.mem.Allocator) void { + gpa.free(job.path); + gpa.free(job.source); + gpa.free(job.arg); + gpa.free(job.root); + gpa.destroy(job); + } +}; + +/// Copy the query out of the core. Null when the pane is gone or an allocation +/// failed, and nothing leaks on either path — the ladder frees exactly what it +/// had managed to take. +/// +/// A pane with no file still asks `status`: that query is about the BACKEND, +/// not the buffer. Empty path and source then, and the root comes off the +/// pane's cwd so a bare terminal still reports which servers it would reach. +pub fn snapshot(gpa: std.mem.Allocator, core: *const pardes.Pardes, req: host_api.LspRequest) ?*Job { + const pane = core.panes[req.pane] orelse return null; + const file = pane.file; + const job = gpa.create(Job) catch return null; + job.* = .{ + .id = req.id, + .kind = req.kind, + .offset = req.offset, + .path = gpa.dupe(u8, if (file) |f| f.path else "") catch { + gpa.destroy(job); + return null; + }, + .source = gpa.dupeZ(u8, if (file) |f| f.content else "") catch { + gpa.free(job.path); + gpa.destroy(job); + return null; + }, + .arg = gpa.dupe(u8, req.arg) catch { + gpa.free(job.path); + gpa.free(job.source); + gpa.destroy(job); + return null; + }, + .root = gpa.dupe(u8, if (file) |f| + (std.fs.path.dirname(f.path) orelse "/") + else + pane.cwdSlice()) catch { + gpa.free(job.path); + gpa.free(job.source); + gpa.free(job.arg); + gpa.destroy(job); + return null; + }, + }; + return job; +} + +/// What a host does with finished rows. It TAKES OWNERSHIP of `rows`, which +/// were allocated with the same allocator the job was. +pub const Deliver = *const fn (ctx: ?*anyopaque, id: u32, rows: []u8) void; + +/// Run `job` to completion and hand its rows to `deliver`. Consumes the job +/// either way. +/// +/// This is the whole async execution model, and it is the one every shell +/// already uses for its pty reader: do the slow thing off the loop, hand the +/// result over as an event, let the core stay a state machine that never +/// blocks. The shell owns the result buffer; the backend only writes into it. +pub fn work(gpa: std.mem.Allocator, job: *Job, ctx: ?*anyopaque, deliver: Deliver) void { + defer job.free(gpa); + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + pardes.lsp.query(gpa, arena.allocator(), .{ + .kind = job.kind, + .path = job.path, + .source = job.source, + .offset = job.offset, + .arg = job.arg, + .root = job.root, + }, &out.writer); + // Duped out of the writer: `deliver` outlives this frame and the writer + // does not. A failed dupe drops the answer, which the core survives — the + // request times out into no rows, exactly as an empty answer would. + const rows = gpa.dupe(u8, out.written()) catch return; + deliver(ctx, job.id, rows); +} + +test "a snapshot owns every byte the backend will read" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + + var needle: [6]u8 = "needle".*; + const job = snapshot(gpa, core, .{ + .id = 7, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = &needle, + }) orelse return error.SnapshotFailed; + defer job.free(gpa); + + try std.testing.expectEqual(@as(u32, 7), job.id); + try std.testing.expectEqual(pardes.lsp.Kind.status, job.kind); + // `arg` is the caller's buffer on the way in and the job's own bytes on the + // way out. THIS is the property the whole ladder exists for: the core reuses + // that buffer for the next builtin's argument the moment the effect drains. + try std.testing.expectEqualStrings("needle", job.arg); + try std.testing.expect(job.arg.ptr != &needle); + // A terminal pane has no file and the query still has to be answerable: + // empty path, a NUL-terminated empty source, and the pane's own cwd as the + // root so a bare terminal still reports which servers it would reach. The + // cwd may legitimately be empty in a core that has never spawned a shell; + // what matters is that the job OWNS it rather than borrowing it. + try std.testing.expectEqualStrings("", job.path); + try std.testing.expectEqual(@as(usize, 0), job.source.len); + try std.testing.expectEqual(@as(u8, 0), job.source[0]); + const pane = core.panes[core.active].?; + try std.testing.expectEqualStrings(pane.cwdSlice(), job.root); + if (job.root.len > 0) try std.testing.expect(job.root.ptr != pane.cwdSlice().ptr); +} + +test "a pane that is gone yields no job rather than a null deref" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + + // The effect is drained after the core has moved on, so the pane it names + // may already have been deleted. Every shell open-coded this check. + const empty = for (core.panes, 0..) |slot, id| { + if (slot == null) break @as(u8, @intCast(id)); + } else return error.NoEmptyPane; + try std.testing.expect(snapshot(gpa, core, .{ + .id = 1, + .kind = .definition, + .pane = empty, + .offset = 0, + .arg = "", + }) == null); +} + +test "work consumes the job and hands its rows to the sink" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + + const Sink = struct { + var seen_id: u32 = 0; + var seen_rows: ?[]u8 = null; + fn take(_: ?*anyopaque, id: u32, rows: []u8) void { + seen_id = id; + seen_rows = rows; + } + }; + Sink.seen_id = 0; + Sink.seen_rows = null; + + const job = snapshot(gpa, core, .{ + .id = 42, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = "", + }) orelse return error.SnapshotFailed; + work(gpa, job, null, Sink.take); + + // `status` is the one kind that answers with no file and no cursor, which + // is what makes it assertable here without a language server on the box. + try std.testing.expectEqual(@as(u32, 42), Sink.seen_id); + const rows = Sink.seen_rows orelse return error.SinkNeverCalled; + defer gpa.free(rows); +} 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 ` 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; + } + 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; } - 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. +/// +/// `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. /// -/// 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. +/// 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); -} - -test "mac panel ABI paint order includes closing tombstones after live panes" { + // 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 "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" { diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift index 0cecd5da..2d411873 100644 --- a/src/macos/Sources/AppDelegate.swift +++ b/src/macos/Sources/AppDelegate.swift @@ -22,6 +22,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // call pump(), but none advances animation; they only observe this flag and // leave the already-scheduled frame alone. private var pumpScheduled: Bool = false + /// The display's own clock, when the OS will lend us one. + /// + /// The fallback below is a `DispatchQueue.asyncAfter(0.016)` chain re-armed + /// AFTER each frame's tick, drain and draw, so its real period is 16 ms plus + /// all of that — comfortably slower than 60 Hz, and jittery, and never in + /// phase with the refresh. That is what makes an animation look choppy even + /// when nothing is dropping frames. A display link fires once per refresh, + /// phase-locked to vsync, which is the cadence the picture is actually + /// presented at. + /// + /// Cadence is now only a SMOOTHNESS question: `pardes_animation_tick` + /// spends measured elapsed time, so a 120 Hz link does not double-speed an + /// animation and a slow one does not halve it. + /// Held as `AnyObject` because a stored property cannot carry + /// `@available`, and this file still deploys to macOS 13. + private var displayLink: AnyObject? // The core is a singleton with no "is it alive" query, and Finder can hand // us documents before applicationDidFinishLaunching runs. Every entry point // that would call into libpardes from outside the launch sequence checks @@ -585,25 +601,62 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { } private func scheduleAnimationFrame() { - guard pardes_animating() && !pumpScheduled else { return } + guard pardes_animating() else { + stopDisplayLink() + return + } + if #available(macOS 14.0, *) { + // One link for the whole animating run, not one callback armed per + // frame: re-arming after the work is what put the period at 16 ms + // PLUS the work, and no amount of tuning that constant fixes a + // clock that is not the display's. + if displayLink == nil { + let link = view.displayLink(target: self, selector: #selector(displayLinkFired)) + link.add(to: .main, forMode: .common) + displayLink = link + } + return + } + // macOS 13 has no NSView display link. Keep the old chain, which is now + // only a cadence compromise rather than a correctness one — the tick + // spends measured time either way. + guard !pumpScheduled else { return } let awaitingPresentation = view.presentationSerial pumpScheduled = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) { + self.pumpScheduled = false self.animationFrame(after: awaitingPresentation) } } + private func stopDisplayLink() { + if #available(macOS 14.0, *) { (displayLink as? CADisplayLink)?.invalidate() } + displayLink = nil + } + + @objc private func displayLinkFired() { + guard coreIsUp else { return } + animationFrame(after: pendingPresentation) + } + + /// The presentation serial the last dirtied sample is waiting on. Held + /// across display-link callbacks because the link, unlike the old chain, + /// does not carry it in a closure. + private var pendingPresentation: UInt64 = 0 + private func animationFrame(after awaitingPresentation: UInt64) { - pumpScheduled = false guard view.presentationSerial != awaitingPresentation else { // Keep the dirty sample pending. AppKit may have coalesced this - // draw or the window may be occluded; retry the clock without - // advancing until draw(_:) supplies the presentation permit. + // draw or the window may be occluded; retry on the next refresh + // without advancing, until draw(_:) supplies the presentation + // permit. On the link that costs ONE refresh; the old chain paid a + // fresh 16 ms for it, which is where the visible hitching came from. view.needsDisplay = true - scheduleAnimationFrame() + if #available(macOS 14.0, *) {} else { scheduleAnimationFrame() } return } _ = pardes_animation_tick() + pendingPresentation = view.presentationSerial pump() } diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index 10c7fd8b..3f5f1346 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -191,6 +191,125 @@ private func advance(_ font: CTFont, _ character: UniChar) -> CGFloat { /// number is how "actual size" stops being the size it actually opened at. let defaultFontSize: CGFloat = 14 +/// The faces CoreText should reach for when the grid face has no glyph. +/// +/// The SDL shell loads a chain of fallback faces itself and rasterizes from +/// whichever one has the codepoint. This shell draws its non-ASCII through +/// CTLine, which already walks a cascade — but the SYSTEM cascade, and the +/// system cascade has never heard of a Nerd Font. Measured on a machine with +/// Mononoki Nerd Font installed: U+E0B0, U+E5FF, U+E700 and U+F015 all resolved +/// to `LastResort`, which is the tofu box. Braille, emoji and CJK resolved +/// fine, which is exactly why this went unnoticed — everything Unicode has an +/// opinion about already worked, and only the Private Use Area did not. +/// +/// Two sources, in this order: +/// +/// 1. `fonts.fallback_names` over the C ABI, so the PREFERENCE ORDER is the +/// one the SDL shell uses and lives in one file. +/// 2. Installed Nerd Font families, found by name and then CONFIRMED BY +/// COVERAGE. Both halves are load-bearing. Coverage alone is not enough: a +/// scan of all 250 families here put `Hannotate TC` and `HanziPen TC` at +/// 4 of 6 probes, because CJK faces map the PUA for their own purposes and +/// would answer a powerline request with an unrelated ideograph. A name +/// alone is not enough either, because "Nerd Font" in a family name is a +/// convention, not a guarantee. So the name decides what a codepoint MEANS +/// and coverage decides whether the face can actually draw it. +/// +/// Computed once. The result is a list of descriptors, which carry no size, so +/// a zoom or a `Font` command reuses it; only installing a font invalidates it, +/// and that is a relaunch. +private enum FontFallbacks { + /// Representative codepoints, one per Nerd Font block that matters: + /// powerline separators, Seti file icons, devicons, Font Awesome. A face + /// answering all four is patched; a face answering one is a coincidence. + private static let nerdProbes: [UInt32] = [0xE0B0, 0xE5FF, 0xE700, 0xF015] + + static let descriptors: [CTFontDescriptor] = build() + + private static func build() -> [CTFontDescriptor] { + var out: [CTFontDescriptor] = [] + var seen = Set() + + func take(_ family: String, _ descriptor: CTFontDescriptor) { + guard seen.insert(family).inserted else { return } + out.append(descriptor) + } + + // 1. The shared preference order. + for index in 0.. 0 else { continue } + let name = String(decoding: UnsafeRawBufferPointer(start: bytes, count: Int(length)), as: UTF8.self) + guard let found = resolve(name) else { continue } + take(found.family, found.descriptor) + } + + // 2. Nerd Fonts that are installed AND cover the blocks. `Mono` cuts + // first: this is a fixed grid, and the propo/variable cuts of the + // same family are drawn to different advances. + let families = (CTFontManagerCopyAvailableFontFamilyNames() as? [String]) ?? [] + let nerd = families.filter { $0.range(of: "nerd font", options: .caseInsensitive) != nil } + for family in nerd.sorted(by: { rank($0) < rank($1) }) { + let descriptor = CTFontDescriptorCreateWithAttributes( + [kCTFontFamilyNameAttribute: family] as CFDictionary) + guard coverage(descriptor, nerdProbes) == nerdProbes.count else { continue } + take(family, descriptor) + } + return out + } + + /// "Mono" before "Propo" before the proportional cut. + private static func rank(_ family: String) -> Int { + if family.range(of: "nerd font mono", options: .caseInsensitive) != nil { return 0 } + if family.range(of: "nerd font propo", options: .caseInsensitive) != nil { return 2 } + return 1 + } + + /// A descriptor that really is the font asked for. CoreText SUBSTITUTES + /// rather than failing — asking it for an uninstalled `SymbolsNerdFont- + /// Regular` hands back Helvetica, and a cascade seeded with Helvetica is a + /// cascade that answers every missing glyph with the wrong one. + private static func resolve(_ name: String) -> (family: String, descriptor: CTFontDescriptor)? { + for attribute in [kCTFontNameAttribute, kCTFontFamilyNameAttribute] { + let query = CTFontDescriptorCreateWithAttributes([attribute: name] as CFDictionary) + guard let match = CTFontDescriptorCreateMatchingFontDescriptor(query, nil) else { continue } + let postScript = CTFontDescriptorCopyAttribute(match, kCTFontNameAttribute) as? String ?? "" + let family = CTFontDescriptorCopyAttribute(match, kCTFontFamilyNameAttribute) as? String ?? "" + guard postScript.compare(name, options: .caseInsensitive) == .orderedSame + || family.compare(name, options: .caseInsensitive) == .orderedSame + else { continue } + return (family.isEmpty ? postScript : family, match) + } + return nil + } + + /// How many of `codepoints` this face can actually draw. Size is irrelevant + /// to coverage, so the probe face is built at a nominal one. + private static func coverage(_ descriptor: CTFontDescriptor, _ codepoints: [UInt32]) -> Int { + let font = CTFontCreateWithFontDescriptor(descriptor, 12, nil) + var hits = 0 + for codepoint in codepoints { + guard let scalar = UnicodeScalar(codepoint) else { continue } + var units = Array(String(scalar).utf16) + var glyphs = [CGGlyph](repeating: 0, count: units.count) + if CTFontGetGlyphsForCharacters(font, &units, &glyphs, units.count) { hits += 1 } + } + return hits + } + + /// `face` with the chain attached. Every face pardes draws with goes through + /// here exactly once, at the base: `CTFontCreateCopyWithSymbolicTraits` and + /// `CTFontCreateCopyWithAttributes` both carry the cascade into the copy, so + /// the bold/italic cuts and the smaller tagline cuts inherit it. + static func attach(to face: CTFont, size: CGFloat) -> CTFont { + guard !descriptors.isEmpty else { return face } + let descriptor = CTFontDescriptorCreateCopyWithAttributes( + CTFontCopyFontDescriptor(face), + [kCTFontCascadeListAttribute: descriptors] as CFDictionary) + return CTFontCreateWithFontDescriptor(descriptor, size, nil) + } +} + /// Everything that changes when the face or its size does, in one value so /// that changing either is one assignment and cannot leave half the numbers /// describing the old font. @@ -221,7 +340,12 @@ private struct Metrics { init(size: CGFloat, path: String?, scale: CGFloat) { let requested = path.flatMap { Metrics.fromFile($0, size) } - let face = requested ?? Metrics.defaultFace(size: size) + // Attached ONCE, at the base: the trait and size copies below inherit + // the cascade, so every cut and the tagline's smaller cuts reach the + // same fallbacks. Metrics is measured from `face` too, and a cascade + // changes no metric — CoreText measures the primary face and only + // consults the chain for a codepoint it lacks. + let face = FontFallbacks.attach(to: requested ?? Metrics.defaultFace(size: size), size: size) let scale = max(1, scale) // UNVERIFIED: CTFontSymbolicTraits member spelling (.traitBold/.traitItalic). @@ -331,7 +455,13 @@ private struct TaglineMetrics { /// band's top offset the way it used to be: that offset is per-row now, and /// a baseline carrying one row's offset would pin every band back to centre. let ascent: CGFloat - let xOffset: CGFloat + /// The tagline face's OWN advance, clamped to the body cell it sits in. + /// A tag row steps by THIS, not by the body cell width: the band behind it + /// is still pane-wide on the body grid, but the text on top of it tracks at + /// the smaller face's own pitch. Centring a smaller glyph inside a + /// body-width cell instead — which is what this shell used to do — leaves + /// the tag text visibly looser than the same session in an SDL window. + let width: CGFloat let height: CGFloat let asciiGlyphs: [[CGGlyph]] /// Kept so the band rules below can work in the physical pixels the core @@ -358,7 +488,7 @@ private struct TaglineMetrics { // over the body row below it. height = min(body.cellHeight, measuredHeight) ascent = max(1 / scale, snap(CTFontGetAscent(face), .toNearestOrAwayFromZero)) - xOffset = snap(max(0, (body.cellWidth - advance(face, 0x4D)) / 2), .toNearestOrAwayFromZero) + width = min(body.cellWidth, max(1 / scale, snap(advance(face, 0x4D), .toNearestOrAwayFromZero))) asciiGlyphs = faces.map { font in var chars = Array(UniChar(0).. CGFloat { + let origin = CGFloat(pardes_tagline_origin_col(UInt16(clamping: col), UInt16(clamping: row))) + return origin * bodyCellWidth + (CGFloat(col) - origin) * width + } + /// Thickness of the rule joining the topbar band to the first pane-tag band, /// zero when the two are meant to join directly. var borderThickness: CGFloat { @@ -830,6 +974,14 @@ final class PardesView: NSView { // Band geometry is stated against the whole canvas: the last row's rule // depends on where the window edge is, not just on the row index. let canvasHeight = bounds.height + // The tag band's own colour, fetched once per frame. A compact tag row + // is painted in TWO passes — pane-wide band on the body grid, then each + // cell's own background on the narrower grid its glyph uses — which is + // what the SDL shell spends its second quad per tagline cell on. Before + // there is a session to ask there is no base and the single body-grid + // fill below is all there is. + let taglineBaseBG = pardes_tagline_bg() + let taglineTwoPass = taglineBaseBG != UInt32(PARDES_COLOR_DEFAULT) for row in 0.., base: Int, cols: Int, + /// This row's grid index. Needed per cell rather than per row for the + /// compact tagline anchor: a column split puts two panes' tags side by + /// side on ONE row, so the origin is a question about the cell. + row: Int, baseline: CGFloat, /// Where this row's tagline band starts, from the row's top. Passed in /// rather than recomputed per cell: it is one answer per row, and the @@ -1164,7 +1348,14 @@ final class PardesView: NSView { // never the hole in the ground. let style = resolve(cell, block: blockCol == col, ground: themeBG ?? pardesDefaultBG, clearGround: false) - let x = CGFloat(col) * cellWidth + // Tagline cells step on the smaller face's own pitch, anchored at + // their pane; everything else on the body grid. The rules go with + // the glyph, not with the body cell, or an underlined tag word ends + // up underlining its neighbour. + let x = tagline + ? taglines.glyphX(col: col, row: row, bodyCellWidth: cellWidth) + : CGFloat(col) * cellWidth + let advanceWidth = tagline ? taglines.width : cellWidth // Rules before the glyph, and independent of it: an underlined space // is a real thing and so is an underlined invisible cell. They are @@ -1172,7 +1363,7 @@ final class PardesView: NSView { if cell.attrs >> UInt16(PARDES_ATTR_UL_SHIFT) != 0 || cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 { flush() - drawRules(ctx, cell, style, x: x, baseline: cellBaseline) + drawRules(ctx, cell, style, x: x, width: advanceWidth, baseline: cellBaseline) } guard style.visible else { continue } @@ -1190,7 +1381,6 @@ final class PardesView: NSView { italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0) let units = text.utf16 let known = units.count == 1 ? glyph(face, units.first!, tagline: tagline) : 0 - let glyphX = x + (tagline ? taglines.xOffset : 0) if known != 0 { if !runGlyphs.isEmpty && (face != runFace || tagline != runTagline @@ -1202,7 +1392,7 @@ final class PardesView: NSView { runColor = style.fg runAlpha = style.alpha runGlyphs.append(known) - runPositions.append(CGPoint(x: glyphX, y: cellBaseline)) + runPositions.append(CGPoint(x: x, y: cellBaseline)) continue } @@ -1214,7 +1404,7 @@ final class PardesView: NSView { setFill(ctx, style.fg, style.alpha) let font = tagline ? taglines.fonts[face.rawValue] : metrics.fonts[face.rawValue] let attributed = NSAttributedString(string: text, attributes: [fontAttribute: font]) - ctx.textPosition = CGPoint(x: glyphX, y: cellBaseline) + ctx.textPosition = CGPoint(x: x, y: cellBaseline) CTLineDraw(CTLineCreateWithAttributedString(attributed as CFAttributedString), ctx) // CTLineDraw leaves the text position at the END of what it drew, // and textPosition IS the translation of the text matrix, which @@ -1256,24 +1446,28 @@ final class PardesView: NSView { _ cell: pardes_cell_s, _ style: (fg: UInt32, bg: UInt32, alpha: CGFloat, visible: Bool), x: CGFloat, + /// The cell's advance: the body cell, or the narrower tagline one. A + /// rule is as wide as the character it belongs to, and on a tag row + /// that stopped being the body cell when the text compacted. + width: CGFloat, baseline: CGFloat ) { let underline = Int(cell.attrs >> PARDES_ATTR_UL_SHIFT) & 7 if underline != Int(PARDES_UL_OFF) { let y = baseline + metrics.underlineOffset - fill(ctx, CGRect(x: x, y: y, width: cellWidth, height: metrics.ruleThickness), style.fg, style.alpha) + fill(ctx, CGRect(x: x, y: y, width: width, height: metrics.ruleThickness), style.fg, style.alpha) // ponytail: curly, dotted and dashed all come out solid; only double // earns its second rule. ctx.setLineDash for two of them and a sine // path for the third is the upgrade, once anyone notices. if underline == Int(PARDES_UL_DOUBLE) { - fill(ctx, CGRect(x: x, y: y - metrics.ruleThickness * 2, width: cellWidth, height: metrics.ruleThickness), + fill(ctx, CGRect(x: x, y: y - metrics.ruleThickness * 2, width: width, height: metrics.ruleThickness), style.fg, style.alpha) } } if cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 { // Rounded like every other rule offset: a third of the ascent is a // fraction, and a fractional one-pixel bar is a two-pixel smear. - fill(ctx, CGRect(x: x, y: baseline + (metrics.ascent * 0.3).rounded(), width: cellWidth, height: metrics.ruleThickness), + fill(ctx, CGRect(x: x, y: baseline + (metrics.ascent * 0.3).rounded(), width: width, height: metrics.ruleThickness), style.fg, style.alpha) } } @@ -1670,8 +1864,15 @@ final class PardesView: NSView { } else { sampled = point } - let col = min(max(Int(sampled.x / cellWidth), 0), cols - 1) let row = min(max(Int(sampled.y / cellHeight), 0), rows - 1) + // The column comes from the core, because a tag row's glyphs step at + // the tagline face's narrower pitch and a body-pitch click drifts one + // word further right for every word along the row. The SDL shell asks + // the identical rule (`pardes.gridColAt`). Points on both sides: only + // the ratio of x to the two widths is read. + let col = min(max(Int(pardes_grid_col_at( + Float(sampled.x), UInt16(clamping: row), + Float(cellWidth), Float(taglines.width))), 0), cols - 1) return GridPoint(col: UInt16(col), row: UInt16(row)) } diff --git a/src/macos/icon.png b/src/macos/icon.png new file mode 100644 index 00000000..9889c348 Binary files /dev/null and b/src/macos/icon.png differ diff --git a/src/macos/icon.swift b/src/macos/icon.swift index 5c7dd881..2f45220f 100644 --- a/src/macos/icon.swift +++ b/src/macos/icon.swift @@ -1,22 +1,29 @@ -// Draws pardes.app's icon at build time and hands the result to iconutil. +// Cuts pardes.app's icon out of a committed drawing and hands the result to +// iconutil. // -// The mark is GLENDA, the Plan 9 rabbit — pardes is an acme, and acme is -// Plan 9's, so the bunny is the lineage stated in one shape. She is drawn out -// of the terminal's own palette rather than traced from a bitmap: the ground -// is defaultBG, the strip she sits under is the tag bar, and she herself is -// defaultFG. That is also why this is generated instead of committed — a -// checked-in .icns is a binary blob that stops matching the app the first time -// one of those colours moves, silently, with nothing in a diff to catch it. +// The mark is still GLENDA, the Plan 9 rabbit — pardes is an acme, and acme is +// Plan 9's, so the bunny is the lineage stated in one shape. What changed is +// where she comes from: she used to be drawn here out of the terminal's own +// palette, four ellipses and a tag bar, and she is now `icon.png` beside this +// file. A drawing is not derivable from a palette, so the old argument for +// generating her ("a checked-in .icns stops matching the app the first time a +// colour moves") no longer applies to the artwork — but it still applies to +// the ICNS, which is why this file did not become a committed binary. What is +// committed is the source picture, in one format, reviewable as an image; what +// is generated is the ten-size container macOS actually reads. +// +// So the work here is no longer drawing. It is the part a designer's PNG never +// has: Apple's icon grid, the rounded-square mask, and ten exact sizes. // // build.zig compiles this file alone into a cached binary and runs it with the -// bundle's Resources directory as argv[1]; Info.plist's CFBundleIconFile names -// the pardes.icns that comes out. Compiled alone is also what makes top-level -// code legal here: one file, one module, its own binary. +// source PNG as argv[1] and the bundle's Resources directory as argv[2]; +// Info.plist's CFBundleIconFile names the pardes.icns that comes out. Compiled +// alone is also what makes top-level code legal here: one file, one module. // // Byte-identical output for byte-identical input is a requirement, not a // nicety — an icns that churns on every build is a bundle that churns on every -// build, and Launch Services notices. Hence a pinned sRGB colour space, integer -// geometry, and nothing read from the clock or the environment. +// build, and Launch Services notices. Hence a pinned sRGB colour space, +// integer geometry, and nothing read from the clock or the environment. import CoreGraphics import Foundation @@ -32,29 +39,6 @@ func die(_ message: String) -> Never { exit(1) } -struct RGB { - let red: CGFloat - let green: CGFloat - let blue: CGFloat - - init(_ hex: UInt32) { - red = CGFloat((hex >> 16) & 0xFF) / 255 - green = CGFloat((hex >> 8) & 0xFF) / 255 - blue = CGFloat(hex & 0xFF) / 255 - } - - func components(_ alpha: CGFloat) -> [CGFloat] { [red, green, blue, alpha] } -} - -// Straight out of PardesView.swift. If those move these move, because the icon -// is a picture of the running program and a stale picture is worse than none: -// it looks deliberate. -let bodyTop = RGB(0x12_12_12) // defaultBG -let bodyBottom = RGB(0x0A_0A_0A) // defaultBG, shaded -let tagBar = RGB(0x34_65_A4) // ansi16[4], the muted blue -let text = RGB(0xCC_CC_CC) // defaultFG -let cursor = RGB(0xFC_E9_4F) // ansi16[11], bright yellow - // Apple's icon grid rather than the whole square: the artwork is a rounded // square floating in a transparent margin, 824 of 1024 with a 185.4 corner // radius in the template — 80.47% of the canvas, and 22.37% of the SQUARE, not @@ -63,98 +47,35 @@ let cursor = RGB(0xFC_E9_4F) // ansi16[11], bright yellow let squareFraction: CGFloat = 0.8047 let cornerFraction: CGFloat = 0.2237 -// GLENDA, as ellipses. Four for the silhouette, filled as ONE path so the -// overlaps vanish under nonzero winding and she is a single shape rather than -// four stuck together, then two eyes and a nose punched back out in the -// ground colour. -// -// Ellipses and not a traced outline for the reason everything else here is a -// fraction: the mark has to survive being twelve pixels across. An outlined -// drawing at that size is a grey smudge with a lighter grey inside it, whereas -// a silhouette is still a rabbit — the two ears are the whole recognition, and -// they are the two shapes that reach furthest from the mass. -let tagHeight: CGFloat = 0.165 - -/// Her box: the body square under the tag bar, inset so the ears are not -/// welded to the strip and the haunch is not welded to the bottom corners. -let stageTop: CGFloat = 0.250 -let stageBottom: CGFloat = 0.950 -let stageInset: CGFloat = 0.135 - -/// One ellipse of her, in fractions of that box: centre, radii, and a tilt in -/// degrees about its own centre. Fractions rather than points because the same -/// numbers have to describe the mark at 16 pixels and at 1024. -struct Blob { - let cx: CGFloat - let cy: CGFloat - let rx: CGFloat - let ry: CGFloat - let tilt: CGFloat - - init(_ cx: CGFloat, _ cy: CGFloat, _ rx: CGFloat, _ ry: CGFloat, tilt: CGFloat = 0) { - self.cx = cx - self.cy = cy - self.rx = rx - self.ry = ry - self.tilt = tilt - } - - func path(in stage: CGRect) -> CGPath { - let box = CGRect( - x: -stage.width * rx, y: -stage.height * ry, - width: stage.width * rx * 2, height: stage.height * ry * 2) - var placement = CGAffineTransform( - translationX: stage.minX + stage.width * cx, - y: stage.minY + stage.height * cy - ).rotated(by: tilt * .pi / 180) - return CGPath(ellipseIn: box, transform: &placement) - } -} - -// The ears overlap the head and the head overlaps the haunch on purpose: each -// pair has to still intersect after rounding at 16 pixels, or she comes apart -// into floating pieces at exactly the size nobody would look twice at. -let silhouette: [Blob] = [ - Blob(0.325, 0.150, 0.080, 0.200, tilt: -12), // left ear - Blob(0.675, 0.150, 0.080, 0.200, tilt: 12), // right ear - Blob(0.500, 0.490, 0.245, 0.212), // head - Blob(0.500, 0.785, 0.268, 0.215), // haunch -] - -// Set wide and low in the head, which is the whole of her expression. Rounder -// than a dot and smaller than the classic drawing's, because a big oval eye -// closes up into a grey blur two sizes down. -let eyes: [Blob] = [ - Blob(0.393, 0.468, 0.056, 0.070), - Blob(0.607, 0.468, 0.056, 0.070), -] - -/// Wider than it is tall, sitting just under the eyes: the one shape that says -/// rabbit rather than cat. Punched in the ground colour like the eyes. -let nose = Blob(0.500, 0.605, 0.045, 0.030) - -// ...and the block cursor, parked at the end of the tag bar. The palette's -// last entry, and the only warm thing in the icon: pardes is still an acme, -// and this is the two pixels that say so above her head. -let cursorWidth: CGFloat = 0.072 -let cursorRightPad: CGFloat = 0.120 - -/// sRGB in the bitmap and sRGB in every colour put into it. `setFillColor(red: -/// green:blue:alpha:)` speaks DeviceRGB, which is a colour match on the way in, -/// and #121212 would stop being #121212. +/// Where the crop comes off when the source is not square. +/// +/// The drawing is 1204x1306 — taller than wide — so filling a square throws +/// away 8% of its height, and WHICH 8% is the whole decision. Anchoring the +/// top keeps the sun, which is the only warm thing in the picture and sits in +/// the top-right corner, and spends the loss on the bottom band of grass, +/// which is texture and repeats. Anchoring the centre would clip the sun's +/// rays to buy back grass, which is the trade backwards. 0 is top, 1 is +/// bottom; a square source ignores this entirely. +let cropAnchor: CGFloat = 0 + +/// sRGB in the bitmap, so the paper white in the drawing is the paper white in +/// the icon rather than whatever DeviceRGB would make of it. func sRGB() -> CGColorSpace { guard let space = CGColorSpace(name: CGColorSpace.sRGB) else { die("sRGB colour space unavailable") } return space } -func cgColor(_ rgb: RGB, alpha: CGFloat = 1) -> CGColor { - guard let color = CGColor(colorSpace: sRGB(), components: rgb.components(alpha)) else { - die("CGColor from sRGB components failed") +func loadSource(_ url: URL) -> CGImage { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { + die("cannot read \(url.path)") } - return color + guard let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + die("\(url.lastPathComponent) holds no decodable image") + } + return image } -func renderIcon(pixels: Int) -> CGImage { +func renderIcon(_ art: CGImage, pixels: Int) -> CGImage { guard let ctx = CGContext( data: nil, width: pixels, height: pixels, @@ -162,11 +83,6 @@ func renderIcon(pixels: Int) -> CGImage { bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { die("CGContext \(pixels)x\(pixels) failed") } - // Top-left origin, so the constants above read in the order the picture - // does. The scale stays ±1, which is what lets snap() round in user space. - ctx.translateBy(x: 0, y: CGFloat(pixels)) - ctx.scaleBy(x: 1, y: -1) - // Round the MARGIN and derive the square from it. Rounding the square // instead leaves an odd remainder to split, and at 16 pixels the artwork // lands a pixel off centre. At 1024 this is Apple's 100/824/100 exactly. @@ -176,63 +92,32 @@ func renderIcon(pixels: Int) -> CGImage { let body = CGRect(x: inset, y: inset, width: side, height: side) let corner = side * cornerFraction - ctx.saveGState() ctx.addPath(CGPath(roundedRect: body, cornerWidth: corner, cornerHeight: corner, transform: nil)) ctx.clip() - // The only gradient in the icon, and it earns its place: a flat near-black - // square reads as a hole punched in the Dock rather than as an object. - let stops = bodyTop.components(1) + bodyBottom.components(1) - let locations: [CGFloat] = [0, 1] - guard - let gradient = CGGradient( - colorSpace: sRGB(), colorComponents: stops, locations: locations, count: 2) - else { die("CGGradient failed") } - ctx.drawLinearGradient( - gradient, - start: CGPoint(x: body.midX, y: body.minY), - end: CGPoint(x: body.midX, y: body.maxY), - options: []) - - // Full bleed, and still inside the clip so its top corners round with the - // body. src/pardes.zig fills row 0 across the whole width the same way; - // that strip is the silhouette of an acme screen and it is the one thing - // that has to survive being two pixels tall. - ctx.setFillColor(cgColor(tagBar)) - let tagRect = CGRect( - x: body.minX, y: body.minY, - width: side, height: max(1, (side * tagHeight).rounded())) - ctx.fill(tagRect) - - // The block cursor at the end of it. Inside the clip and inset from the - // corner so the rounding never clips a corner off the block itself. - ctx.setFillColor(cgColor(cursor)) - ctx.fill( - CGRect( - x: (body.maxX - side * (cursorRightPad + cursorWidth)).rounded(), - y: (tagRect.minY + tagRect.height * 0.24).rounded(), - width: max(1, (side * cursorWidth).rounded()), - height: max(1, (tagRect.height * 0.52).rounded()))) - ctx.restoreGState() - - // Glenda. One fill for the whole silhouette so the four ellipses union - // instead of seaming, then the eyes and the nose over the top of her. - let stage = CGRect( - x: body.minX + side * stageInset, - y: body.minY + side * stageTop, - width: side * (1 - 2 * stageInset), - height: side * (stageBottom - stageTop)) - - ctx.setFillColor(cgColor(text)) - for blob in silhouette { ctx.addPath(blob.path(in: stage)) } - ctx.fillPath(using: .winding) - - // The ground colour rather than black: her eyes and nose are HOLES in her, - // and a hole darker than what is behind it reads as paint. One fill for all - // three, so they can never disagree about which colour a hole is. - ctx.setFillColor(cgColor(bodyTop)) - for hole in eyes + [nose] { ctx.addPath(hole.path(in: stage)) } - ctx.fillPath(using: .winding) + // Aspect FILL, not fit. Fitting would letterbox the rounded square with a + // flat band beside textured paper, and the seam between the two is visible + // at every size the band is wide enough to see. Filling overflows the clip + // instead, and the clip is already exact. + let artWidth = CGFloat(art.width) + let artHeight = CGFloat(art.height) + guard artWidth > 0, artHeight > 0 else { die("source image is empty") } + let scale = max(side / artWidth, side / artHeight) + let drawWidth = artWidth * scale + let drawHeight = artHeight * scale + + // CoreGraphics is bottom-left origin, so `cropAnchor` 0 (the TOP of the + // picture) means the drawing's top edge meets the body's top edge and the + // overflow hangs off the bottom, into the clip. + let overflowY = drawHeight - side + let overflowX = drawWidth - side + ctx.interpolationQuality = .high + ctx.draw( + art, + in: CGRect( + x: body.minX - overflowX / 2, + y: body.maxY - drawHeight + overflowY * cropAnchor, + width: drawWidth, height: drawHeight)) guard let image = ctx.makeImage() else { die("CGContext.makeImage failed at \(pixels)") } return image @@ -266,12 +151,13 @@ let variants: [(name: String, pixels: Int)] = [ ] let arguments = CommandLine.arguments -guard arguments.count == 2 else { - die("usage: \(URL(fileURLWithPath: arguments.first ?? "icon").lastPathComponent) ") +guard arguments.count == 3 else { + die("usage: \(URL(fileURLWithPath: arguments.first ?? "icon").lastPathComponent) ") } let files = FileManager.default -let outputDir = URL(fileURLWithPath: arguments[1], isDirectory: true) +let art = loadSource(URL(fileURLWithPath: arguments[1])) +let outputDir = URL(fileURLWithPath: arguments[2], isDirectory: true) let output = outputDir.appendingPathComponent("pardes.icns") // A fixed scratch path, cleared before use rather than a unique one: a run that @@ -289,7 +175,7 @@ do { } for variant in variants { - writePNG(renderIcon(pixels: variant.pixels), to: iconset.appendingPathComponent(variant.name)) + writePNG(renderIcon(art, pixels: variant.pixels), to: iconset.appendingPathComponent(variant.name)) } let iconutil = Process() diff --git a/src/macos/pardes.h b/src/macos/pardes.h index 8b798df6..c28b3514 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -269,6 +269,38 @@ uint32_t pardes_tagline_band_offset(uint16_t row, float canvas_h, uint32_t cell_ uint32_t tagline_h); uint32_t pardes_topbar_pane_border_px(uint32_t cell_h, uint32_t tagline_h); +// The column a compact tagline band anchors at: the pane's left edge, so a tag +// row advances on the tagline face's own narrower pitch instead of centring a +// smaller glyph inside every body-width cell. CELLS, not pixels — the host +// knows both widths — and fractional, because an animating panel's origin is. +// +// glyph_x = origin * body_cell_w + (col - origin) * tagline_cell_w +float pardes_tagline_origin_col(uint16_t col, uint16_t row); + +// The inverse, for the pointer: which grid column `x` falls in on `row`, given +// that a tag row's glyphs step at the narrower pitch. Compacting the text +// without compacting this makes a click drift one word further right for every +// word along the row. `x` and both widths must share a unit; only the ratio is +// read, so a host measuring in points passes points. +uint16_t pardes_grid_col_at(float x, uint16_t row, float body_w, float tagline_w); + +// The tag band's own background, the base a compact tag row is painted on: the +// pane-wide band goes down in THIS colour on the body grid, then each cell's +// own background on the narrower grid its glyph uses. One pass would put a +// highlighted word's box on body pitch and its letters on tagline pitch. +// PARDES_COLOR_DEFAULT before there is a session to ask. +uint32_t pardes_tagline_bg(void); + +// The fallback font PREFERENCE ORDER, shared with the SDL shell. Only the order +// travels: resolving a name is the host's business, because SDL matches font +// FILE STEMS while walking the font directories and CoreText matches PostScript +// and family names, which for one face are routinely different strings. +// +// `pardes_fallback_font_name` returns a borrowed, NOT NUL-terminated pointer +// and writes its byte length through `len`; NULL past the end. +uint32_t pardes_fallback_font_count(void); +const char *pardes_fallback_font_name(uint32_t index, uint32_t *len); + // Colour of that rule: a compiled override, else the theme's scrollbar track. // PARDES_COLOR_DEFAULT before there is a session to ask — do not draw it then. uint32_t pardes_topbar_pane_border_rgb(void); diff --git a/src/panel_animation.zig b/src/panel_animation.zig index e0cb32d3..a77e5a34 100644 --- a/src/panel_animation.zig +++ b/src/panel_animation.zig @@ -116,8 +116,46 @@ pub const Box = extern struct { pub fn eql(a: Box, b: Box) bool { return a.x == b.x and a.y == b.y and a.w == b.w and a.h == b.h; } + + /// Whether a grid cell falls inside this box. Cells are whole, boxes are + /// fractional mid-animation, so the test is the cell's ORIGIN against a + /// half-open range: a box straddling a column owns it once its origin is + /// covered, and never owns it twice. + pub fn contains(box: Box, col: u16, row: u16) bool { + const x: f32 = @floatFromInt(col); + const y: f32 = @floatFromInt(row); + return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; + } }; +/// The order every backend composites tracks in: moving panes first, then new +/// panes, then inert closing tombstones on top. Returns how many were written. +/// +/// A function rather than a loop inside `Pardes.render` because it is a RULE +/// three hosts used to re-derive — macos.zig re-sorted the already-sorted list +/// and tty/panel_compositor.zig walked the phases again — and a second sort +/// that happens to agree is the one that silently stops agreeing. `render` +/// calls this and every host receives the result verbatim. +/// +/// Inactive tracks are dropped here, so a host never has to ask. +pub fn paintOrder(live: []const ?Track, closing: []const Track, out: []Track) usize { + var len: usize = 0; + for ([_]Phase{ .moving, .opening }) |phase| for (live) |maybe| { + const track = maybe orelse continue; + if (!track.active() or track.phase != phase) continue; + if (len == out.len) return len; + out[len] = track; + len += 1; + }; + for (closing) |track| { + if (!track.active()) continue; + if (len == out.len) return len; + out[len] = track; + len += 1; + } + return len; +} + /// One POD record is enough for every backend. `from` and `to` are logical /// cell boxes; frontends convert them to pixels only at their render edge. pub const Track = extern struct { diff --git a/src/pardes.zig b/src/pardes.zig index 1eae2113..b64b62d7 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -229,6 +229,193 @@ pub fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 { return @min(@as(u32, config.gui_topbar_pane_border_px), spare * 2); } +/// The column a compact tagline band anchors at: the left edge of the pane +/// whose tag row this cell sits on. ONE rule for both pixel hosts, for exactly +/// the reason `taglineBandOffset` is one — the SDL shell reached this through +/// its own copy of the pane walk, and the AppKit shell could not do the walk at +/// all (pane rects are not on its C ABI), so its tag rows advanced on BODY +/// pitch with the smaller glyph merely centred in each body cell. Same session, +/// same percentage, visibly looser tracking in one of the two windows. +/// +/// CELLS, and fractional on purpose: an animating panel's box is fractional, +/// and rounding here would step a sliding pane's tag row a whole body cell at a +/// time while the rest of the pane moved smoothly. +/// +/// `track` is the panel track painting this cell, when one is. It is a +/// parameter rather than something looked up here because the caller has +/// already decided which track owns the cell — the SDL shell from its paint +/// plan, the C ABI wrapper from the frame's track list — and two answers to +/// that question is the drift this function exists to prevent. +/// +/// The last resort is the cell's own column, which puts that one cell back on +/// body pitch. That is deliberate: a stale cell whose pane has closed, or any +/// cell of an attached window, still has to be legible, and a band anchored at +/// a pane that no longer exists is not. +pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?panel_animation.Track) f32 { + if (row < TOPBAR_H) return 0; + if (track) |active| { + const box = active.contentBox(); + const tag_y = if (p.settings.tag_bottom) box.y + box.h - @as(f32, @floatFromInt(BOX_H)) else box.y; + if (@as(f32, @floatFromInt(row)) >= tag_y and + @as(f32, @floatFromInt(row)) < tag_y + @as(f32, @floatFromInt(BOX_H))) + return box.x; + } + for (p.panes, 0..) |slot, id| { + if (slot == null) continue; + const r = p.rects[id]; + const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; + if (row == tag_y and col >= r.x and col < r.x + r.w) return @floatFromInt(r.x); + } + return @floatFromInt(col); +} + +/// `taglineOriginCol` for a host with no paint plan of its own: the owning +/// track is resolved from the frame's own list. The SDL shell already knows +/// which track is painting a cell and passes it; AppKit reaches the grid +/// through the C ABI and does not, so the lookup belongs here rather than in +/// the wrapper — a second answer to "which track owns this cell" is exactly +/// the drift `taglineOriginCol` was moved into the core to stop. +pub fn taglineOriginColForFrame(p: *const Pardes, col: u16, row: u16) f32 { + for (p.surface.panelTracks()) |track| + if (track.contentBox().contains(col, row)) + return taglineOriginCol(p, col, row, track); + return taglineOriginCol(p, col, row, null); +} + +test "paint order is moving, then opening, then closing tombstones on top" { + const Track = panel_animation.Track; + // Deliberately interleaved on the way in: the phases are what order the + // output, not the slot they happened to occupy. + const live = [_]?Track{ + .{ .serial = 11, .pane = 3, .phase = .opening, .effect = .slide }, + .{ .serial = 12, .pane = 1, .phase = .moving, .effect = .zoom }, + null, + .{ .serial = 14, .pane = 5, .phase = .opening, .effect = .ascii }, + .{ .serial = 15, .pane = 2, .phase = .moving, .effect = .dissolve }, + }; + const closing = [_]Track{ + .{ .serial = 16, .pane = 2, .phase = .closing, .effect = .vertical }, + }; + var out: [8]Track = undefined; + const len = panel_animation.paintOrder(&live, &closing, &out); + + var serials: [8]u32 = undefined; + for (out[0..len], 0..) |track, i| serials[i] = track.serial; + try std.testing.expectEqualSlices(u32, &.{ 12, 15, 11, 14, 16 }, serials[0..len]); + + // A host's array is fixed-size and the core's is not its business: writing + // past it would be a buffer overrun in whichever shell had the smaller one. + var tight: [2]Track = undefined; + try std.testing.expectEqual(@as(usize, 2), panel_animation.paintOrder(&live, &closing, &tight)); + try std.testing.expectEqual(@as(u32, 12), tight[0].serial); + try std.testing.expectEqual(@as(u32, 15), tight[1].serial); +} + +/// The INVERSE of the two rules above: which grid column a pointer sits in, +/// given where the glyphs actually went. Compacting a tag row without +/// compacting the hit test is a click that lands one word to the right by the +/// end of the row, so these two are one feature and belong in one place. +/// +/// `x` and both widths are in whatever unit the host measures in — physical +/// pixels for SDL, points for AppKit — because only their RATIO is used. +/// +/// The topbar anchors at column zero, a pane tag row at its pane's left edge +/// and is clamped to that pane's last column so a click in the slack at the +/// right of a compacted band stays on the pane it was aimed at, and everything +/// else is the body grid. Deliberately track-blind: the pointer is aimed at +/// what is on screen NOW, and a mid-animation pane is somewhere its own +/// geometry says it is not yet. +pub fn gridColAt(p: ?*const Pardes, x: f32, row: u16, body_w: f32, tagline_w: f32) u16 { + const body = @max(body_w, 1); + const tag = @max(tagline_w, 1); + if (row < TOPBAR_H) return colFromSpan(x, tag); + if (p) |core| for (core.panes, 0..) |slot, id| { + if (slot == null) continue; + const r = core.rects[id]; + const tag_y = if (core.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; + if (row != tag_y or r.w == 0) continue; + const left = @as(f32, @floatFromInt(r.x)) * body; + const right = @as(f32, @floatFromInt(r.x + r.w)) * body; + if (x < left or x >= right) continue; + const within: u16 = @intFromFloat(@min( + @floor(@max(0, x - left) / tag), + @as(f32, @floatFromInt(r.w - 1)), + )); + return r.x + within; + }; + return colFromSpan(x, body); +} + +fn colFromSpan(x: f32, span: f32) u16 { + return @intFromFloat(@min(@floor(@max(x, 0) / span), 10_000)); +} + +test "a compact tagline anchors at its own pane, and both shells step from the same origin" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 24 }); + defer p.deinit(); + _ = try p.newShell(1, ""); + try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + p.sync(); + + // Two panes side by side put two tags on ONE row, which is the case a + // per-row origin gets wrong and the reason this is asked per cell. + const left = p.rects[0]; + const right = p.rects[1]; + try std.testing.expect(right.x > left.x); + try std.testing.expectEqual(@as(f32, @floatFromInt(left.x)), taglineOriginCol(p, left.x + 3, left.y, null)); + try std.testing.expectEqual(@as(f32, @floatFromInt(right.x)), taglineOriginCol(p, right.x + 3, right.y, null)); + + // The topbar's origin is column zero always — it is not a pane rect, which + // is why row zero is right even in a window with no core to ask. + try std.testing.expectEqual(@as(f32, 0), taglineOriginCol(p, 40, 0, null)); + + // A row no pane tags falls back to the cell's own column, which is the + // identity that puts that cell back on body pitch rather than sliding it + // somewhere a closed pane used to be. + const body_row = left.y + 2; + try std.testing.expectEqual(@as(f32, 7), taglineOriginCol(p, 7, body_row, null)); + + // THE CROSS-SHELL CONTRACT. gui.zig lays a compact cell out as + // `x_off + col * tag_w` with `x_off = origin * (body_w - tag_w)`; the + // AppKit shell spells the same placement as + // `origin * body_w + (col - origin) * tag_w`. They are the same line of + // algebra and this is the assertion that keeps them one: the two windows + // are supposed to be indistinguishable at the same percentage, and the + // whole bug was one of them quietly using body pitch. + const body_w: f32 = 10; + const tag_w: f32 = 8; + for ([_]u16{ 0, 1, 5, 40, 119 }) |col| { + const origin = taglineOriginCol(p, col, left.y, null); + const sdl = origin * (body_w - tag_w) + @as(f32, @floatFromInt(col)) * tag_w; + const appkit = origin * body_w + (@as(f32, @floatFromInt(col)) - origin) * tag_w; + try std.testing.expectEqual(sdl, appkit); + } + + // ...and the POINTER agrees with both. Placing a glyph on a narrower pitch + // while still dividing clicks by the body cell is a hit that drifts one + // column further right for every column along the row — dead centre of the + // last word in a wide tag lands on empty space past its end. Forward and + // inverse live in different files and different languages; this is what + // keeps them inverses. + for ([_]u16{ 0, 1, 4, 9 }) |offset| { + const col = left.x + offset; + if (offset >= left.w) break; + const origin = taglineOriginCol(p, col, left.y, null); + const glyph_x = origin * body_w + (@as(f32, @floatFromInt(col)) - origin) * tag_w; + try std.testing.expectEqual(col, gridColAt(p, glyph_x + tag_w / 2, left.y, body_w, tag_w)); + } + + // The topbar's pointer grid is compact from column zero, with no pane to + // anchor to — the one tag row that is right with or without a core. + try std.testing.expectEqual(@as(u16, 3), gridColAt(p, 3.5 * tag_w, 0, body_w, tag_w)); + try std.testing.expectEqual(@as(u16, 3), gridColAt(null, 3.5 * tag_w, 0, body_w, tag_w)); + + // A body row is untouched: still the body grid, still divided by the body + // cell. Only tag rows compact. + try std.testing.expectEqual(@as(u16, 3), gridColAt(p, 3.5 * body_w, body_row, body_w, tag_w)); +} + /// A place the keyboard has been: a pane AND a spot in it, which is the whole /// upgrade over the stack of bare pane ids this replaces — Ctrl-o can now /// rewind WITHIN a pane, and a Jumplist row can name a line. @@ -15508,19 +15695,14 @@ pub const Pardes = struct { s.cell_diffs = p.panel_cell_diffs; } // Moving panes first, then new panes, then inert closing tombstones on - // top. Native GUI paint planners may regroup by phase, but every host - // receives this same deterministic dense record set. - for ([_]panel_animation.Phase{ .moving, .opening }) |phase| for (p.panel_tracks) |maybe| { - const track = maybe orelse continue; - if (!track.active() or track.phase != phase) continue; - s.panel_tracks[s.npanel_tracks] = track; - s.npanel_tracks += 1; - }; - for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |track| { - if (!track.active()) continue; - s.panel_tracks[s.npanel_tracks] = track; - s.npanel_tracks += 1; - } + // top. The rule is `panel_animation.paintOrder` so that it has exactly + // one definition: every host receives this same deterministic dense + // record set and none of them needs to sort it again. + s.npanel_tracks = panel_animation.paintOrder( + &p.panel_tracks, + p.closing_panel_tracks[0..p.nclosing_panel_tracks], + &s.panel_tracks, + ); return p.composeAsciiTransitions(arena, s); } @@ -15736,8 +15918,13 @@ pub const Pardes = struct { // text area resets to terminal-default cells (vaxis clear semantics); // light themes paint the page over it. - s.clearRect(tx, r.y, tw, r.h); - if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } }); + { + // Two full passes over every cell in the pane, every frame. + const tz_clear = tracy.zone(@src(), "paneClear"); + defer tz_clear.end(); + s.clearRect(tx, r.y, tw, r.h); + if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } }); + } // the layout box: the pane's MODE, one character, in the gutter cells // of the tag row. Same box you drag a pane by — the whole GUTTER is @@ -15870,11 +16057,17 @@ pub const Pardes = struct { const tz_body = tracy.zone(@src(), "bodyText"); const body = try p.bodyText(arena, pane); tz_body.end(); - var it = std.mem.splitScalar(u8, body, '\n'); - var i: u16 = 0; - while (it.next()) |line| : (i += 1) { - if (i >= body_h) break; - _ = s.print(tx, body_y + i, tw, line, body_style); + { + // The rows themselves. `bodyText` above is only the string BUILD; + // this is what writes it into the surface. + const tz_rows = tracy.zone(@src(), "paneBodyRows"); + defer tz_rows.end(); + var it = std.mem.splitScalar(u8, body, '\n'); + var i: u16 = 0; + while (it.next()) |line| : (i += 1) { + if (i >= body_h) break; + _ = s.print(tx, body_y + i, tw, line, body_style); + } } // Coloring is one algorithm per pane, chosen by title (colorAlgo): the @@ -15883,6 +16076,7 @@ pub const Pardes = struct { // both feed f.highlights, which refreshHighlights filled with whichever // this same choice named. Order is load-bearing — gutter, recolor, then // wrap markers; the selection/cursor passes below win over all three. + const tz_color = tracy.zone(@src(), "paneRecolor"); switch (pane.colorAlgo()) { // Every mode, not just `.tty`: `recolorAnsi` translates a row's // colour anchor through the same slide the edit buffer applied to @@ -15902,6 +16096,7 @@ pub const Pardes = struct { }, .none => {}, } + tz_color.end(); // mouse selections (pane-local coords), one pass per button — later // buttons win on overlap. A left .done stays highlighted after release; diff --git a/src/selection_pipe.zig b/src/selection_pipe.zig index 65c4c7fd..c1104010 100644 --- a/src/selection_pipe.zig +++ b/src/selection_pipe.zig @@ -78,6 +78,55 @@ pub const Response = struct { } }; +/// The in-flight set a host keeps while pipes run off its loop. +/// +/// tty.zig and gui.zig each had this verbatim — same `finish` walk, same +/// `cancelAll`, same 16 — differing only in whether `add` asserted or returned +/// a bool. It lives here beside the Job it tracks so a third host (the AppKit +/// shell, which had no pipe support at all) does not have to grow a fourth. +/// +/// Bounded on purpose: a filter is a user gesture, and sixteen concurrent ones +/// is already more than anybody means. `add` returning false is the host's cue +/// to answer the request as failed rather than to queue it. +pub const Tasks = struct { + pub const capacity = 16; + + pub const Task = struct { + id: u32, + future: std.Io.Future(anyerror!void), + }; + + items: [capacity]Task = undefined, + len: usize = 0, + + pub fn full(tasks: *const Tasks) bool { + return tasks.len == tasks.items.len; + } + + pub fn add(tasks: *Tasks, task: Task) bool { + if (tasks.full()) return false; + tasks.items[tasks.len] = task; + tasks.len += 1; + return true; + } + + /// Join the one that answered and drop it, preserving order so `cancelAll` + /// stays deterministic. + pub fn finish(tasks: *Tasks, io: std.Io, id: u32) void { + for (tasks.items[0..tasks.len], 0..) |*task, i| if (task.id == id) { + task.future.await(io) catch {}; + tasks.len -= 1; + std.mem.copyForwards(Task, tasks.items[i..tasks.len], tasks.items[i + 1 .. tasks.len + 1]); + return; + }; + } + + pub fn cancelAll(tasks: *Tasks, io: std.Io) void { + for (tasks.items[0..tasks.len]) |*task| task.future.cancel(io) catch {}; + tasks.len = 0; + } +}; + const WriterContext = struct { io: std.Io, file: std.Io.File, diff --git a/src/shell_bin.zig b/src/shell_bin.zig index 81ff90fa..1090bb2b 100644 --- a/src/shell_bin.zig +++ b/src/shell_bin.zig @@ -27,6 +27,227 @@ const libc = std.c; const X_OK: c_int = 1; extern "c" fn mkstemp(template: [*:0]u8) c_int; +extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; + +// ------------------------------------------------- the GUI launch's PATH + +/// Bounded storage for the composed PATH. /etc/paths and /etc/paths.d hold ten +/// directories on a stock machine and a handful more with third-party +/// packages; 4 KiB is not a limit anyone will meet, and a fixed buffer keeps +/// this callable from a host that has not built an allocator yet. +const path_capacity = 4096; +const max_path_files = 64; + +/// macOS: give the PROCESS the PATH a login session would have, but only when +/// it plainly has not got one. +/// +/// A GUI launch — Finder, the Dock, `open(1)` — inherits launchd's +/// environment, and launchd's PATH is `/usr/bin:/bin:/usr/sbin:/sbin`. Nothing +/// else: no /opt/homebrew/bin, no /usr/local/bin. A launch from a terminal +/// inherits the shell's PATH and is fine. That difference is the whole bug, +/// and it is why it reads as intermittent — the same build finds `yazi` when +/// you start it from a terminal and cannot find it when you start it from the +/// Dock. +/// +/// macOS's own answer is /usr/libexec/path_helper, which reads /etc/paths and +/// /etc/paths.d. LOGIN shells run it and non-login shells do not, and pardes +/// spawns non-login shells deliberately (see `resolve`) — so a pane cannot fix +/// this for itself. Nor should it: one environ is inherited by every pty shell +/// pardes forks, every `/bin/sh -c` filter, and every language server the LSP +/// client spawns, and `binOf` searching a launchd PATH is a rust-analyzer that +/// is never found. Fixing the process fixes all of them at once. +/// +/// ONLY when every entry already in PATH is a system directory. That is the +/// test for "nobody configured this". path_helper appends pre-existing entries +/// AFTER the system set, so running it over a real session's PATH would demote +/// a version manager's shims behind /usr/bin and quietly change which `node` +/// runs. A configured PATH is left exactly as it is; the launchd case is +/// unambiguous and is the only one touched. +pub fn adoptSystemPath() void { + if (comptime builtin.os.tag != .macos) return; + var buf: [path_capacity]u8 = undefined; + var len: usize = 0; + collectSystemPath(&buf, &len); + if (len == 0) return; + const system = buf[0..len]; + + const current: []const u8 = if (libc.getenv("PATH")) |p| std.mem.span(p) else ""; + if (!allEntriesWithin(current, system)) return; + if (std.mem.eql(u8, current, system)) return; + + var out: [path_capacity:0]u8 = undefined; + if (len >= out.len) return; + @memcpy(out[0..len], system); + out[len] = 0; + _ = setenv("PATH", out[0..len :0].ptr, 1); +} + +/// Everything a native shell must do TO THE PROCESS before it forks its first +/// pane, in the order it has to happen, handing back the prompt files those +/// forks will borrow. +/// +/// Four hosts performed this ritual by hand and the copies had already +/// diverged. detached/server.zig forks bash through `resolve` exactly like its +/// siblings and never set BASH_SILENCE_DEPRECATION_WARNING, so every pane in a +/// detached session on macOS opened with Apple's zsh-migration banner printed +/// across the top of it — and nobody noticed, because the three hosts anyone +/// looks at daily all had the line. That is the failure mode of a four-line +/// ritual written four times. +/// +/// The ORDER is the content here. `adoptSystemPath` has to precede the fork +/// because the child inherits the environ; the setenv has to precede bash +/// because bash reads it at startup and the rc file is already too late; and +/// the rc files have to be complete on disk before any child can be handed a +/// path to one. +pub fn prepareForFork() PromptRcs { + adoptSystemPath(); + if (comptime builtin.os.tag.isDarwin()) + _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); + return PromptRcs.init(); +} + +/// /etc/paths, then every file in /etc/paths.d in NAME ORDER, which is the +/// order path_helper reads them in and therefore the order the directories +/// take precedence in. +fn collectSystemPath(buf: []u8, len: *usize) void { + var file_buf: [path_capacity]u8 = undefined; + if (readSmall("/etc/paths", &file_buf)) |body| appendLines(buf, len, body); + + const io = std.Io.Threaded.global_single_threaded.io(); + var dir = std.Io.Dir.cwd().openDir(io, "/etc/paths.d", .{ .iterate = true }) catch return; + defer dir.close(io); + + // readdir order is undefined and path_helper's is not, so the names are + // collected and sorted before any of them is read. + var names: [max_path_files][256]u8 = undefined; + var name_lens: [max_path_files]usize = undefined; + var count: usize = 0; + var it = dir.iterate(); + while (count < names.len) { + const entry = (it.next(io) catch break) orelse break; + if (entry.kind == .directory) continue; + if (entry.name.len == 0 or entry.name.len > names[count].len) continue; + @memcpy(names[count][0..entry.name.len], entry.name); + name_lens[count] = entry.name.len; + count += 1; + } + var order: [max_path_files]usize = undefined; + for (0..count) |i| order[i] = i; + std.mem.sort(usize, order[0..count], Names{ .names = &names, .lens = &name_lens }, Names.lessThan); + + var path_buf: [512]u8 = undefined; + for (order[0..count]) |i| { + const name = names[i][0..name_lens[i]]; + const path = std.fmt.bufPrintSentinel(&path_buf, "/etc/paths.d/{s}", .{name}, 0) catch continue; + if (readSmall(path, &file_buf)) |body| appendLines(buf, len, body); + } +} + +const Names = struct { + names: *const [max_path_files][256]u8, + lens: *const [max_path_files]usize, + + fn lessThan(self: Names, a: usize, b: usize) bool { + return std.mem.order(u8, self.names[a][0..self.lens[a]], self.names[b][0..self.lens[b]]) == .lt; + } +}; + +/// One directory per line, blanks and whitespace ignored — the format both +/// files use and the only thing path_helper reads out of them. +fn appendLines(buf: []u8, len: *usize, body: []const u8) void { + var lines = std.mem.splitScalar(u8, body, '\n'); + while (lines.next()) |raw| appendEntry(buf, len, std.mem.trim(u8, raw, " \t\r")); +} + +/// Append `entry` unless it is already present. Dedup preserves the FIRST +/// occurrence, which is what makes the order above mean precedence. +fn appendEntry(buf: []u8, len: *usize, entry: []const u8) void { + if (entry.len == 0) return; + if (hasEntry(buf[0..len.*], entry)) return; + const separator: usize = if (len.* == 0) 0 else 1; + if (len.* + separator + entry.len > buf.len) return; + if (separator == 1) { + buf[len.*] = ':'; + len.* += 1; + } + @memcpy(buf[len.*..][0..entry.len], entry); + len.* += entry.len; +} + +fn hasEntry(list: []const u8, entry: []const u8) bool { + var it = std.mem.tokenizeScalar(u8, list, ':'); + while (it.next()) |have| if (std.mem.eql(u8, have, entry)) return true; + return false; +} + +/// Whether `candidate` holds nothing `list` does not. An empty candidate is +/// within any list: a process with no PATH at all is the launchd case too. +fn allEntriesWithin(candidate: []const u8, list: []const u8) bool { + var it = std.mem.tokenizeScalar(u8, candidate, ':'); + while (it.next()) |entry| if (!hasEntry(list, entry)) return false; + return true; +} + +fn readSmall(path: [:0]const u8, buf: []u8) ?[]const u8 { + const fd = libc.open(path, .{ .ACCMODE = .RDONLY }, @as(libc.mode_t, 0)); + if (fd < 0) return null; + defer _ = libc.close(fd); + var off: usize = 0; + while (off < buf.len) { + const n = libc.read(fd, buf[off..].ptr, buf.len - off); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + return null; + } + if (n == 0) break; + off += @intCast(n); + } + return buf[0..off]; +} + +test "the launchd PATH is replaced and a configured one is left alone" { + var buf: [256]u8 = undefined; + var len: usize = 0; + appendEntry(&buf, &len, "/usr/bin"); + appendEntry(&buf, &len, "/bin"); + appendEntry(&buf, &len, "/usr/bin"); // already there: dedup keeps the first + appendEntry(&buf, &len, ""); + try std.testing.expectEqualStrings("/usr/bin:/bin", buf[0..len]); + + // Exactly the launchd default, in any order: nothing here is a choice. + try std.testing.expect(allEntriesWithin("/usr/bin:/bin", "/usr/bin:/bin:/sbin")); + try std.testing.expect(allEntriesWithin("", "/usr/bin")); + // One entry nobody could have inherited by accident, and the whole PATH is + // off limits — reordering it behind /usr/bin is how a version manager stops + // deciding which `node` runs. + try std.testing.expect(!allEntriesWithin("/Users/x/.cargo/bin:/usr/bin", "/usr/bin:/bin")); + try std.testing.expect(!allEntriesWithin("/opt/homebrew/bin", "/usr/bin:/bin")); +} + +test "the composed system path is the real one, in path_helper's order" { + if (comptime builtin.os.tag != .macos) return; + var buf: [path_capacity]u8 = undefined; + var len: usize = 0; + collectSystemPath(&buf, &len); + const composed = buf[0..len]; + // /etc/paths exists on every mac and leads with these. + try std.testing.expect(hasEntry(composed, "/usr/bin")); + try std.testing.expect(hasEntry(composed, "/bin")); + // ...and its entries come before anything /etc/paths.d contributes, which + // is the precedence the order encodes. + try std.testing.expect(std.mem.startsWith(u8, composed, "/usr/local/bin:")); + // No duplicates: /etc/paths.d files routinely repeat a system directory. + var seen = std.mem.tokenizeScalar(u8, composed, ':'); + var index: usize = 0; + while (seen.next()) |entry| : (index += 1) { + var rest = std.mem.tokenizeScalar(u8, composed, ':'); + var matches: usize = 0; + while (rest.next()) |other| if (std.mem.eql(u8, other, entry)) { + matches += 1; + }; + try std.testing.expectEqual(@as(usize, 1), matches); + } +} /// Prompt integration, per shell FAMILY rather than per binary: pardes hides /// prompt rows, moves the cursor by clicking one, and tells a command's output diff --git a/src/term_pane.zig b/src/term_pane.zig index 75229e4e..17e50c88 100644 --- a/src/term_pane.zig +++ b/src/term_pane.zig @@ -28,6 +28,7 @@ const EditText = pardes.EditText; const modal = @import("modal.zig"); const config = @import("config.zig"); const dump = @import("dump.zig"); +const tracy = @import("tracy.zig"); // no-op unless -Dtracy names a checkout /// `pardes.terminal_panes`, re-exported so every gate in this file reads one /// local name. When false the import below is a DEAD comptime branch, so @@ -1478,6 +1479,13 @@ pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bo const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; var filtered_storage: FilteredColors = undefined; const filtered: ?*FilteredColors = if (pane.tty_filter) blk: { + // Enumerating the indexed answers is the whole cost of the filter that + // is NOT per cell, so it gets to be visible on its own: this is the + // number that says whether the tables should be cached across frames + // rather than rebuilt per pass. Measured at 2.9 us warm against a + // 117 us `paneRecolor`, which is why they are not. + const tz_filter = tracy.zone(@src(), "filterInit"); + defer tz_filter.end(); filtered_storage = FilteredColors.init(p, pane); break :blk &filtered_storage; } else null; @@ -1720,11 +1728,22 @@ fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColor return cs; } -/// Per-render resolver. The source palette is materialized through Ghostty's -/// public xterm API, so OSC 4 changes participate without reaching into the -/// emulator's private state. Truecolour and visually overridden entries are +/// Per-render resolver, in two stages. The source palette is materialized +/// through Ghostty's public xterm API, so OSC 4 changes participate without +/// reaching into the emulator's private state. +/// +/// STAGE ONE is the default foreground and background roles, because they are +/// the anchors: Ghostty generates the whole 256-colour projection from them, +/// and `default_bg` below is the page every other colour is judged against. +/// +/// STAGE TWO is everything else. Truecolour and visually overridden entries are /// reduced to the nearest canonical Ghostty palette key; the key then indexes -/// the theme palette. Repeated RGBs pay that search only once per frame. +/// the theme palette. Repeated RGBs pay that search only once per frame. A +/// FOREGROUND additionally has to clear `config.tty_filter_min_contrast` +/// against `default_bg` — the reduction is an RGB distance and knows nothing +/// about the page, and the projection's cube corners are the anchors +/// themselves, so without the gate the nearest key to a truecolour extreme is +/// the background and the text is painted in the colour of the page. const FilteredColors = struct { source: GColor.Palette, target: *const GColor.Palette, @@ -1732,6 +1751,35 @@ const FilteredColors = struct { theme_fg: GColor.RGB, dynamic_bg: ?GColor.RGB, dynamic_fg: ?GColor.RGB, + /// Stage one's background, mapped: exactly what `bg` answers for a cell + /// that names no colour of its own, and therefore the page a foreground + /// has to stay legible against. + default_bg: GColor.RGB, + /// What a foreground too near `default_bg` becomes instead. + fallback_fg: GColor.RGB, + /// `default_bg`'s luminance, computed once. `legible` runs per CELL and + /// asks for the contrast ratio against this same colour every time; the + /// half of the ratio that belongs to the background never changes. + default_bg_luminance: f64, + /// Every answer the INDEXED path can give, resolved before the first + /// cell is read. + /// + /// A cell that names a palette colour has 256 possible inputs, and this + /// filter is a pure function of them: the OSC 4 comparison, the theme + /// projection and the contrast gate all depend only on the index and on + /// state that is fixed for the whole pass. So the per-cell chain + /// collapses to one array read, and `legible` - six libm `pow` calls + /// through `RGB.contrast`, which profiling put at 12 of 43 draw samples + /// - stops being a per-cell cost entirely. + /// + /// Only TRUECOLOUR still searches: it carries arbitrary RGB, so its + /// answers cannot be enumerated and the direct-mapped cache below is + /// what keeps it cheap. + fg_for_palette: [256]pardes.Color = undefined, + bg_for_palette: [256]pardes.Color = undefined, + /// The two answers for a cell that names no colour of its own. + fg_default: pardes.Color = undefined, + bg_default: pardes.Color = undefined, // Direct-mapped rather than append-only: a frame which encounters more // than the cache's capacity must not strand every later (and repeated) // colour on the 256-entry nearest-key scan. The RGB hash spreads the @@ -1756,54 +1804,96 @@ const FilteredColors = struct { std.mem.swap(GColor.RGB, &theme_bg, &theme_fg); std.mem.swap(?GColor.RGB, &dynamic_bg, &dynamic_fg); } - return .{ + var self: FilteredColors = .{ .source = source, .target = p.tty_filter_palette.get(theme), .theme_bg = theme_bg, .theme_fg = theme_fg, .dynamic_bg = dynamic_bg, .dynamic_fg = dynamic_fg, + .default_bg = theme_bg, + .fallback_fg = theme_fg, + .default_bg_luminance = luminanceOf(theme_bg), }; + // Stage one, finished before a single other colour is mapped. OSC 11 + // moves the page, so the floor moves with it; the anchor that survives + // as the fallback is then whichever of the theme's own pair can still + // be seen on it, which on an untouched terminal is always the theme's + // foreground (a background has no contrast with itself). + if (dynamic_bg) |rgb| self.default_bg = self.keyedRgb(rgb); + self.default_bg_luminance = luminanceOf(self.default_bg); + if (self.theme_bg.contrast(self.default_bg) > self.theme_fg.contrast(self.default_bg)) + self.fallback_fg = self.theme_bg; + + // Stage two, ENUMERATED rather than answered per cell. Everything the + // indexed path needs is now fixed, and its input is a u8, so every + // answer it can ever give is computed here - once for the pass, not + // once for each of the tens of thousands of cells that will ask. + self.fg_default = asPardesColor(self.legible( + if (self.dynamic_fg) |rgb| self.keyedRgb(rgb) else self.theme_fg, + )); + self.bg_default = asPardesColor(self.default_bg); + for (&self.source, 0..) |current, i| { + const idx: u8 = @intCast(i); + const mapped = self.paletteRgb(idx, current); + self.fg_for_palette[idx] = asPardesColor(self.legible(mapped)); + self.bg_for_palette[idx] = asPardesColor(mapped); + } + return self; } + /// One array read for every colour a cell can NAME. Only truecolour, + /// whose 16.7M inputs cannot be enumerated, reaches the reduction - and + /// `style.fg` is now asked only on that path, because the other two + /// answers no longer depend on it. fn fg(self: *FilteredColors, style: ghostty_vt.Style) pardes.Color { - const resolved = style.fg(.{ - .default = self.dynamic_fg orelse self.theme_fg, - .palette = &self.source, - .bold = null, - }); return switch (style.fg_color) { - .none => if (self.dynamic_fg) |rgb| self.keyed(rgb) else asPardesColor(self.theme_fg), - .palette => |idx| self.palette(idx, resolved), - .rgb => self.keyed(resolved), + .none => self.fg_default, + .palette => |idx| self.fg_for_palette[idx], + .rgb => asPardesColor(self.legible(self.keyedRgb(style.fg(.{ + .default = self.dynamic_fg orelse self.theme_fg, + .palette = &self.source, + .bold = null, + })))), }; } fn bg(self: *FilteredColors, style: ghostty_vt.Style, cell: *const ghostty_vt.Cell) pardes.Color { - const resolved = style.bg(cell, &self.source); - return switch (cell.content_tag) { - .bg_color_palette => self.palette(cell.content.color_palette.data, resolved.?), - .bg_color_rgb => self.keyed(resolved.?), + switch (cell.content_tag) { + .bg_color_palette => return self.bg_for_palette[cell.content.color_palette.data], + .bg_color_rgb => {}, else => switch (style.bg_color) { - .none => if (self.dynamic_bg) |rgb| self.keyed(rgb) else asPardesColor(self.theme_bg), - .palette => |idx| self.palette(idx, resolved.?), - .rgb => self.keyed(resolved.?), + .none => return self.bg_default, + .palette => |idx| return self.bg_for_palette[idx], + .rgb => {}, }, - }; + } + // Truecolour, from either the cell or its style. + return asPardesColor(self.keyedRgb(style.bg(cell, &self.source).?)); + } + + /// Stage two's only rule, and a FOREGROUND rule: a background IS the page + /// for whatever is drawn over it, so holding one away from itself would be + /// meaningless. An ANSI black on a dark theme and a truecolour white on a + /// light one both reduce to the key whose projected value is the page — + /// ratio 1.000, invisible text — and both land here instead. + fn legible(self: *const FilteredColors, rgb: GColor.RGB) GColor.RGB { + if (contrastOf(luminanceOf(rgb), self.default_bg_luminance) >= + config.tty_filter_min_contrast) return rgb; + return self.fallback_fg; } /// Preserve an ordinary indexed colour's semantic key. A value changed by /// OSC 4 instead carries arbitrary RGB intent, so key that RGB the same way /// as truecolour. Setting an entry to its exact original value is visually /// indistinguishable and correctly takes this fast path. - fn palette(self: *FilteredColors, idx: u8, current: GColor.RGB) pardes.Color { - if (current.eql(GColor.default[idx])) return asPardesColor(self.target[idx]); - return self.keyed(current); + fn paletteRgb(self: *FilteredColors, idx: u8, current: GColor.RGB) GColor.RGB { + if (current.eql(GColor.default[idx])) return self.target[idx]; + return self.keyedRgb(current); } - fn keyed(self: *FilteredColors, rgb: GColor.RGB) pardes.Color { - const key = self.nearestKey(rgb); - return asPardesColor(self.target[key]); + fn keyedRgb(self: *FilteredColors, rgb: GColor.RGB) GColor.RGB { + return self.target[self.nearestKey(rgb)]; } fn nearestKey(self: *FilteredColors, rgb: GColor.RGB) u8 { @@ -1830,6 +1920,46 @@ const FilteredColors = struct { } }; +/// W3C relative luminance per 8-bit channel, precomputed. +/// +/// ghostty's `RGB.componentLuminance` ends in `std.math.pow(f64, x, 2.4)` +/// (color.zig:474), `luminance` calls it three times, and `contrast` calls +/// `luminance` for BOTH colours — so `legible`'s single `rgb.contrast(bg)` is +/// up to six libm `pow` calls, per cell, per frame. Profiling the AppKit shell +/// put `cellStyle -> FilteredColors.legible -> RGB.contrast` at 12 of 43 draw +/// samples; the whole rest of `recolorAnsi` was 3. +/// +/// The input is a `u8`. There are 256 possible answers. This is the table. +/// +/// Bit-identical to ghostty's function by construction — same expression, +/// evaluated at comptime — so the filter's decisions do not move. The +/// equivalence test below pins that. +const channel_luminance: [256]f64 = blk: { + @setEvalBranchQuota(20000); + var table: [256]f64 = undefined; + for (&table, 0..) |*slot, c| { + const normalized: f64 = @as(f64, @floatFromInt(c)) / 255; + slot.* = if (normalized <= 0.03928) + normalized / 12.92 + else + std.math.pow(f64, (normalized + 0.055) / 1.055, 2.4); + } + break :blk table; +}; + +fn luminanceOf(rgb: GColor.RGB) f64 { + return 0.2126 * channel_luminance[rgb.r] + + 0.7152 * channel_luminance[rgb.g] + + 0.0722 * channel_luminance[rgb.b]; +} + +/// ghostty's `RGB.contrast` with both luminances already in hand. +fn contrastOf(a_luminance: f64, b_luminance: f64) f64 { + const lighter = @max(a_luminance, b_luminance); + const darker = @min(a_luminance, b_luminance); + return (lighter + 0.05) / (darker + 0.05); +} + fn colorDistance(a: GColor.RGB, b: GColor.RGB) u32 { const dr = @as(i32, a.r) - @as(i32, b.r); const dg = @as(i32, a.g) - @as(i32, b.g); @@ -1837,6 +1967,29 @@ fn colorDistance(a: GColor.RGB, b: GColor.RGB) u32 { return @intCast(dr * dr + dg * dg + db * db); } +test "the luminance table answers exactly what ghostty computes" { + // The filter's decisions are a threshold comparison on these numbers, so + // "close enough" is not enough: one ULP either side of + // `tty_filter_min_contrast` is a different colour on screen. Every + // channel value, and the pairs a real pass actually asks about. + for (0..256) |i| { + const c: u8 = @intCast(i); + const grey: GColor.RGB = .{ .r = c, .g = c, .b = c }; + try std.testing.expectEqual(grey.luminance(), luminanceOf(grey)); + } + // Channel weights are asymmetric, so a grey ramp alone would not catch a + // transposed coefficient. The palette is what the tables enumerate. + for (GColor.default) |candidate| { + try std.testing.expectEqual(candidate.luminance(), luminanceOf(candidate)); + for (GColor.default) |page| { + try std.testing.expectEqual( + candidate.contrast(page), + contrastOf(luminanceOf(candidate), luminanceOf(page)), + ); + } + } +} + test "terminal Filter keys indexed truecolor OSC and background-only cells through the theme" { const testing = std.testing; const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); @@ -1947,16 +2100,24 @@ test "terminal Filter keys indexed truecolor OSC and background-only cells throu try testing.expectEqual(asPardesColor(expected[59]), blank.bg); // Ghostty owns DEC reverse-screen parsing. Filter follows that mode for - // the dynamic/default roles while leaving an explicit ANSI foreground on - // an existing cell bound to the same semantic palette key. + // the dynamic/default roles, and here the swap turns this cell into a + // COLLISION: its explicit ANSI foreground is the OSC 4 red keyed to 196, + // and reverse video has just made that same red the page. Stage two + // refuses the mapping rather than painting red on red, so the ink becomes + // the anchor still visible on it — under the swap, the theme's own + // background colour. Unreversed, the very same cell keeps key 196. const explicit_before_reverse = dynamic.at(tx, body_y).style.fg; + try testing.expectEqual(asPardesColor(expected[196]), explicit_before_reverse); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); _ = frame.reset(.retain_capacity); const reversed = try p.render(frame.allocator()); const reversed_blank = reversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; try testing.expectEqual(asPardesColor(expected[59]), reversed_blank.fg); try testing.expectEqual(asPardesColor(expected[196]), reversed_blank.bg); - try testing.expectEqual(explicit_before_reverse, reversed.at(tx, body_y).style.fg); + const reversed_explicit = reversed.at(tx, body_y).style; + try testing.expectEqual(asPardesColor(expected[196]), reversed_explicit.bg); + try testing.expectEqual(pardes.Color{ .rgb = p.theme().bg.? }, reversed_explicit.fg); + try testing.expect(!std.meta.eql(reversed_explicit.fg, reversed_explicit.bg)); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5l" } }); _ = frame.reset(.retain_capacity); @@ -2029,6 +2190,146 @@ test "terminal Filter preserves exact palette-null light theme default roles" { try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, reversed.at(tx, body_y).style.fg); try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, reversed.at(tx, body_y).style.bg); } + +test "terminal Filter maps the default roles before it maps anything else" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + try testing.expect(pane.tty_filter); + + // Stage one is the two anchors, and they are taken from the theme WHOLE: + // a cell that names no colour of its own is not routed through the + // projection at all, so the page and the ink are exactly the theme's. + for (0..3) |t| { + p.settings.theme = @intCast(t); + const stage_one = FilteredColors.init(p, pane); + // `dark` declares no background of its own, which is exactly why the + // resolver reads the tag colours as the fallback rather than `.?`. + const theme = p.theme(); + try testing.expect(stage_one.theme_bg.eql(asGhostRgb(theme.bg orelse theme.tag_bg))); + try testing.expect(stage_one.theme_fg.eql(asGhostRgb(theme.fg orelse theme.tag_fg))); + // With no OSC 11 in play the mapped page IS that anchor, and the + // fallback is the other one: a background never contrasts with itself. + try testing.expect(stage_one.default_bg.eql(stage_one.theme_bg)); + try testing.expect(stage_one.fallback_fg.eql(stage_one.theme_fg)); + } + + // OSC 11 moves the page, and stage one moves with it: the reference the + // floor is measured against becomes the PROJECTED dynamic background, not + // the theme's, because that is what `bg` paints behind a default cell. + p.settings.theme = 0; + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]11;#5f5f5f\x1b\\" } }); + var moved = FilteredColors.init(p, pane); + try testing.expect(!moved.default_bg.eql(moved.theme_bg)); + try testing.expect(moved.default_bg.eql(moved.keyedRgb(.{ .r = 0x5f, .g = 0x5f, .b = 0x5f }))); +} + +test "terminal Filter refuses a foreground that would collapse onto the page" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + try testing.expect(pane.tty_filter); + + // Two ways to land on the page, one per theme orientation. On the light + // theme the projection's white corner IS the paper, so a truecolour white + // reduces to it; on a dark theme the same is true of ANSI black, which a + // shell reaches for with a bare `\x1b[30m` and which takes the semantic + // fast path rather than the nearest-key scan. Both used to render text in + // the colour of the page under it. + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[38;2;255;255;255mW" ++ + "\x1b[0;30mB" ++ + "\x1b[0;31mR" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + for (0..3) |t| { + p.settings.theme = @intCast(t); + var fc = FilteredColors.init(p, pane); + const page = asPardesColor(fc.default_bg); + const rescued = asPardesColor(fc.fallback_fg); + _ = frame.reset(.retain_capacity); + const g = try p.render(frame.allocator()); + + // The colour each of the three would have been given with no floor. + const raw_white = fc.keyedRgb(.{ .r = 255, .g = 255, .b = 255 }); + const raw_black = fc.paletteRgb(0, GColor.default[0]); + const raw_red = fc.paletteRgb(1, GColor.default[1]); + + for ([_]struct { at: u16, raw: GColor.RGB }{ + .{ .at = 0, .raw = raw_white }, + .{ .at = 1, .raw = raw_black }, + .{ .at = 2, .raw = raw_red }, + }) |case| { + const cell = g.at(tx + case.at, body_y).style; + try testing.expectEqual(page, cell.bg); + if (case.raw.contrast(fc.default_bg) < config.tty_filter_min_contrast) { + // Refused: the projection's answer is not painted, the anchor is. + try testing.expectEqual(rescued, cell.fg); + try testing.expect(!std.meta.eql(cell.fg, cell.bg)); + } else { + // Cleared the floor, so stage two leaves it exactly alone. + try testing.expectEqual(asPardesColor(case.raw), cell.fg); + } + // Either way a filtered cell delegates neither colour to a backend. + switch (cell.fg) { + .rgb => |ink| try testing.expect(asGhostRgb(ink).contrast(fc.default_bg) >= + config.tty_filter_min_contrast), + else => return error.FilteredForegroundWasNotRgb, + } + } + + // At least one of the three has to have been a real collapse, or this + // theme proved nothing: white on the light theme, black on the dark. + try testing.expect(raw_white.contrast(fc.default_bg) < config.tty_filter_min_contrast or + raw_black.contrast(fc.default_bg) < config.tty_filter_min_contrast); + // A saturated red is never the page on any curated theme. + try testing.expect(raw_red.contrast(fc.default_bg) >= config.tty_filter_min_contrast); + } +} + +test "terminal Filter holds every projected foreground off the page" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + + // The invariant over the WHOLE projection rather than a sampled colour: + // whatever key a foreground reduces to, what stage two hands back clears + // the floor. A background is exempt by construction and must stay so — + // `bg` is what the floor is measured against. + for (0..3) |t| { + p.settings.theme = @intCast(t); + var fc = FilteredColors.init(p, pane); + var refused: usize = 0; + for (fc.target, 0..) |projected, key| { + const ink = fc.legible(projected); + try testing.expect(ink.contrast(fc.default_bg) >= config.tty_filter_min_contrast); + if (!ink.eql(projected)) { + refused += 1; + try testing.expect(ink.eql(fc.fallback_fg)); + // Only ever refused for being too near the page. + try testing.expect(projected.contrast(fc.default_bg) < config.tty_filter_min_contrast); + } + // The key a background asks for is handed back untouched, including + // the one whose value is the page itself. + try testing.expect(fc.keyedRgb(GColor.default[key]).eql(fc.target[fc.nearestKey(GColor.default[key])])); + } + // Every curated theme owns at least one collapsing key — that is why + // the floor exists — and the floor must not be flattening the palette. + try testing.expect(refused > 0); + try testing.expect(refused < fc.target.len / 8); + } +} + test "tty ansi colors follow the prompt hug into normal mode" { const testing = std.testing; const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); diff --git a/src/tty/tty.zig b/src/tty/tty.zig index e8a36035..ca263f90 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -86,63 +86,14 @@ pub const Command = struct { }; const Loop = vaxis.Loop(@TypeOf(Command.value)); -/// One language query, owned by the worker that runs it. Everything the -/// backend may read is copied in here before the worker starts: the core goes -/// on editing the moment the effect is drained, so a borrowed slice would be a -/// use-after-free the length of one keystroke. -const LspJob = struct { - id: u32, - kind: pardes.lsp.Kind, - offset: u32, - path: []u8, - source: [:0]u8, - arg: []u8, - root: []u8, - - fn free(j: *LspJob, allocator: std.mem.Allocator) void { - allocator.free(j.path); - allocator.free(j.source); - allocator.free(j.arg); - allocator.free(j.root); - allocator.destroy(j); - } -}; - -const max_pipe_tasks = 16; - -const PipeTask = struct { - id: u32, - future: std.Io.Future(anyerror!void), -}; - -const PipeTasks = struct { - items: [max_pipe_tasks]PipeTask = undefined, - len: usize = 0, - - fn full(tasks: *const PipeTasks) bool { - return tasks.len == tasks.items.len; - } - - fn add(tasks: *PipeTasks, task: PipeTask) void { - std.debug.assert(!tasks.full()); - tasks.items[tasks.len] = task; - tasks.len += 1; - } - - fn finish(tasks: *PipeTasks, io: std.Io, id: u32) void { - for (tasks.items[0..tasks.len], 0..) |*task, i| if (task.id == id) { - task.future.await(io) catch {}; - tasks.len -= 1; - std.mem.copyForwards(PipeTask, tasks.items[i..tasks.len], tasks.items[i + 1 .. tasks.len + 1]); - return; - }; - } +/// The shared snapshot/worker pair every native shell uses. This file used to +/// carry its own `LspJob` and gui.zig carried a copy of it; the copies said so. +const lsp_host = @import("../lsp_host.zig"); - fn cancelAll(tasks: *PipeTasks, io: std.Io) void { - for (tasks.items[0..tasks.len]) |*task| task.future.cancel(io) catch {}; - tasks.len = 0; - } -}; +/// The pipe in-flight set moved to `selection_pipe.Tasks`, beside the Job it +/// tracks: gui.zig carried this same table verbatim. +const PipeTask = selection_pipe.Tasks.Task; +const PipeTasks = selection_pipe.Tasks; const Pty = struct { file: std.Io.File, @@ -655,13 +606,10 @@ fn localSession( } else try pardes.Pardes.init(allocs.pardes, options); defer core.deinit(); - // Private, complete before any fork and retained until the last possible - // spawn; children borrow only these stable in-struct path buffers. - var prompt_rcs = shell_bin.PromptRcs.init(); + // PATH, the bash banner and the prompt rc files, in the one order that + // works. Children borrow only these stable in-struct path buffers. + var prompt_rcs = shell_bin.prepareForFork(); defer prompt_rcs.deinit(); - // macos: apple's bash 3.2 prints the zsh-deprecation banner into every - // pane unless this is in the env BEFORE bash starts (the rc is too late) - if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame); defer frame_arena.deinit(); @@ -1527,39 +1475,7 @@ const Shell = struct { fn lsp(ctx: ?*anyopaque, req: host_api.LspRequest) void { const s = of(ctx); if (!s.threads_ok) return; // pre-loop drain: nothing to answer to yet - const pane = s.core.panes[req.pane] orelse return; - // a pane with no file still asks `status` (it is about the backend, - // not the buffer): empty path and source, root from the pane's cwd - const f = pane.file; - const a = s.lsp_gpa; - const job = a.create(LspJob) catch return; - job.* = .{ - .id = req.id, - .kind = req.kind, - .offset = req.offset, - .path = a.dupe(u8, if (f) |ff| ff.path else "") catch { - a.destroy(job); - return; - }, - .source = a.dupeZ(u8, if (f) |ff| ff.content else "") catch { - a.free(job.path); - a.destroy(job); - return; - }, - .arg = a.dupe(u8, req.arg) catch { - a.free(job.path); - a.free(job.source); - a.destroy(job); - return; - }, - .root = a.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch { - a.free(job.path); - a.free(job.source); - a.free(job.arg); - a.destroy(job); - return; - }, - }; + const job = lsp_host.snapshot(s.lsp_gpa, s.core, req) orelse return; // ponytail: ONE query in flight, so one future slot. Replacing it // cancels-then-joins the previous worker, which for a backend that // ignores cancellation means waiting out a query the user already @@ -1569,8 +1485,8 @@ const Shell = struct { old.cancel(s.io) catch {}; s.lsp_task = null; } - s.lsp_task = s.io.concurrent(lspWorker, .{ a, job, s.loop }) catch { - job.free(a); + s.lsp_task = s.io.concurrent(lspWorker, .{ s.lsp_gpa, job, s.loop }) catch { + job.free(s.lsp_gpa); return; }; } @@ -1588,7 +1504,9 @@ const Shell = struct { job.deinit(s.gpa); return; }; - s.pipe_tasks.add(.{ .id = id, .future = future }); + // `full()` was checked above, so this cannot fail; assert rather than + // discard, because a silently dropped task is a future nobody joins. + std.debug.assert(s.pipe_tasks.add(.{ .id = id, .future = future })); } }; @@ -1617,30 +1535,24 @@ fn requestClipboard(vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { vx.requestSystemClipboard(tty.writer()) catch {}; } -/// Answer a language query off the event loop and post the rows back. This is -/// the whole async execution model: the same shape as readPty — do the slow -/// thing on a worker, hand the result to the loop as an event, let the core -/// stay a state machine that never blocks. -fn lspWorker(allocator: std.mem.Allocator, job: *LspJob, loop: *Loop) anyerror!void { - defer job.free(allocator); - var arena: std.heap.ArenaAllocator = .init(allocator); - defer arena.deinit(); - // The shell owns the result buffer; the backend only ever writes to it. - var out: std.Io.Writer.Allocating = .init(allocator); - defer out.deinit(); - pardes.lsp.query(allocator, arena.allocator(), .{ - .kind = job.kind, - .path = job.path, - .source = job.source, - .offset = job.offset, - .arg = job.arg, - .root = job.root, - }, &out.writer); - const rows = allocator.dupe(u8, out.written()) catch return; - loop.postEvent(.{ .lsp_done = .{ .id = job.id, .rows = rows } }) catch allocator.free(rows); - return; +/// Answer a language query off the event loop and post the rows back. The +/// snapshot and the query body are `lsp_host`'s; the only part that is this +/// shell's is the vaxis event the rows travel home on. +fn lspWorker(allocator: std.mem.Allocator, job: *lsp_host.Job, loop: *Loop) anyerror!void { + var sink: LspRowSink = .{ .allocator = allocator, .loop = loop }; + lsp_host.work(allocator, job, &sink, LspRowSink.take); } +const LspRowSink = struct { + allocator: std.mem.Allocator, + loop: *Loop, + + fn take(ctx: ?*anyopaque, id: u32, rows: []u8) void { + const s: *LspRowSink = @ptrCast(@alignCast(ctx orelse return)); + s.loop.postEvent(.{ .lsp_done = .{ .id = id, .rows = rows } }) catch s.allocator.free(rows); + } +}; + /// The registered `lsp.setStatusSink` target, called from the protocol /// client's READER threads. Only thread-safe, NON-BLOCKING things happen /// here: a dupe with the concurrent lsp allocator and a TRY-post onto the -- cgit v1.3