summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/CHANGELOG.md75
-rw-r--r--src/config.zig29
-rw-r--r--src/detached/server.zig9
-rw-r--r--src/gui/gui.zig221
-rw-r--r--src/lsp/lsp_client.zig87
-rw-r--r--src/lsp_host.zig206
-rw-r--r--src/macos.zig565
-rw-r--r--src/macos/Sources/AppDelegate.swift63
-rw-r--r--src/macos/Sources/PardesView.swift237
-rw-r--r--src/macos/icon.pngbin0 -> 1788282 bytes
-rw-r--r--src/macos/icon.swift248
-rw-r--r--src/macos/pardes.h32
-rw-r--r--src/panel_animation.zig38
-rw-r--r--src/pardes.zig235
-rw-r--r--src/selection_pipe.zig49
-rw-r--r--src/shell_bin.zig221
-rw-r--r--src/term_pane.zig361
-rw-r--r--src/tty/tty.zig152
18 files changed, 2190 insertions, 638 deletions
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 <path>` line from a pardes launched inside this one. Arrives
/// on the listener thread; runs, like everything else, on the main one.
command: []u8,
+ /// A language query finished on a worker; `rows` are gpa-owned. NOT lossy:
+ /// the core is holding a request id open for exactly this, and dropping it
+ /// leaves `lsp_wait` armed and every later query dead.
+ lsp_done: struct { id: u32, rows: []u8 },
+ /// Unsolicited server state — "rust-analyzer indexing 45%" — for the
+ /// transient message row. Periodic news, so it IS lossy: a dropped line is
+ /// repriced by the next one.
+ lsp_status: []u8,
+ /// A `|` filter finished on a worker. NOT lossy for the same reason
+ /// `lsp_done` is not: the core is holding a request id open for it.
+ pipe: selection_pipe.Response,
fn free(m: Msg, gpa: std.mem.Allocator) void {
switch (m) {
.output => |o| gpa.free(o.bytes),
.eof => {},
.command => |c| gpa.free(c),
+ .lsp_done => |d| gpa.free(d.rows),
+ .lsp_status => |t| gpa.free(t),
+ .pipe => |r| {
+ var response = r;
+ response.deinit(gpa);
+ },
}
}
};
+
const inbox_capacity = 512;
const MessageBatch = struct {
@@ -443,8 +476,8 @@ const Inbox = struct {
}
if (q.len == q.items.len) {
const lossy = switch (m) {
- .output => true,
- .eof, .command => false,
+ .output, .lsp_status => true,
+ .eof, .command, .lsp_done, .pipe => false,
};
if (lossy) {
m.free(gpa);
@@ -453,8 +486,8 @@ const Inbox = struct {
var offset: usize = 0;
while (offset < q.len) : (offset += 1)
if (switch (q.items[(q.head + offset) % q.items.len]) {
- .output => true,
- .eof, .command => false,
+ .output, .lsp_status => true,
+ .eof, .command, .lsp_done, .pipe => false,
}) break;
if (offset == q.len) return;
q.removeAt(offset).free(gpa);
@@ -523,6 +556,13 @@ const State = struct {
panel_tracks_len: usize = 0,
ptys: [pardes.MAX_PANES]?Pty = @splat(null),
inbox: Inbox = .{},
+ /// The single in-flight language query. ONE slot, like the tty shell's:
+ /// replacing it cancels the previous worker, which is right because the
+ /// only query anyone is waiting for is the one they just asked for.
+ lsp_task: ?std.Io.Future(anyerror!void) = null,
+ /// Filters running off the main thread. Bounded by the shared table; a full
+ /// one answers the request as failed rather than queueing it.
+ pipe_tasks: selection_pipe.Tasks = .{},
file_watches: FileWatches = .{},
/// Per-slot spawn generation, owned by the main thread. A reader carries a
/// copy in every message it posts; anything that no longer matches belongs
@@ -548,9 +588,25 @@ const State = struct {
/// velocity because during the gesture that velocity is a MEASUREMENT —
/// spending it then would double every twist under the hand making it.
rotate_coasting: bool = false,
- /// Display-clock time for the persistent Core Image scene pass. Input and
- /// pty pumps never spend it; pardes_animation_tick is the only writer.
- scene_frame: u32 = 0,
+ /// Real elapsed time for the persistent Core Image scene pass, in
+ /// nanoseconds. Input and pty pumps never spend it; pardes_animation_tick
+ /// is the only writer.
+ ///
+ /// TIME, not a callback count. It used to be a frame counter divided by an
+ /// assumed 60 Hz, and the callbacks do not arrive at 60 Hz — the pump
+ /// re-arms `asyncAfter(0.016)` only after the previous frame's work, so the
+ /// real period is 16 ms PLUS a tick, a drain and a draw. Shader time
+ /// therefore advanced at roughly three quarters of wall clock, unevenly,
+ /// which is what a scene effect looks like when it stutters.
+ scene_ns: u64 = 0,
+ /// Monotonic stamp of the previous tick, and the leftover time that was not
+ /// yet worth a whole fixed animation step. The core's transitions count
+ /// FRAMES, so real elapsed time is banked here and spent in whole
+ /// `animation.frame_ns` steps: a late callback advances two frames instead
+ /// of stretching one, which is what keeps a transition's duration the same
+ /// on a busy machine as on an idle one.
+ last_tick_ns: u64 = 0,
+ tick_bank_ns: u64 = 0,
/// Panes whose shell has produced output since we last read its cwd.
///
/// The cwd is wanted for pane tags and for resolving a relative Look, and
@@ -641,13 +697,11 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
// capability, because there it is a question rather than a fact.
core.native_images = true;
- // Complete private files before any fork; State retains their path buffers
- // for every later shell spawn and removes the files at app teardown.
- var prompt_rcs = shell_bin.PromptRcs.init();
+ // PATH, the bash banner and the prompt rc files, in the one order that
+ // works. State retains the path buffers for every later spawn and removes
+ // the files at app teardown.
+ var prompt_rcs = shell_bin.prepareForFork();
errdefer prompt_rcs.deinit();
- // Apple's bash 3.2 prints the zsh-deprecation banner into every pane unless
- // this is in the environment BEFORE bash starts — the rc file is too late.
- if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1);
state = .{
.gpa = gpa,
@@ -683,6 +737,13 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
st.started = true;
for (&st.ptys, 0..) |*slot, id| if (slot.*) |*pt| startReader(st, pt, @intCast(id));
+ // Server-state narration onto the transient message row. Registered HERE
+ // and not at the `state = .{...}` assignment because the sink is called
+ // from the protocol client's reader threads and must not fire before the
+ // inbox is reachable. Without this the sink existed and nothing ever called
+ // it, so "rust-analyzer: indexing 45%" never appeared in this shell.
+ pardes.lsp.setStatusSink(st, lspStatusSink);
+
// Last, because it is the one thing here that publishes this process to
// the outside: nothing may connect before the core can answer. The shells
// above are already forked, which is why the listener's fd is CLOEXEC —
@@ -728,6 +789,17 @@ export fn pardes_deinit() void {
// the process, which is what its detach() already said.
nested.unlisten(st.sock_fd);
st.sock_fd = -1;
+ // The protocol client's reader threads call the sink, and the State it is
+ // handed is about to become null: unregister before the inbox goes away,
+ // and cancel the one query that may still be running against it.
+ pardes.lsp.setStatusSink(null, null);
+ if (st.lsp_task) |*t| {
+ t.cancel(st.io) catch {};
+ st.lsp_task = null;
+ }
+ // ...and every filter still running against it. A future nobody cancels is
+ // a thread writing into a State that is about to be null.
+ st.pipe_tasks.cancelAll(st.io);
// Cancel host directory sources while their generation table still exists.
// A debounce block already queued on the main runloop may call back later;
// state=null below and the bumped generation each make that callback inert.
@@ -781,8 +853,75 @@ fn currentSceneFlags(st: *const State) u32 {
return encodeSceneEffects(st.core.settings.scene_effects);
}
-fn advanceSceneFrame(frame: *u32) void {
- frame.* = (frame.* + 1) % scene_frame_wrap;
+/// Advance the scene clock by real elapsed time, wrapping so an f32
+/// `time_seconds` keeps sub-millisecond resolution forever.
+fn advanceSceneClock(st: *State, elapsed_ns: u64) void {
+ st.scene_ns = (st.scene_ns +| elapsed_ns) % scene_wrap_ns;
+}
+
+/// How much real time this tick may spend, and how many whole fixed steps that
+/// buys. Pure arithmetic, split out of `pardes_animation_tick` so the clock the
+/// whole feel of the app rides on can be asserted without a display attached.
+///
+/// `previous` of zero means "no sample yet" — the first tick of a run, or a
+/// monotonic clock that refused to answer — and spends exactly one step rather
+/// than the entire uptime.
+const TickSpend = struct { elapsed_ns: u64, steps: u32, bank_ns: u64 };
+
+fn spendTickTime(previous_ns: u64, now_ns: u64, bank_ns: u64) TickSpend {
+ const measured = if (previous_ns == 0 or now_ns <= previous_ns)
+ pardes.animation.frame_ns
+ else
+ now_ns - previous_ns;
+ const elapsed = @min(measured, max_tick_catch_up_ns);
+ var bank = bank_ns +| elapsed;
+ var steps: u32 = 0;
+ while (bank >= pardes.animation.frame_ns) : (steps += 1) bank -= pardes.animation.frame_ns;
+ return .{ .elapsed_ns = elapsed, .steps = steps, .bank_ns = bank };
+}
+
+test "the animation clock spends real time, not callbacks" {
+ const frame = pardes.animation.frame_ns;
+ const expectEqual = std.testing.expectEqual;
+
+ // First tick of a run has nothing to measure from and spends exactly one
+ // step — never the whole uptime.
+ const first = spendTickTime(0, 999 * std.time.ns_per_s, 0);
+ try expectEqual(@as(u32, 1), first.steps);
+ try expectEqual(frame, first.elapsed_ns);
+
+ // A callback that lands ON time buys one step and banks nothing.
+ const on_time = spendTickTime(1_000, 1_000 + frame, 0);
+ try expectEqual(@as(u32, 1), on_time.steps);
+ try expectEqual(@as(u64, 0), on_time.bank_ns);
+
+ // THE BUG THIS FIXES. A callback that lands late used to still count as one
+ // frame, so an animation stretched and ran slow. Two frames' worth of real
+ // time now buys two steps.
+ const late = spendTickTime(1_000, 1_000 + 2 * frame, 0);
+ try expectEqual(@as(u32, 2), late.steps);
+
+ // ...and time too short for a step is BANKED, not discarded: three 6 ms
+ // callbacks are worth one 16 ms frame, not zero and not three.
+ var bank: u64 = 0;
+ var steps: u32 = 0;
+ for (0..3) |_| {
+ const partial = spendTickTime(1_000, 1_000 + 6 * std.time.ns_per_ms, bank);
+ bank = partial.bank_ns;
+ steps += partial.steps;
+ }
+ try expectEqual(@as(u32, 1), steps);
+ try expectEqual(@as(u64, 2 * std.time.ns_per_ms), bank);
+
+ // A stall — occluded window, sleep, breakpoint — is CLAMPED. Resuming an
+ // animation must not fast-forward it by however long nobody was looking.
+ const stall = spendTickTime(1_000, 1_000 + 10 * std.time.ns_per_s, 0);
+ try expectEqual(max_tick_catch_up_ns, stall.elapsed_ns);
+ try expectEqual(@as(u32, @intCast(max_tick_catch_up_ns / frame)), stall.steps);
+
+ // A monotonic clock that refuses to answer, or that goes backwards, spends
+ // one step rather than a garbage dt.
+ try expectEqual(@as(u32, 1), spendTickTime(5_000, 4_000, 0).steps);
}
/// Something on screen moves on its own and wants ~60 Hz ticks: a finite core
@@ -846,6 +985,30 @@ export fn pardes_topbar_pane_border_px(cell_h: u32, tagline_h: u32) u32 {
return pardes.topbarPaneBorderPixels(cell_h, tagline_h);
}
+/// ...and the HORIZONTAL half of the same story: the column a compact tagline
+/// band anchors at, so a tag row advances on the tagline face's own pitch
+/// instead of dropping a smaller glyph into the middle of every body cell.
+/// Without it this shell tracked its tags visibly looser than the SDL window
+/// beside it at the same percentage.
+///
+/// CELLS, not pixels: the caller already knows both cell widths, and an
+/// animating panel's origin is fractional.
+export fn pardes_tagline_origin_col(col: u16, row: u16) f32 {
+ const st = &(state orelse return @floatFromInt(col));
+ return pardes.taglineOriginColForFrame(st.core, col, row);
+}
+
+/// ...and its inverse, for the pointer. A tag row whose glyphs were compacted
+/// but whose clicks were not is a click that drifts one word further right for
+/// every word along the row, so the layout and the hit test are one feature.
+///
+/// `x` and both widths in the SAME unit — this shell measures in POINTS and
+/// passes points; only their ratio is read.
+export fn pardes_grid_col_at(x: f32, row: u16, body_w: f32, tagline_w: f32) u16 {
+ const st = &(state orelse return pardes.gridColAt(null, x, row, body_w, tagline_w));
+ return pardes.gridColAt(st.core, x, row, body_w, tagline_w);
+}
+
/// Colour of that rule: the compiled override when a build pins one, otherwise
/// the active theme's scrollbar track — the same resolution the SDL shell does
/// at `src/gui/gui.zig:3813`. PARDES_COLOR_DEFAULT before there is a session to
@@ -858,6 +1021,47 @@ export fn pardes_topbar_pane_border_rgb() u32 {
return @as(u32, rgb[0]) << 16 | @as(u32, rgb[1]) << 8 | rgb[2];
}
+/// The tag band's own background — `chromeTheme().tag_bg`, the same value the
+/// SDL shell builds its `tagline_base` cell from.
+///
+/// A host needs it because a compact tag row is painted in two passes: the
+/// pane-wide band in THIS colour on the body grid, then each cell's own
+/// background on the narrower grid the glyphs use. Without the split, a
+/// highlighted word's box lands on body pitch while its letters sit on tagline
+/// pitch, and the box drifts further from the word the further along the row
+/// it is. PARDES_COLOR_DEFAULT before there is a session to ask.
+export fn pardes_tagline_bg() u32 {
+ const st = state orelse return color_default;
+ const rgb = st.core.chromeTheme().tag_bg;
+ return @as(u32, rgb[0]) << 16 | @as(u32, rgb[1]) << 8 | rgb[2];
+}
+
+/// The shared fallback PREFERENCE ORDER — `fonts.fallback_names`, the same list
+/// the SDL shell walks. Only the order is shared; resolving a name is each
+/// host's own business, and has to be: SDL matches file stems while walking the
+/// font directories itself, and CoreText matches PostScript and family names,
+/// which for the same face are routinely different strings. "Mononoki Nerd
+/// Font Mono" ships as `MononokiNerdFontMono-Regular.ttf` and answers to
+/// `MononokiNFM-Regular`, and a by-stem lookup on this platform silently
+/// resolves to Helvetica rather than failing.
+///
+/// Returned as pointer + length rather than NUL-terminated because these are
+/// Zig string literals and a sentinel copy of each would exist only to be
+/// dropped again by the caller.
+export fn pardes_fallback_font_count() u32 {
+ return fonts.fallback_names.len;
+}
+
+export fn pardes_fallback_font_name(index: u32, len: *u32) ?[*]const u8 {
+ if (index >= fonts.fallback_names.len) {
+ len.* = 0;
+ return null;
+ }
+ const name = fonts.fallback_names[index];
+ len.* = @intCast(name.len);
+ return name.ptr;
+}
+
test "tagline percent falls back before init and follows live core state" {
try std.testing.expectEqual(pardes.config.gui_tagline_font_percent, taglineFontPercent(null));
@@ -868,15 +1072,42 @@ test "tagline percent falls back before init and follows live core state" {
try std.testing.expectEqual(changed, taglineFontPercent(core));
}
+test "the fallback preference order crosses the ABI intact and ends at the boundary" {
+ try std.testing.expectEqual(@as(u32, fonts.fallback_names.len), pardes_fallback_font_count());
+ try std.testing.expect(pardes_fallback_font_count() > 0);
+
+ // Every name arrives byte for byte and in the SAME ORDER, which is the
+ // whole of what is shared: the AppKit shell seeds its CoreText cascade from
+ // this list and the SDL shell walks the font directories for it, and a
+ // reordering here would silently give one window a different fallback than
+ // the other at the same codepoint.
+ for (fonts.fallback_names, 0..) |want, i| {
+ var len: u32 = 0;
+ const got = pardes_fallback_font_name(@intCast(i), &len) orelse return error.MissingFallbackName;
+ try std.testing.expectEqualStrings(want, got[0..len]);
+ }
+
+ // Past the end is null AND a zero length: a host that ignores the count and
+ // walks until null must not read a stale length and copy from a null
+ // pointer.
+ var len: u32 = 12345;
+ try std.testing.expect(pardes_fallback_font_name(pardes_fallback_font_count(), &len) == null);
+ try std.testing.expectEqual(@as(u32, 0), len);
+}
+
/// One coherent snapshot for the host's single scene postprocess. The clock is
-/// frame based, just like pane/theme transitions: it advances on the scheduled
-/// display callback and never on an input or pty drain.
+/// REAL ELAPSED TIME, advanced only on the scheduled display callback and never
+/// on an input or pty drain — so a burst of typing cannot fast-forward a scene
+/// effect, and a slow callback no longer slows one down either.
export fn pardes_scene() Scene {
const st = &(state orelse return .{});
+ const seconds = @as(f64, @floatFromInt(st.scene_ns)) / @as(f64, std.time.ns_per_s);
return .{
.flags = currentSceneFlags(st),
- .time_seconds = @as(f32, @floatFromInt(st.scene_frame)) / @as(f32, @floatFromInt(scene_frame_hz)),
- .frame = st.scene_frame,
+ .time_seconds = @floatCast(seconds),
+ // The shader's frame counter is that time expressed in nominal display
+ // frames; it is a UNIT of the clock now, not the clock itself.
+ .frame = @intFromFloat(seconds * @as(f64, @floatFromInt(scene_frame_hz))),
};
}
@@ -889,7 +1120,7 @@ export fn pardes_postprocessor_unavailable() void {
st.core.disableSceneEffects();
st.core.settings.panel_transition = .off;
st.core.abandonPanelAnimations();
- st.scene_frame = 0;
+ st.scene_ns = 0;
}
/// One transient postprocess submission failed and the host will draw the
@@ -1141,6 +1372,38 @@ fn drainInbox(st: *State) bool {
// Already filtered down to `Look ` by the accept side — this
// socket may open things and that is all it may do.
.command => |c| st.core.update(.{ .command = c }),
+ // The rows the worker produced, back into the request the core is
+ // still holding open. Joining the future here is what keeps a
+ // completed task from leaking its allocation.
+ .lsp_done => |d| {
+ st.core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } });
+ if (st.lsp_task) |*t| {
+ t.cancel(st.io) catch {};
+ st.lsp_task = null;
+ }
+ },
+ // "rust-analyzer: cargo check 88%" onto the transient message row,
+ // on the ACTIVE pane: server state is session news, not a fact
+ // about whichever pane happened to ask.
+ .lsp_status => |text| {
+ var mbuf: [256]u8 = undefined;
+ st.core.setMessage(st.core.active, message.stamp(&mbuf, "lsp", text));
+ },
+ // The filter's answer, then join the worker that produced it.
+ //
+ // NO deinit here: this loop's `defer msg.free(st.gpa)` owns the
+ // response, and `Msg.free` deinits it. The SDL shell frees inside
+ // its arm because its queue has no blanket free — copying that arm
+ // across without the surrounding contract is a double free, which
+ // is exactly what it was until the first `|` crashed the app.
+ .pipe => |value| {
+ st.core.update(.{ .pipe_resp = .{
+ .id = value.id,
+ .success = value.success,
+ .outputs = value.outputs,
+ } });
+ st.pipe_tasks.finish(st.io, value.id);
+ },
}
}
for (0..pardes.MAX_PANES) |pane| {
@@ -1187,34 +1450,59 @@ export fn pardes_tick() bool {
return did;
}
-/// Advance exactly one display-clock frame. Event pumps deliberately never
-/// call this: a burst of key, mouse, or pty notifications is work to drain,
-/// not elapsed animation time.
+/// Spend the real time elapsed since the previous tick. Event pumps deliberately
+/// never call this: a burst of key, mouse, or pty notifications is work to
+/// drain, not elapsed animation time.
export fn pardes_animation_tick() bool {
const st = &(state orelse return false);
+
+ // MEASURED elapsed time, not one assumed frame. The scheduler re-arms only
+ // after the previous frame's tick, drain and draw have finished, so on the
+ // fallback clock the callbacks land slower than 60 Hz and unevenly.
+ // Counting each as one frame made every animation run slow AND stutter;
+ // spending real time makes cadence a question of smoothness only, and no
+ // longer a question of speed.
+ const now: u64 = @intCast(@max(0, monotonicNs()));
+ const spend = spendTickTime(st.last_tick_ns, now, st.tick_bank_ns);
+ st.last_tick_ns = now;
+ st.tick_bank_ns = spend.bank_ns;
+
var changed = false;
- if (st.core.animationActive()) {
- st.core.update(.tick);
- changed = true;
- }
if (currentSceneFlags(st) != 0) {
- advanceSceneFrame(&st.scene_frame);
+ // Shader time is wall-clock seconds, so a scene effect runs at the same
+ // rate whatever the callback cadence turns out to be.
+ advanceSceneClock(st, spend.elapsed_ns);
changed = true;
}
- // ...and the dial, for the same reason and off the same clock: one frame
- // of coast per tick, decayed, until it is slower than a notch a second.
- if (st.rotate_coasting) {
- spendRotation(st, st.rotate_velocity * rotation_fling_step);
- st.rotate_velocity *= rotation_fling_decay;
- if (@abs(st.rotate_velocity) < rotation_fling_stop) {
- st.rotate_velocity = 0;
- st.rotate_coasting = false;
- // The remainder dies with the gesture: a banked half-notch
- // surviving into the next twist is the hysteresis `rotate 0`
- // exists to clear.
- st.rotate_lag = 0;
+
+ // The core's transitions and the dial's coast are FIXED-STEP: they count
+ // frames. The banked time is spent in whole steps, so a late callback
+ // advances two frames rather than stretching one over 32 ms.
+ for (0..spend.steps) |_| {
+ if (st.core.animationActive()) {
+ st.core.update(.tick);
+ changed = true;
}
- changed = true;
+ if (st.rotate_coasting) {
+ spendRotation(st, st.rotate_velocity * rotation_fling_step);
+ st.rotate_velocity *= rotation_fling_decay;
+ if (@abs(st.rotate_velocity) < rotation_fling_stop) {
+ st.rotate_velocity = 0;
+ st.rotate_coasting = false;
+ // The remainder dies with the gesture: a banked half-notch
+ // surviving into the next twist is the hysteresis `rotate 0`
+ // exists to clear.
+ st.rotate_lag = 0;
+ }
+ changed = true;
+ }
+ }
+ // Nothing is animating any more: drop the banked remainder so the next run
+ // starts on a whole step instead of jumping however far this one stopped
+ // short, and forget the stamp so its first dt is not the idle gap.
+ if (!changed) {
+ st.tick_bank_ns = 0;
+ st.last_tick_ns = 0;
}
return changed;
}
@@ -1430,11 +1718,16 @@ export fn pardes_resize(cols_arg: u16, rows_arg: u16, cell_w: u16, cell_h: u16)
/// if the render failed.
export fn pardes_frame() u32 {
const st = &(state orelse return 0);
+ // The macOS host had NO zones at all, so every capture attributed its whole
+ // frame to the core. This is the boundary the AppKit `draw(_:)` calls into.
+ const tz = tracy.zone(@src(), "pardes_frame");
+ defer tz.end();
st.core.pump(hostFor(st)) catch |err| {
log.err("render failed: {t}", .{err});
clearFrame(st);
return 0;
};
+ tracy.frameMark();
return @intCast(st.frame_len);
}
@@ -1454,6 +1747,8 @@ fn clearFrame(st: *State) void {
/// panel diff, the attachments and the tracks are all encoded here.
fn presentFrame(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
const st = hostState(ctx);
+ const tz = tracy.zone(@src(), "presentFrame");
+ defer tz.end();
clearFrame(st);
const count: usize = @as(usize, surface.cols) * surface.rows;
if (count != st.cells.len) {
@@ -1471,29 +1766,33 @@ fn presentFrame(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
st.frame_len = count;
st.frame_cols = surface.cols;
st.frame_rows = surface.rows;
- for (surface.cells, st.cells[0..count]) |cell, *out| out.* = encodeCell(cell);
+ {
+ // One encode per cell, every frame, whether or not the cell changed.
+ // If this is the hot zone the answer is a dirty-range copy, not a
+ // faster encodeCell.
+ const tz_cells = tracy.zone(@src(), "encodeCells");
+ defer tz_cells.end();
+ for (surface.cells, st.cells[0..count]) |cell, *out| out.* = encodeCell(cell);
+ }
collectPanelDiff(st, surface, count);
collectImages(st, surface);
collectPanelTracks(st, surface);
}
-/// Flatten tracks into the C-visible order the shader composites them. Pane
-/// slots are stable tie-breakers because Surface publishes them in slot order.
+/// Flatten tracks into the C-visible array the shader composites from.
+///
+/// A plain copy, and that is the point. This used to re-sort by phase into
+/// moving/opening/closing — which is EXACTLY the order `Pardes.render` already
+/// publishes them in ("Moving panes first, then new panes, then inert closing
+/// tombstones on top", src/pardes.zig), and it re-filtered `active()` the core
+/// had already filtered. A second ordering rule that happens to agree is not
+/// free: it is the thing that silently stops agreeing. The core's order is the
+/// contract; every host receives the same dense record set.
fn collectPanelTracks(st: *State, surface: *const pardes.Surface) void {
- st.panel_tracks_len = copyPanelTracksInPaintOrder(surface.panelTracks(), &st.panel_tracks);
-}
-
-fn copyPanelTracksInPaintOrder(source: []const PanelTrack, out: []PanelTrack) usize {
- var len: usize = 0;
- for ([_]panel_animation.Phase{ .moving, .opening, .closing }) |phase| {
- for (source) |track| {
- if (!track.active() or track.phase != phase) continue;
- std.debug.assert(len < out.len);
- out[len] = track;
- len += 1;
- }
- }
- return len;
+ const source = surface.panelTracks();
+ const len = @min(source.len, st.panel_tracks.len);
+ @memcpy(st.panel_tracks[0..len], source[0..len]);
+ st.panel_tracks_len = len;
}
/// Copy the old/new semantic transition data as one all-or-nothing snapshot.
@@ -1775,18 +2074,19 @@ fn activeFilePath(st: *State) ?[]const u8 {
/// * `post_present` — presentation is acknowledged when the destination
/// context has accepted the frame (pardes_frame_presented), which is a
/// later callback, not the moment the cells were encoded.
-/// * `lsp` — the core's own empty answer is exactly what this host replied,
-/// and for the same reason: a dropped request leaves lsp_wait armed and
-/// every later dot-Tab dead.
-/// * `pipe` — no worker to hand a job to. Teardown is not a method at all:
-/// pardes_deinit is the app's own call, made after AppKit's loop rather
-/// than from inside one.
+/// * `pipe` — no worker to hand a job to yet, so a `|` filter does nothing
+/// in this shell. Teardown is not a method at all: pardes_deinit is the
+/// app's own call, made after AppKit's loop rather than from inside one.
///
-/// ponytail: lsp and pipe still do no work. Each wants real machinery — a
-/// worker plus a snapshot of the pane's file for lsp (src/tty/tty.zig:919), and
-/// a job copy for pipe. Watch is deliberately different: FileWatcher.swift
-/// owns its per-directory DispatchSource and only returns a debounced hint;
-/// these main-thread methods own the bytes, hash and shared text/PDF core event.
+/// `lsp` USED to be on that list, and the entry claimed the core's empty answer
+/// was "exactly what this host replied". It was not a considered trade: it
+/// meant every language query in the shipped Mac app did nothing, silently, and
+/// looked from the outside like a backend with no answer rather than a host
+/// with no method. It is now `lspRequest` over the shared `lsp_host` worker.
+///
+/// Watch is deliberately different again: FileWatcher.swift owns its
+/// per-directory DispatchSource and only returns a debounced hint; these
+/// main-thread methods own the bytes, hash and shared text/PDF core event.
const vtable: pardes.Host.VTable = .{
.push_present = presentFrame,
.push_poll_frame = refreshCwds,
@@ -1803,12 +2103,83 @@ const vtable: pardes.Host.VTable = .{
.push_set_clipboard = setClipboard,
.pull_read_clipboard = readClipboard,
.push_open_link = openLink,
+ .pull_lsp = lspRequest,
+ .pull_pipe = pipeRequest,
};
fn hostFor(st: *State) pardes.Host {
return .{ .ctx = st, .vtable = &vtable };
}
+/// Answer a language query off the main thread and post the rows back. The
+/// snapshot and the worker body are `lsp_host`'s, shared with the tty and SDL
+/// shells; what is left here is the only part that is actually this host's —
+/// which allocator, and how a finished job reaches the main thread.
+fn lspRequest(ctx: ?*anyopaque, req: host_api.LspRequest) void {
+ const st = hostState(ctx);
+ const job = lsp_host.snapshot(st.gpa, st.core, req) orelse return;
+ // One in flight. Replacing it cancels the previous worker, which is right:
+ // the only answer anyone is waiting for is the one just asked for.
+ if (st.lsp_task) |*old| {
+ old.cancel(st.io) catch {};
+ st.lsp_task = null;
+ }
+ st.lsp_task = st.io.concurrent(lspWorker, .{ st, job }) catch {
+ job.free(st.gpa);
+ return;
+ };
+}
+
+/// Run a `|` filter off the main thread. The job copy, the subprocess and the
+/// response all belong to `selection_pipe`; what is here is this host's inbox
+/// and its bounded in-flight table.
+///
+/// This shell had no `pull_pipe` at all, so `pardes.zig` self-answered every
+/// filter as failed — a `|` in the Mac app silently did nothing, the same shape
+/// of gap `pull_lsp` was.
+fn pipeRequest(ctx: ?*anyopaque, id: u32) void {
+ const st = hostState(ctx);
+ if (st.pipe_tasks.full()) {
+ st.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } });
+ return;
+ }
+ const view = st.core.pipeRequest(id) orelse return;
+ const job = selection_pipe.Job.copy(st.gpa, view) catch return;
+ const future = st.io.concurrent(pipeWorker, .{ st, job }) catch {
+ job.deinit(st.gpa);
+ return;
+ };
+ std.debug.assert(st.pipe_tasks.add(.{ .id = id, .future = future }));
+}
+
+fn pipeWorker(st: *State, job: *selection_pipe.Job) anyerror!void {
+ defer job.deinit(st.gpa);
+ const response = selection_pipe.runJob(st.gpa, st.io, job);
+ st.inbox.push(st.gpa, .{ .pipe = response });
+ wake(st);
+}
+
+fn lspWorker(st: *State, job: *lsp_host.Job) anyerror!void {
+ lsp_host.work(st.gpa, job, st, deliverLspRows);
+}
+
+fn deliverLspRows(ctx: ?*anyopaque, id: u32, rows: []u8) void {
+ const st: *State = @ptrCast(@alignCast(ctx orelse return));
+ st.inbox.push(st.gpa, .{ .lsp_done = .{ .id = id, .rows = rows } });
+ wake(st);
+}
+
+/// The registered `lsp.setStatusSink` target, called from the protocol client's
+/// READER threads. Thread-safe and non-blocking only: a dupe and an inbox push,
+/// which is lossy for this message kind by design — the sink's lock is held
+/// around this call and server state is periodic news.
+fn lspStatusSink(ctx: ?*anyopaque, text: []const u8) void {
+ const st: *State = @ptrCast(@alignCast(ctx orelse return));
+ const copy = st.gpa.dupe(u8, text) catch return;
+ st.inbox.push(st.gpa, .{ .lsp_status = copy });
+ wake(st);
+}
+
fn hostState(ctx: ?*anyopaque) *State {
return @ptrCast(@alignCast(ctx.?));
}
@@ -2173,7 +2544,12 @@ test "pardes.h declares every export the way it is defined" {
try expectSameAbi(@TypeOf(c.pardes_gui_tagline_font_percent), @TypeOf(pardes_gui_tagline_font_percent));
try expectSameAbi(@TypeOf(c.pardes_tagline_band_offset), @TypeOf(pardes_tagline_band_offset));
try expectSameAbi(@TypeOf(c.pardes_topbar_pane_border_px), @TypeOf(pardes_topbar_pane_border_px));
+ try expectSameAbi(@TypeOf(c.pardes_tagline_origin_col), @TypeOf(pardes_tagline_origin_col));
+ try expectSameAbi(@TypeOf(c.pardes_grid_col_at), @TypeOf(pardes_grid_col_at));
try expectSameAbi(@TypeOf(c.pardes_topbar_pane_border_rgb), @TypeOf(pardes_topbar_pane_border_rgb));
+ try expectSameAbi(@TypeOf(c.pardes_tagline_bg), @TypeOf(pardes_tagline_bg));
+ try expectSameAbi(@TypeOf(c.pardes_fallback_font_count), @TypeOf(pardes_fallback_font_count));
+ try expectSameAbi(@TypeOf(c.pardes_fallback_font_name), @TypeOf(pardes_fallback_font_name));
try expectSameAbi(@TypeOf(c.pardes_scene), @TypeOf(pardes_scene));
try expectSameAbi(@TypeOf(c.pardes_postprocessor_unavailable), @TypeOf(pardes_postprocessor_unavailable));
try expectSameAbi(@TypeOf(c.pardes_panel_animation_failed), @TypeOf(pardes_panel_animation_failed));
@@ -2336,32 +2712,39 @@ test "scene effect flags and display clock are compact and independent" {
encodeSceneEffects(.{ .crt = true, .ripple = true, .glitch = true }),
);
- var frame: u32 = scene_frame_wrap - 1;
- advanceSceneFrame(&frame);
- try expectEqual(@as(u32, 0), frame);
- advanceSceneFrame(&frame);
- try expectEqual(@as(u32, 1), frame);
+ // The clock is TIME now, so the wrap is a duration and the assertion is
+ // that it wraps without losing the remainder — an f32 `time_seconds` that
+ // grew without bound would lose sub-millisecond resolution within a day.
+ var st: State = undefined;
+ st.scene_ns = scene_wrap_ns - (std.time.ns_per_ms * 5);
+ advanceSceneClock(&st, std.time.ns_per_ms * 5);
+ try expectEqual(@as(u64, 0), st.scene_ns);
+ advanceSceneClock(&st, std.time.ns_per_ms * 7);
+ try expectEqual(@as(u64, std.time.ns_per_ms * 7), st.scene_ns);
}
-test "mac panel ABI paint order includes closing tombstones after live panes" {
+test "the mac panel ABI hands the shader the core's order verbatim" {
+ // The host used to re-sort by phase here. It does not any more: the order
+ // is `panel_animation.paintOrder`, applied once in `Pardes.render`, and
+ // asserted where it lives (src/pardes.zig). What this host still owes is
+ // that it copies FAITHFULLY and cannot overrun its fixed ABI array.
const source = [_]PanelTrack{
- .{ .serial = 11, .pane = 3, .phase = .opening, .effect = .slide },
.{ .serial = 12, .pane = 1, .phase = .moving, .effect = .zoom },
- .{ .serial = 13, .pane = 0, .phase = .moving, .effect = .off },
- .{ .serial = 14, .pane = 5, .phase = .opening, .effect = .ascii },
.{ .serial = 15, .pane = 2, .phase = .moving, .effect = .dissolve },
+ .{ .serial = 11, .pane = 3, .phase = .opening, .effect = .slide },
.{ .serial = 16, .pane = 2, .phase = .closing, .effect = .vertical },
};
- var ordered: [source.len]PanelTrack = undefined;
- const len = copyPanelTracksInPaintOrder(&source, &ordered);
- try std.testing.expectEqual(@as(usize, 5), len);
- try std.testing.expectEqualSlices(u32, &.{ 12, 15, 11, 14, 16 }, &.{
- ordered[0].serial,
- ordered[1].serial,
- ordered[2].serial,
- ordered[3].serial,
- ordered[4].serial,
- });
+ var st: State = undefined;
+ st.panel_tracks = undefined;
+ st.panel_tracks_len = 0;
+ var surface: pardes.Surface = std.mem.zeroes(pardes.Surface);
+ @memcpy(surface.panel_tracks[0..source.len], &source);
+ surface.npanel_tracks = source.len;
+
+ collectPanelTracks(&st, &surface);
+ try std.testing.expectEqual(source.len, st.panel_tracks_len);
+ for (source, st.panel_tracks[0..st.panel_tracks_len]) |want, got|
+ try std.testing.expectEqual(want.serial, got.serial);
}
test "mac panel mask is a literal normalized grayscale texture" {
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<String>()
+
+ 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..<pardes_fallback_font_count() {
+ var length: UInt32 = 0
+ guard let bytes = pardes_fallback_font_name(index, &length), length > 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)..<UniChar(128))
var glyphs = [CGGlyph](repeating: 0, count: 128)
@@ -380,6 +510,20 @@ private struct TaglineMetrics {
UInt16(clamping: row), Float(canvasHeight * scale), cellPixels, bandPixels)) / scale
}
+ /// Where a tagline cell's GLYPH sits, on the compact grid.
+ ///
+ /// Only the text compacts. The colour band behind it was already painted
+ /// pane-wide on the body grid, which is what keeps a tag bar the width of
+ /// its pane; this steps the characters on top of it at the tagline face's
+ /// own pitch, anchored so the compact grid's column zero is the pane's
+ /// physical left edge. The anchor is the core's — the SDL shell reaches the
+ /// identical rule through `pardes.taglineOriginCol`, and pane rects are not
+ /// otherwise on this shell's ABI at all.
+ func glyphX(col: Int, row: Int, bodyCellWidth: CGFloat) -> 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..<rows {
let base = row * cols
let y = CGFloat(row) * cellHeight
@@ -855,9 +1007,29 @@ final class PardesView: NSView {
if color != bgClear {
let bandY = tagline ? y + bandTop : y
let bandHeight = tagline ? taglines.height : cellHeight
- fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY,
- width: CGFloat(col - start) * cellWidth, height: bandHeight),
- color, 1)
+ if tagline && taglineTwoPass {
+ // The band keeps the BODY grid so it spans its pane
+ // exactly. Anything that is NOT the band — a hovered
+ // word, a selection, the block cursor — belongs to the
+ // cell and has to land under the glyph, which moved to
+ // the narrower grid. Runs are painted left to right and
+ // a compact overlay always sits left of its own run's
+ // right edge, so no later band can cover one.
+ fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY,
+ width: CGFloat(col - start) * cellWidth, height: bandHeight),
+ taglineBaseBG, 1)
+ if color != taglineBaseBG {
+ fill(ctx, CGRect(
+ x: taglines.glyphX(col: start, row: row, bodyCellWidth: cellWidth),
+ y: bandY,
+ width: CGFloat(col - start) * taglines.width,
+ height: bandHeight), color, 1)
+ }
+ } else {
+ fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY,
+ width: CGFloat(col - start) * cellWidth, height: bandHeight),
+ color, 1)
+ }
}
start = col
color = next
@@ -933,7 +1105,7 @@ final class PardesView: NSView {
ctx.scaleBy(x: 1, y: -1)
let height = bounds.height
for row in 0..<rows {
- drawRow(ctx, cells, base: row * cols, cols: cols,
+ drawRow(ctx, cells, base: row * cols, cols: cols, row: row,
baseline: height - (CGFloat(row) * cellHeight + metrics.ascent),
taglineTop: taglines.top(row: row, canvasHeight: canvasHeight),
blockCol: blockY == row ? blockX : -1)
@@ -963,9 +1135,17 @@ final class PardesView: NSView {
let caretY = CGFloat(y) * cellHeight
+ (tagline ? taglines.top(row: y, canvasHeight: bounds.height) : 0)
let caretHeight = tagline ? taglines.height : cellHeight
+ // On the compact grid when the cell is, or the caret sits a
+ // growing distance to the left of the character it marks as the
+ // row runs on. The SDL shell puts its cursor bar through the
+ // same layout for the same reason.
+ let caretX = tagline
+ ? taglines.glyphX(col: x, row: y, bodyCellWidth: cellWidth)
+ : CGFloat(x) * cellWidth
+ let caretWidth = max(1, ((tagline ? taglines.width : cellWidth) / 8).rounded(.up))
ctx.setShouldAntialias(false)
- fill(ctx, CGRect(x: CGFloat(x) * cellWidth, y: caretY,
- width: max(1, (cellWidth / 8).rounded(.up)), height: caretHeight), fg, 1)
+ fill(ctx, CGRect(x: caretX, y: caretY,
+ width: caretWidth, height: caretHeight), fg, 1)
}
}
}
@@ -1128,6 +1308,10 @@ final class PardesView: NSView {
_ cells: UnsafePointer<pardes_cell_s>,
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
--- /dev/null
+++ b/src/macos/icon.png
Binary files 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)
- }
-}
+/// 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
-// 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.
+/// 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)")
+ }
+ guard let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
+ die("\(url.lastPathComponent) holds no decodable image")
}
- return color
+ 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)
+ // 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
- // 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)
+ // 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) <output-directory>")
+guard arguments.count == 3 else {
+ die("usage: \(URL(fileURLWithPath: arguments.first ?? "icon").lastPathComponent) <source.png> <output-directory>")
}
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,
+/// 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 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;
- };
- }
-
- 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