diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-01 14:34:19 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-02 16:35:21 -0300 |
| commit | 3f2d6f43199d0e230490396deb50f8dc49c7b8b0 (patch) | |
| tree | 309d464fe96487545082920b4080639576644bdf /src/gui/gui.zig | |
| parent | cce6b18a49870086982f9a0e1fda90ed170b9fba (diff) | |
| download | pardes-3f2d6f43199d0e230490396deb50f8dc49c7b8b0.tar.gz pardes-3f2d6f43199d0e230490396deb50f8dc49c7b8b0.zip | |
hosts: the effects three shells kept a copy of become one, and the mac's own bugs go with them
Nine read-only scouts compared every host-side concern across `src/macos.zig`,
`src/tty/tty.zig`, `src/gui/gui.zig` and `src/detached/server.zig`. What they
found was not a style problem: each duplicated body had drifted, and in every
case the drift WAS a bug the users of that shell could see. So the fixes and
the deduplication are the same change.
**One PATH, adopted before the first fork.** LaunchServices hands a bundle
launchd's environment, whose `PATH` is `/usr/bin:/bin:/usr/sbin:/sbin`. Every
pty shell, `|` filter and language server the app forked inherited it, so
`yazi` in `/opt/homebrew/bin` was absent from a Dock launch and present in the
identical binary run from a terminal — the "it worked briefly" window was
simply the sessions started from a shell. `shell_bin.adoptSystemPath` composes
`/etc/paths` then `/etc/paths.d/*` in the order `path_helper` reads them,
deduplicating on first occurrence, and runs once at startup in all four native
hosts. It APPENDS: an entry already present keeps its position, so running it
over a real session cannot demote a mise shim behind `/usr/bin` and silently
change which `node` runs. A `PATH` that was configured is left byte-for-byte
alone; only one nobody configured is repaired. `prepareForFork` folds that
adoption together with the prompt-rc staging and the `BASH_SILENCE_DEPRECATION_WARNING`
setenv the five hand-copied prefork sites had between them — `server.zig` had
none of it, which is why every detached pane opened with Apple's zsh banner.
**The LSP protocol client never worked on macOS.** It opened its control
socket with `libc.SOCK.CLOEXEC`; Zig defines that constant for Linux and
Darwin answers `socketpair` with `EPROTONOSUPPORT`, so the call failed before
any fork, `ensure` returned `error.NoServer`, and every row in the spec table
— rust-analyzer, clangd, gopls — was unreachable in every macOS build. The
in-process ZLS backend kept answering, which is what made it read as "only Zig
is supported". It is a plain socket plus `fcntl(FD_CLOEXEC)` now, the route
`fuse.zig:943` and `nested.zig:95` already took for the same reason. The
snapshot suite that covered this path had never run natively on a Mac: the
harness targets defaulted to x86_64-linux.
**One LSP host worker.** `src/lsp_host.zig` is the snapshot, the worker body
and the job lifetime that `tty.zig` and `gui.zig` carried verbatim — `gui.zig`
said so in a comment — and that `macos.zig` did not carry at all: `lsp` and
`pipe` were absent from its `Host.VTable`, so the core answered its own empty
answer, `SPC l i` rendered a blank panel and a `|` filter silently did
nothing. All three shells share the module, and the AppKit host implements
both effects. Its status sink is now REGISTERED as well as defined, so
unsolicited server news reaches the message row instead of nowhere.
**The animation clock measures time.** `pardes_animation_tick` advanced one
scene frame per callback and published `frame_count / 60`, so scene time was a
count of callbacks rather than elapsed seconds — and `AppDelegate` re-armed
`asyncAfter(.now() + 0.016)` only after the previous frame's work had
finished, making the true period 16 ms plus all of it. Motion ran at about
three quarters of wall clock and unevenly. The tick now spends measured
monotonic time in whole `frame_ns` steps and banks the remainder, so a late
callback advances two frames instead of stretching one; `spendTickTime` is
that arithmetic as a pure function with its own tests and no display attached.
On macOS 14+ the animating run is one `CADisplayLink` phase-locked to vsync
rather than a chain rebuilt after every frame; macOS 13 keeps the old chain.
**Three more single definitions.** `panel_animation.paintOrder` is the
moving-then-opening-then-closing composite order as a rule the core applies
once in `Pardes.render` — `macos.zig` was re-sorting an already-sorted list.
`selection_pipe.Tasks` is the bounded in-flight pipe table `tty.zig` and
`gui.zig` each declared. `boxContains` was a fourth copy of the half-open cell
test and is now an alias of `Box.contains`.
**A filtered terminal stops asking libm per cell.** `Filter`'s legibility
stage called `RGB.contrast` for every painted cell, and that ends in
`std.math.pow` up to six times, re-deriving a ratio against a background that
had not moved; the existing memo cache covered the palette reduction beside it
and never this. The indexed path's input is a `u8`, so all 256 answers are
enumerated once per pass — after the default roles are fixed, before the first
cell is read — and what a cell names becomes an array index. Only truecolour
still reduces. ReleaseFast, 190x56, Tracy: recolour 3.09 ms -> 0.130 ms,
frame 3.37 ms -> 0.299 ms. The comptime luminance table is pinned to
`RGB.luminance` and `RGB.contrast` by exact-equality test over every channel
value and all 65 536 palette pairs, because the decision is a threshold
comparison where one ULP is a different colour. A `filterInit` Tracy zone
records the part that is still per-pass: 2.9 us warm against a 117 us pass,
which is the measurement that says not to cache it across frames.
Released as 0.0.2. `build.zig.zon` carries the version into `pardes --version`
and into the `Changelog` pane through `@embedFile`, so the entries above open a
`## 0.0.2` section and `## 0.0.1` closes with the tagline work of the parent
commit.
Two bugs here were mine, caught by review rather than by me: a double free in
the macOS pipe drain arm (`Msg.free` already owns the response) that segfaulted
the app on the first `|`, and a proposed `getRowAndCell` optimisation that
targeted 2 of 43 draw samples while the contrast math beside it took 12 — and
would not have compiled. The profile that justified it was a Debug build, which
`build.zig:1160` already documents as ~5x slower than release.
Native and -Dplatform=macos suites: 0 failures. All targets build with Tracy on
and off; the shipped release binary contains no `___tracy_emit_zone_begin`.
App reinstalled, signature verified, dmg regenerated, launched with 0 crash
reports; installed binaries verified byte-identical to a fresh build.
Diffstat (limited to 'src/gui/gui.zig')
| -rw-r--r-- | src/gui/gui.zig | 221 |
1 files changed, 54 insertions, 167 deletions
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 = .{ |
