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/pardes.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/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 235 |
1 files changed, 215 insertions, 20 deletions
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; |
