summaryrefslogtreecommitdiff
path: root/src/gui
diff options
context:
space:
mode:
Diffstat (limited to 'src/gui')
-rw-r--r--src/gui/gui.zig903
1 files changed, 544 insertions, 359 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 63fe2091..e080dd9a 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -13,6 +13,7 @@ const linux = std.os.linux; // inotify constants; referenced only on linux
const vaxis = @import("vaxis"); // test modes only: the stdin escape-seq parser
const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells
const pardes = @import("../pardes.zig");
+const host_api = @import("../host.zig"); // LspRequest, the one host type not re-exported
const config = @import("../config.zig");
const look = @import("../look.zig");
const message = @import("../message.zig");
@@ -23,7 +24,6 @@ const crt = @import("crt.zig");
const fonts = @import("../fonts.zig"); // the Font builtin's half of the seam
const selection_pipe = @import("../selection_pipe.zig");
-const temp_file = @import("../temp_file.zig");
const shell_bin = @import("../shell_bin.zig");
const nested = @import("../nested.zig");
@@ -160,9 +160,10 @@ const touch_scroll_tick: f32 = 0.02;
/// SDL input can wake the loop faster than display cadence, so ticking once
/// per pass would make animation duration depend on pty traffic or mouse
-/// motion. This monotonic gate keeps it near 60 Hz without sleeping the event
-/// loop. It is consulted only AFTER a successful presentation: the current
-/// sample reaches the screen before the display interval may advance it.
+/// motion — and `pump` deliberately spends no animation time of its own. This
+/// monotonic gate keeps it near 60 Hz without sleeping the event loop. It is
+/// consulted only AFTER a successful presentation: the current sample reaches
+/// the screen before the display interval may advance it.
const AnimationClock = struct {
next_ns: u64 = 0,
@@ -1138,7 +1139,7 @@ fn lspThread(lsp_allocator: std.mem.Allocator, workers: *LspWorkers, job: *LspJo
/// Copy the query out of the core and hand it to a thread. A detached thread
/// per query is fine at this rate: one keystroke, one query, and the queue
/// already tolerates a late push after close.
-fn spawnLsp(core: *pardes.Pardes, q: *Queue, e: anytype) void {
+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
@@ -1158,7 +1159,7 @@ fn spawnLsp(core: *pardes.Pardes, q: *Queue, e: anytype) void {
lsp_allocator.destroy(job);
return;
},
- .arg = lsp_allocator.dupe(u8, e.arg.slice()) catch {
+ .arg = lsp_allocator.dupe(u8, e.arg) catch {
lsp_allocator.free(job.path);
lsp_allocator.free(job.source);
lsp_allocator.destroy(job);
@@ -1195,19 +1196,19 @@ fn spawnPipe(
gpa: std.mem.Allocator,
q: *Queue,
tasks: *PipeTasks,
- request: anytype,
+ id: u32,
) void {
if (tasks.len == tasks.items.len) {
- core.update(.{ .pipe_resp = .{ .id = request.id, .success = false, .outputs = &.{} } });
+ core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } });
return;
}
- const view = core.pipeRequest(request.id) orelse return;
+ const view = core.pipeRequest(id) orelse return;
const job = selection_pipe.Job.copy(gpa, view) catch return;
const future = io.concurrent(pipeThread, .{ io, gpa, job, q }) catch {
job.deinit(gpa);
return;
};
- std.debug.assert(tasks.add(.{ .id = request.id, .future = future }));
+ std.debug.assert(tasks.add(.{ .id = id, .future = future }));
}
// ---- the renderer state ----
@@ -1836,6 +1837,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
opts.image_allocator = allocs.image;
opts.pdf_allocator = allocs.pdf;
opts.tree_sitter_allocator = allocs.tree_sitter;
+ opts.frame_allocator = allocs.frame;
var pw: c_int = 0;
var ph: c_int = 0;
if (opts.load_path != null) {
@@ -1866,13 +1868,11 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
var ptys: [pardes.MAX_PANES]?Pty = @splat(null);
// per-slot spawn generation: drops a dead shell's late output/eof when its
- // pane id has been respawned (see drainEffects .spawn)
+ // pane id has been respawned (see the host's spawnPane)
var gens: [pardes.MAX_PANES]u32 = @splat(0);
defer for (&ptys) |*slot| if (slot.*) |pt| {
_ = libc.close(pt.fd);
};
- // the core's one way to ask about those ptys, pulled at the Exec that cares
- core.tty_query = .{ .ctx = &ptys, .taken = &ttyTakenAt };
var lsp_workers: LspWorkers = .{};
var queue: Queue = .{
.gpa = gpa,
@@ -1903,9 +1903,30 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
const sock_fd: c_int = if (opts.nested) -1 else nested.listen();
defer nested.unlisten(sock_fd);
+ var shell: Shell = .{
+ .core = core,
+ .gui = &g,
+ .io = io,
+ .gpa = gpa,
+ .lsp_allocator = allocs.lsp,
+ .prompt_rcs = &prompt_rcs,
+ .ptys = &ptys,
+ .gens = &gens,
+ .queue = &queue,
+ .pipe_tasks = &pipe_tasks,
+ .inotify_fd = inotify_fd,
+ .watches = &watches,
+ .test_mode = test_mode,
+ };
+ const host = shell.host();
+ // `pump` installs this every pass; the pre-loop drain below happens
+ // outside one, so the initial spawns would otherwise reach the core's own
+ // virtual ptys instead of forking.
+ core.host = host;
+
// initial spawns BEFORE any worker thread exists: forkpty from a
// multithreaded process can wedge the child before exec (see tty.zig).
- drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, &queue, &pipe_tasks, &g, inotify_fd, &watches, false);
+ while (core.nextEffect()) |e| core.perform(e);
for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue);
// ...and the one file watcher. Started even with nothing marked yet: the fd
// already exists and an unwatched inotify instance just parks in read(2) —
@@ -1917,71 +1938,15 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
_ = c.SDL_StartTextInput(window);
- var feed: StdinFeed = .{};
if (test_mode) setStdinRaw() catch {};
+ shell.threads_ok = true;
- var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame);
- defer frame_arena.deinit();
- var animation_clock: AnimationClock = .{};
-
+ // The core owns the loop. This owns the one thing a pump cannot do from
+ // inside itself: Restore swaps the whole Pardes, which is only safe
+ // BETWEEN iterations.
while (!core.quit) {
- // 1. SDL input: block briefly for the first event, then drain the rest
- var sev = std.mem.zeroes(c.SDL_Event);
- if (c.SDL_WaitEventTimeout(&sev, 16)) {
- dispatch(&g, core, &sev);
- while (c.SDL_PollEvent(&sev)) dispatch(&g, core, &sev);
- }
- // test mode: input comes from stdin escape sequences instead
- if (test_mode) {
- const r = feed.pump(gpa, core, &g) catch break;
- if (r.eof) break;
- }
- // 2. pty output from the reader threads
- var msgs = queue.take();
- var check_files = false;
- for (msgs.slice()) |m| switch (m) {
- .output => |o| {
- if (gens[o.pane] == o.gen)
- core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } });
- gpa.free(o.bytes);
- },
- .eof => |e| {
- _ = libc.close(e.fd); // the dead reader's master — stale or current
- if (gens[e.pane] == e.gen) {
- ptys[e.pane] = null;
- core.update(.{ .eof = .{ .pane = e.pane } });
- }
- },
- .lsp => |l| {
- core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } });
- allocs.lsp.free(l.rows);
- },
- .pipe => |response_value| {
- var response = response_value;
- core.update(.{ .pipe_resp = .{
- .id = response.id,
- .success = response.success,
- .outputs = response.outputs,
- } });
- response.deinit(gpa);
- pipe_tasks.finish(io, response_value.id);
- },
- .command => |line| {
- core.update(.{ .command = line });
- gpa.free(line);
- },
- // Coalesced on purpose: a burst of writes (a formatter, a build, a
- // `git checkout`) collapses into ONE pass below, so it cannot queue
- // a reload — or an undo entry — per write.
- .files_changed => check_files = true,
- };
- if (check_files and file_watch.reloadChanged(core, io, gpa, &watches))
- queue.push(.files_changed);
- // 3. steamdeck: poll gamepad axes into virtual cursor / wheel events
- pollGamepad(&g, core);
- // 4. effects
- drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, &queue, &pipe_tasks, &g, inotify_fd, &watches, true);
- if (core.quit) break;
+ try core.pump(host);
+ if (core.quit) break; // a session that ended does not restore into one
// Restore builtin: swap in a core rebuilt from the dump; kill the live
// shells (their detached readers wake on child death; gens bumped so
// the stale eofs close the old fds without touching the replay panes)
@@ -2013,91 +1978,14 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
g.presented_images.clearRetainingCapacity();
g.prepared_images.clearRetainingCapacity();
nc.native_images = true;
+ nc.host = host;
core.deinit();
core = nc;
+ shell.core = nc;
+ shell.surface = null;
observeGuiFont(&g, core);
syncTaglineFont(&g, core);
}
- // TaglineSize is pure renderer state: update the smaller face and its
- // visual band immediately, without changing the body metrics or grid.
- syncTaglineFont(&g, core);
- // Font builtin: the core resolved a name to a path and asked for it —
- // it cannot load a font itself, having no rasterizer, no atlas and no
- // window. Inline here beside Restore because it is the same kind of
- // thing and this is the flat loop.
- if (core.takeFontRequest()) |path| blk: {
- const bytes = look.readFile(gpa, path) catch {
- core.rejectFont();
- break :blk;
- };
- const nf = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse {
- // FreeType turned it down. Keep wearing the one that works: a
- // font pardes cannot rasterize is a blank window with no way
- // back out of it.
- log.err("ui_font_new failed: {s}", .{path});
- gpa.free(bytes);
- core.rejectFont();
- break :blk;
- };
- c.ui_font_free(g.font);
- if (g.font_bytes.len != 0) gpa.free(g.font_bytes);
- g.font = nf;
- g.font_bytes = bytes; // FreeType borrows them for the face lifetime
- setGuiFontName(&g, core.settings.font.requested_name.get());
- refitFont(&g, core);
- acknowledgeGuiFont(&g, core);
- }
- // 5. live cwd for tags/look: cheap /proc readlink per pane, per frame
- pollCwds(core, &ptys);
- // 6. the grid follows the window (covers WINDOW_RESIZED and test
- // resizes). Off g.cell_w/h, not the init locals: a font change moves
- // them, and this is the line that would go on dividing by the old cell.
- _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph);
- const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w)));
- const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h)));
- if (updateCoreResize(core, cols, rows, g.cell_w, g.cell_h)) resetScroll(&g);
- // 7. render — apply the wheel batch LAST before it, while
- // core.surface still holds the frame the last pass drew
- stepScroll(&g, core, gpa);
- _ = frame_arena.reset(.retain_capacity);
- const surface = try core.render(frame_arena.allocator());
- const scene_requested = core.settings.scene_effects.crt or
- core.settings.scene_effects.ripple or core.settings.scene_effects.glitch;
- if (!scene_requested) g.scene_failures = 0;
- const presented = renderFrame(
- &g,
- gpa,
- core,
- surface,
- core.theme().bg,
- config.gui_topbar_pane_border_rgb orelse core.chromeTheme().scroll_track,
- core.chromeTheme().tag_bg,
- core.settings.scene_effects,
- core.settings.debug,
- ) catch |err| blk: {
- log.err("render: {t}", .{err});
- break :blk false;
- };
- if (g.scene_target_failed) {
- g.scene_failures +|= 1;
- log.err("scene target unavailable (attempt {d}/3)", .{g.scene_failures});
- if (g.scene_failures >= 3) {
- core.disableSceneEffects();
- g.scene_failures = 0;
- }
- } else if (scene_requested and presented) g.scene_failures = 0;
- if (presented) {
- // Ripple/glitch move source cells under a stationary physical
- // pointer. Re-feed only when that accepted scene maps to a new
- // cell, using the same core mouse path a real motion event uses.
- refreshPresentedPointer(&g, core);
- finishPresentedAnimationFrame(
- &animation_clock,
- core,
- surface.panelTracks(),
- c.SDL_GetTicksNS(),
- );
- }
}
}
@@ -2125,6 +2013,7 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
opts.image_allocator = allocs.image;
opts.pdf_allocator = allocs.pdf;
opts.tree_sitter_allocator = allocs.tree_sitter;
+ opts.frame_allocator = allocs.frame;
if (opts.load_path != null) {
opts.cols = grid_cols;
opts.rows = grid_rows;
@@ -2148,12 +2037,11 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
var ptys: [pardes.MAX_PANES]?Pty = @splat(null);
// per-slot spawn generation: drops a dead shell's late output/eof when its
- // pane id has been respawned (see drainEffects .spawn)
+ // pane id has been respawned (see the host's spawnPane)
var gens: [pardes.MAX_PANES]u32 = @splat(0);
defer for (&ptys) |*slot| if (slot.*) |pt| {
_ = libc.close(pt.fd);
};
- core.tty_query = .{ .ctx = &ptys, .taken = &ttyTakenAt };
var lsp_workers: LspWorkers = .{};
var queue: Queue = .{
.gpa = gpa,
@@ -2171,77 +2059,66 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
// scripted input event, and a reload that arrives on its own clock would
// put a frame in the stream nothing asked for. -1 makes watchPane a no-op.
var watches: file_watch.Table = @splat(null);
- drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, false);
+ var shell: Shell = .{
+ .core = core,
+ .io = io,
+ .gpa = gpa,
+ .lsp_allocator = allocs.lsp,
+ .prompt_rcs = &prompt_rcs,
+ .ptys = &ptys,
+ .gens = &gens,
+ .queue = &queue,
+ .pipe_tasks = &pipe_tasks,
+ .inotify_fd = -1,
+ .watches = &watches,
+ };
+ const host = shell.host();
+ // The core owns the loop here too, but not the scripted stdin: EOF ends
+ // the session and nothing may be drawn after it, so this reads its own
+ // input and hands `pump` a pass that has already been fed.
+ core.host = host;
+ while (core.nextEffect()) |e| core.perform(e);
for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue);
+ shell.threads_ok = true;
setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort
- var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame);
- defer frame_arena.deinit();
- var feed: StdinFeed = .{};
-
- // first frame before touching stdin, so `printf '' | pardes` still shows one
+ // First frame before touching stdin, so `printf '' | pardes` still shows
+ // one. Its arena is released before the core's own ever allocates: both
+ // draw from the one stack-fallback buffer, and a live arena on top of it
+ // would push every later frame out to the heap.
{
- const surface = try core.render(frame_arena.allocator());
+ var first: std.heap.ArenaAllocator = .init(allocs.frame);
+ defer first.deinit();
+ const surface = try core.render(first.allocator());
try dumpGrid(gpa, surface);
// The grid protocol writes canonical cells; panel tracks are metadata
// for a compositor it deliberately does not run.
core.acknowledgePanelPresentation(&.{});
}
while (!core.quit) {
- const r = try feed.pump(gpa, core, null);
+ // The two halves of a pass's input, in the order the flat loop had
+ // them: the scripted feed, then whatever the reader threads handed
+ // over. `pump` has no `wait_input` to do it in — see `grid_vtable`.
+ const r = try shell.feed.pump(gpa, core, null);
if (r.eof) break;
- var n_events: usize = r.n_events;
- var msgs = queue.take();
- for (msgs.slice()) |m| switch (m) {
- .output => |o| {
- if (gens[o.pane] == o.gen)
- core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } });
- gpa.free(o.bytes);
- n_events += 1;
- },
- .eof => |e| {
- _ = libc.close(e.fd); // the dead reader's master — stale or current
- if (gens[e.pane] == e.gen) {
- ptys[e.pane] = null;
- core.update(.{ .eof = .{ .pane = e.pane } });
- }
- n_events += 1;
- },
- .lsp => |l| {
- core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } });
- allocs.lsp.free(l.rows);
- n_events += 1;
- },
- .pipe => |response_value| {
- var response = response_value;
- core.update(.{ .pipe_resp = .{
- .id = response.id,
- .success = response.success,
- .outputs = response.outputs,
- } });
- response.deinit(gpa);
- n_events += 1;
- pipe_tasks.finish(io, response_value.id);
- },
- // both unreachable here: this mode starts neither thread
- .files_changed => {},
- .command => |line| gpa.free(line),
- };
- drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, true);
- pollCwds(core, &ptys);
- // The grid harness polls stdin at the same 16 ms cadence as native
- // SDL. Advancing here lets `stable` wait for exact endpoint colors;
- // once inactive it resumes the old event-only frame contract.
- if (core.animationActive()) {
- core.update(.tick);
- n_events += 1;
- }
- if (n_events == 0) continue; // idle tick: nothing changed, no frame
- _ = frame_arena.reset(.retain_capacity);
- const surface = try core.render(frame_arena.allocator());
- try dumpGrid(gpa, surface);
- core.acknowledgePanelPresentation(&.{});
+ if (r.n_events != 0) shell.saw_event = true;
+ shell.drainQueue();
+ try core.pump(host);
+ if (shell.dump_err) |err| return err;
+ }
+ // The last frame is outside `pump` for the same reason the first one is:
+ // `pump` returns before drawing a quitting pass, and this stream records
+ // the empty grid a closed session leaves behind. Same three host methods
+ // in the same order, so the idle rule and the acknowledgement stay in one
+ // place — only the render is out here.
+ if (core.quit) {
+ gridPollFrame(&shell);
+ var last: std.heap.ArenaAllocator = .init(allocs.frame);
+ defer last.deinit();
+ gridPresent(&shell, try core.render(last.allocator()));
+ gridPostPresent(&shell);
+ if (shell.dump_err) |err| return err;
}
}
@@ -3107,150 +2984,468 @@ fn pollGamepad(g: *Gui, core: *pardes.Pardes) void {
}
// =====================================================================
-// effects — identical duties to tty.zig's drainEffects, plus SDL clipboard
+// the host seam — everything the core cannot do itself: ptys, files, the
+// desktop, pixels, and the one place this process is allowed to sleep.
// =====================================================================
-fn drainEffects(
+/// The state the host methods below need. `gui` is null in grid test mode:
+/// no SDL, so no clipboard and no pixels, and the frames go out as text.
+const Shell = struct {
+ /// Reassigned by Restore, which is why the loop pumps rather than runs:
+ /// a swap is only safe BETWEEN iterations.
core: *pardes.Pardes,
+ gui: ?*Gui = null,
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ lsp_allocator: std.mem.Allocator,
prompt_rcs: *const shell_bin.PromptRcs,
ptys: *[pardes.MAX_PANES]?Pty,
gens: *[pardes.MAX_PANES]u32,
- io: std.Io,
- gpa: std.mem.Allocator,
queue: *Queue,
pipe_tasks: *PipeTasks,
- g: ?*Gui, // null in grid test mode (no SDL: clipboard effects are no-ops)
inotify_fd: c_int,
watches: *file_watch.Table,
- threads_ok: bool,
-) void {
- while (core.nextEffect()) |effect| switch (effect) {
- .spawn => |sp| {
- // the core reuses pane ids and there is no close effect: a deleted
- // pane's shell lives in its slot until a respawn lands here. Kill
- // it; its detached reader wakes on child death and the gen-guarded
- // eof closes the old fd (not here — the reader still reads it).
- if (ptys[sp.pane]) |old| {
- _ = libc.kill(old.pid, libc.SIG.KILL);
- ptys[sp.pane] = null;
- }
- gens[sp.pane] +%= 1;
- const cwd = sp.cwd.slice();
- var cwd_buf: [256:0]u8 = undefined;
- var cwd_z: ?[*:0]const u8 = null;
- if (cwd.len > 0) {
- @memcpy(cwd_buf[0..cwd.len], cwd);
- cwd_buf[cwd.len] = 0;
- cwd_z = @ptrCast(&cwd_buf);
- }
- const pt = forkShell(core, sp.pane, prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w);
- ptys[sp.pane] = pt;
- // report the pane's starting directory back to the core (tags); the
- // slot needs no occupancy reset, nothing about it is remembered
- var lbuf: [1024]u8 = undefined;
- if (look.shellCwd(pt.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd);
- if (threads_ok) spawnReader(gpa, pt, sp.pane, gens[sp.pane], queue);
- },
- .write => |w| {
- if (ptys[w.pane]) |pt| writeFd(pt.fd, w.bytes.slice());
- },
- .resize_pty => |rs| {
- if (ptys[rs.pane]) |pt| {
- const ws: posix.winsize = .{ .row = rs.rows, .col = rs.cols, .xpixel = 0, .ypixel = 0 };
- _ = posix.system.ioctl(pt.fd, TIOCSWINSZ, @intFromPtr(&ws));
- }
- },
- .open_link => |url| look.openLink(url.slice()), // desktop browser
- .save_file => |sf| {
- const pane = core.panes[sf.pane] orelse continue;
- const f = pane.file orelse continue;
- var pathbuf: [4096:0]u8 = undefined;
- if (f.path.len >= pathbuf.len) continue;
- @memcpy(pathbuf[0..f.path.len], f.path);
- pathbuf[f.path.len] = 0;
- const fd = libc.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
- if (fd < 0) continue;
- writeFd(fd, f.content);
- _ = libc.close(fd);
- // our own write is about to come back as a watch event: restamp
- // from the bytes we just put there so it reads as "no change"
- if (watches[sf.pane]) |*w| if (w.serial == pane.serial) switch (w.generation) {
- .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, f.content) },
+ /// worker threads exist. The pre-loop drain forks before any of them do:
+ /// forkpty from a multithreaded process can wedge the child before exec.
+ threads_ok: bool = false,
+ /// PARDES_TEST: input is stdin escape sequences, not SDL events
+ test_mode: bool = false,
+ feed: StdinFeed = .{},
+ /// what the last present actually put on screen, and the frame it drew:
+ /// post_present may only acknowledge a frame the user has seen.
+ presented: bool = false,
+ surface: ?*pardes.Surface = null,
+ /// `pump` spends no animation time; this is where the display clock does.
+ animation_clock: AnimationClock = .{},
+ /// Whether this pass observed any input. Only the grid harness reads it:
+ /// its contract is one frame per scripted input event, so a pass that saw
+ /// nothing writes nothing.
+ saw_event: bool = false,
+ /// Grid mode only: a failed write to the frame stream. Kept rather than
+ /// swallowed because a `present` cannot fail and the harness must.
+ dump_err: ?anyerror = null,
+
+ fn host(s: *Shell) pardes.Host {
+ return .{ .ctx = s, .vtable = if (s.gui == null) &grid_vtable else &vtable };
+ }
+
+ const vtable: pardes.Host.VTable = .{
+ .pull_wait_input = waitInput,
+ .push_present = present,
+ .push_post_present = postPresent,
+ .push_poll_frame = pollFrame,
+ .push_spawn = spawnPane,
+ .push_pty_write = ptyWrite,
+ .push_pty_resize = ptyResize,
+ .pull_tty_taken = ttyTaken,
+ .push_write_file = writeFile,
+ .push_write_dump = writeDump,
+ .push_watch_file = watchFile,
+ .push_watch_theme = watchTheme,
+ .push_dump_themes = dumpThemes,
+ .push_set_clipboard = setClipboard,
+ .pull_read_clipboard = readClipboard,
+ .push_open_link = openLink,
+ .pull_lsp = lsp,
+ .pull_pipe = pipe,
+ };
+
+ /// The headless grid harness. It reads its scripted stdin itself, because
+ /// EOF ends the session and nothing may be drawn after it — so there is no
+ /// `wait_input` here, and this process never sleeps in grid mode. It starts
+ /// neither the watcher nor the nested listener, so `drainQueue`'s
+ /// `files_changed` and `command` arms cannot fire behind it.
+ const grid_vtable: pardes.Host.VTable = vt: {
+ var v = vtable;
+ v.pull_wait_input = null;
+ v.push_poll_frame = gridPollFrame;
+ v.push_present = gridPresent;
+ v.push_post_present = gridPostPresent;
+ break :vt v;
+ };
+
+ /// What the detached workers handed this thread since the last pass.
+ /// Their bytes are borrowed for exactly one `update` call each. Each
+ /// message is something this pass observed — the grid harness draws a
+ /// frame only for a pass that observed something.
+ fn drainQueue(s: *Shell) void {
+ var msgs = s.queue.take();
+ var check_files = false;
+ for (msgs.slice()) |m| switch (m) {
+ .output => |o| {
+ if (s.gens[o.pane] == o.gen)
+ s.core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } });
+ s.gpa.free(o.bytes);
+ s.saw_event = true;
+ },
+ .eof => |e| {
+ _ = libc.close(e.fd); // the dead reader's master — stale or current
+ if (s.gens[e.pane] == e.gen) {
+ s.ptys[e.pane] = null;
+ s.core.update(.{ .eof = .{ .pane = e.pane } });
+ }
+ s.saw_event = true;
+ },
+ .lsp => |l| {
+ s.core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } });
+ s.lsp_allocator.free(l.rows);
+ s.saw_event = true;
+ },
+ .pipe => |response_value| {
+ var response = response_value;
+ s.core.update(.{ .pipe_resp = .{
+ .id = response.id,
+ .success = response.success,
+ .outputs = response.outputs,
+ } });
+ response.deinit(s.gpa);
+ s.saw_event = true;
+ s.pipe_tasks.finish(s.io, response_value.id);
+ },
+ .command => |line| {
+ s.core.update(.{ .command = line });
+ s.gpa.free(line);
+ },
+ // Coalesced on purpose: a burst of writes (a formatter, a build, a
+ // `git checkout`) collapses into ONE pass below, so it cannot queue
+ // a reload — or an undo entry — per write.
+ .files_changed => check_files = true,
+ };
+ if (check_files and file_watch.reloadChanged(s.core, s.io, s.gpa, s.watches))
+ s.queue.push(.files_changed);
+ }
+};
+
+fn shellOf(ctx: ?*anyopaque) *Shell {
+ return @ptrCast(@alignCast(ctx.?));
+}
+
+/// SDL first (blocking briefly for one event, then draining the burst), then
+/// the scripted stdin feed, then the worker inbox, then the sticks. Never
+/// blocks indefinitely even when the core offers to: cwd polling, the gamepad
+/// and the test feed have no SDL event to wake them.
+fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
+ const s = shellOf(ctx);
+ const core = s.core;
+ if (s.gui) |g| {
+ var sev = std.mem.zeroes(c.SDL_Event);
+ const ms: c_int = if (timeout_ms != 0) @intCast(timeout_ms) else 16;
+ if (c.SDL_WaitEventTimeout(&sev, ms)) {
+ dispatch(g, core, &sev);
+ while (c.SDL_PollEvent(&sev)) dispatch(g, core, &sev);
+ }
+ }
+ if (s.test_mode) {
+ // A dead scripted feed ends the session HERE, before the inbox, the
+ // sticks and the frame: the pre-pump loop broke at this line, and a
+ // capture written after EOF is a frame no script asked for.
+ const r = s.feed.pump(s.gpa, core, s.gui) catch {
+ core.quit = true;
+ return;
+ };
+ if (r.eof) {
+ core.quit = true;
+ return;
+ }
+ }
+ s.drainQueue();
+ if (s.gui) |g| pollGamepad(g, core);
+}
+
+/// Per-frame host bookkeeping with no event of its own, in the order the flat
+/// loop had it: the tagline face, a font the core asked for, live cwds, the
+/// grid following the window, and the wheel batch — applied LAST before the
+/// render, while the previous frame is still the one on screen.
+fn pollFrame(ctx: ?*anyopaque) void {
+ const s = shellOf(ctx);
+ const core = s.core;
+ const g = s.gui orelse return;
+ // TaglineSize is pure renderer state: update the smaller face and its
+ // visual band immediately, without changing the body metrics or grid.
+ syncTaglineFont(g, core);
+ // Font builtin: the core resolved a name to a path and asked for it — it
+ // cannot load a font itself, having no rasterizer, no atlas and no window.
+ if (core.takeFontRequest()) |path| blk: {
+ const bytes = look.readFile(s.gpa, path) catch {
+ core.rejectFont();
+ break :blk;
+ };
+ const nf = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse {
+ // FreeType turned it down. Keep wearing the one that works: a font
+ // pardes cannot rasterize is a blank window with no way back out.
+ log.err("ui_font_new failed: {s}", .{path});
+ s.gpa.free(bytes);
+ core.rejectFont();
+ break :blk;
+ };
+ c.ui_font_free(g.font);
+ if (g.font_bytes.len != 0) s.gpa.free(g.font_bytes);
+ g.font = nf;
+ g.font_bytes = bytes; // FreeType borrows them for the face lifetime
+ setGuiFontName(g, core.settings.font.requested_name.get());
+ refitFont(g, core);
+ acknowledgeGuiFont(g, core);
+ }
+ pollCwds(core, s.ptys);
+ // Off g.cell_w/h, not the startup metrics: a font change moves them, and
+ // this is the line that would go on dividing by the old cell.
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
+ const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w)));
+ const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h)));
+ if (updateCoreResize(core, cols, rows, g.cell_w, g.cell_h)) resetScroll(g);
+ stepScroll(g, core, s.gpa);
+}
+
+fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
+ const s = shellOf(ctx);
+ const core = s.core;
+ const g = s.gui orelse return;
+ // renderFrame consumes the frame the core just built; nothing here writes
+ // to it, and post_present needs the same one to acknowledge.
+ const frame = @constCast(surface);
+ s.surface = frame;
+ const scene_requested = core.settings.scene_effects.crt or
+ core.settings.scene_effects.ripple or core.settings.scene_effects.glitch;
+ if (!scene_requested) g.scene_failures = 0;
+ s.presented = renderFrame(
+ g,
+ s.gpa,
+ core,
+ frame,
+ core.theme().bg,
+ config.gui_topbar_pane_border_rgb orelse core.chromeTheme().scroll_track,
+ core.chromeTheme().tag_bg,
+ core.settings.scene_effects,
+ core.settings.debug,
+ ) catch |err| blk: {
+ log.err("render: {t}", .{err});
+ break :blk false;
+ };
+ if (g.scene_target_failed) {
+ g.scene_failures +|= 1;
+ log.err("scene target unavailable (attempt {d}/3)", .{g.scene_failures});
+ if (g.scene_failures >= 3) {
+ core.disableSceneEffects();
+ g.scene_failures = 0;
+ }
+ } else if (scene_requested and s.presented) g.scene_failures = 0;
+}
+
+/// A tick is spent only on a frame that was actually PRESENTED: a failed
+/// renderFrame must not advance samples nobody saw.
+fn postPresent(ctx: ?*anyopaque) void {
+ const s = shellOf(ctx);
+ const g = s.gui orelse return;
+ if (!s.presented) return;
+ const frame = s.surface orelse return;
+ // Ripple/glitch move source cells under a stationary physical pointer.
+ // Re-feed only when that accepted scene maps to a new cell, using the same
+ // core mouse path a real motion event uses.
+ refreshPresentedPointer(g, s.core);
+ finishPresentedAnimationFrame(
+ &s.animation_clock,
+ s.core,
+ frame.panelTracks(),
+ c.SDL_GetTicksNS(),
+ );
+}
+
+/// The grid harness's own three seams. It has no window, no pointer and no
+/// compositor, so what is left of a frame is the cwds a tagline draws, one
+/// animation step, and the text of the grid itself.
+fn gridPollFrame(ctx: ?*anyopaque) void {
+ const s = shellOf(ctx);
+ pollCwds(s.core, s.ptys);
+ // The harness polls stdin at the same 16 ms cadence as native SDL, so a
+ // pass IS a frame interval and the tick is due here rather than behind a
+ // display clock. Advancing lets `stable` wait for exact endpoint colors;
+ // once inactive it resumes the event-only frame contract.
+ if (s.core.animationActive()) {
+ s.core.update(.tick);
+ s.saw_event = true;
+ }
+}
+
+fn gridPresent(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
+ const s = shellOf(ctx);
+ // Idle pass: nothing changed, so no frame. The stream is one frame per
+ // scripted input event and a repeat of the last grid would be read as one.
+ s.presented = s.saw_event;
+ s.saw_event = false;
+ if (!s.presented) return;
+ dumpGrid(s.gpa, @constCast(surface)) catch |err| {
+ s.dump_err = err;
+ s.presented = false;
+ };
+}
+
+fn gridPostPresent(ctx: ?*anyopaque) void {
+ const s = shellOf(ctx);
+ if (!s.presented) return;
+ // The grid protocol writes canonical cells; panel tracks are metadata for
+ // a compositor it deliberately does not run.
+ s.core.acknowledgePanelPresentation(&.{});
+}
+
+fn spawnPane(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
+ const s = shellOf(ctx);
+ // the core reuses pane ids and there is no close effect: a deleted pane's
+ // shell lives in its slot until a respawn lands here. Kill it; its
+ // detached reader wakes on child death and the gen-guarded eof closes the
+ // old fd (not here — the reader still reads it).
+ if (s.ptys[pane]) |old| {
+ _ = libc.kill(old.pid, libc.SIG.KILL);
+ s.ptys[pane] = null;
+ }
+ s.gens[pane] +%= 1;
+ var cwd_buf: [256:0]u8 = undefined;
+ var cwd_z: ?[*:0]const u8 = null;
+ if (cwd.len > 0 and cwd.len < cwd_buf.len) {
+ @memcpy(cwd_buf[0..cwd.len], cwd);
+ cwd_buf[cwd.len] = 0;
+ cwd_z = @ptrCast(&cwd_buf);
+ }
+ const pt = forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w);
+ s.ptys[pane] = pt;
+ // report the pane's starting directory back to the core (tags); the slot
+ // needs no occupancy reset, nothing about it is remembered
+ var lbuf: [1024]u8 = undefined;
+ if (look.shellCwd(pt.pid, &lbuf)) |wd| s.core.setCwd(pane, wd);
+ if (s.threads_ok) spawnReader(s.gpa, pt, pane, s.gens[pane], s.queue);
+}
+
+fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void {
+ const s = shellOf(ctx);
+ if (s.ptys[pane]) |pt| writeFd(pt.fd, bytes);
+}
+
+fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void {
+ const s = shellOf(ctx);
+ if (s.ptys[pane]) |pt| {
+ const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ _ = posix.system.ioctl(pt.fd, TIOCSWINSZ, @intFromPtr(&ws));
+ }
+}
+
+/// Is a program (vim, a pager, an agent) holding this pane's tty instead of
+/// the shell we forked? Asked by the core only where it is about to type a
+/// command line, which is why the /proc walk behind it is not in pollCwds:
+/// nothing draws this answer, and an Exec is a rare frame.
+fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool {
+ const s = shellOf(ctx);
+ const pt = s.ptys[pane] orelse return false;
+ return look.ttyTaken(pt.pid, pt.fd);
+}
+
+fn writeWholeFile(path: []const u8, bytes: []const u8) bool {
+ var pathbuf: [4096:0]u8 = undefined;
+ if (path.len >= pathbuf.len) return false;
+ @memcpy(pathbuf[0..path.len], path);
+ pathbuf[path.len] = 0;
+ const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ if (fd < 0) return false;
+ writeFd(fd, bytes);
+ _ = libc.close(fd);
+ return true;
+}
+
+fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
+ const s = shellOf(ctx);
+ if (!writeWholeFile(path, bytes)) return;
+ // our own write is about to come back as a watch event: restamp from the
+ // bytes we just put there so it reads as "no change". Only for the pane's
+ // OWN file — a `Put` elsewhere is a change like any other.
+ if (s.core.panes[pane]) |pane_state| if (pane_state.file) |f| {
+ if (std.mem.eql(u8, f.path, path))
+ if (s.watches[pane]) |*w| if (w.serial == pane_state.serial) switch (w.generation) {
+ .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) },
.pdf => {},
};
- // ...and say so on the pane's message row. AFTER the write, not
- // beside it: every `continue` above is a save that did not happen
- // and must not be reported as one.
- var mbuf: [256]u8 = undefined;
- core.setMessage(sf.pane, message.stamp(&mbuf, "saved", f.path));
- },
- .new_file => |request| {
- var path_buf: [4096:0]u8 = undefined;
- const made = temp_file.create(&path_buf) orelse continue;
- if (core.openNewFile(request.pane, request.serial, made.path))
- made.adopt()
- else
- made.discard();
- },
- .write_dump => {
- const out = core.dump_out orelse continue;
- var pbuf: [1024:0]u8 = undefined;
- const path = pardes.dump.outPath(&pbuf) orelse continue;
- const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
- if (fd < 0) continue;
- writeFd(fd, out);
- core.setLastDump(path);
- _ = libc.close(fd);
- },
- .dump_themes => |request| {
- const config_dir = core.opts.config_dir orelse continue;
- const out_dir = user_config.dumpThemes(io, gpa, config_dir, pardes.themes) catch |err| {
- core.reportError(request.pane, "dump themes", err);
- continue;
- };
- defer gpa.free(out_dir);
- var mbuf: [256]u8 = undefined;
- core.setMessage(request.pane, message.stamp(&mbuf, "dumped themes", out_dir));
- },
- .set_clipboard => {
- if (g == null) continue;
- const y = core.yank orelse continue;
- const z = gpa.dupeZ(u8, y) catch continue;
- defer gpa.free(z);
- _ = c.SDL_SetClipboardText(z.ptr);
- },
- .read_clipboard => {
- if (g == null) continue;
- // SDL answers synchronously, so the paste the core is waiting on
- // lands inside this same drain — nothing to remember, no reply
- // path to plumb. SDL3 hands over an OWNED copy that is ours to
- // SDL_free, and reports "no text" as an EMPTY string rather than
- // null, so the length check is what actually rejects a miss.
- const raw = c.SDL_GetClipboardText() orelse continue;
- defer c.SDL_free(raw);
- const text = std.mem.span(raw);
- if (text.len == 0) continue;
- core.update(.{ .paste = text });
- },
- .lsp => |e| if (threads_ok) spawnLsp(core, queue, e),
- .pipe => |e| if (threads_ok) spawnPipe(core, io, gpa, queue, pipe_tasks, e),
- .watch => |w| {
- if (file_watch.applyEffect(core, io, gpa, inotify_fd, watches, w.pane, w.on))
- queue.push(.files_changed);
- },
- .theme_file => |request| {
- if (file_watch.applyThemeEffect(
- core,
- gpa,
- inotify_fd,
- watches,
- request.generation,
- request.on,
- threads_ok,
- )) queue.push(.files_changed);
- },
- .quit => {},
};
+ // ...and say so on the pane's message row. AFTER the write, not beside it:
+ // a save that did not happen must not be reported as one.
+ var mbuf: [256]u8 = undefined;
+ s.core.setMessage(pane, message.stamp(&mbuf, "saved", path));
+}
+
+fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
+ const s = shellOf(ctx);
+ var pbuf: [1024:0]u8 = undefined;
+ const path = pardes.dump.outPath(&pbuf) orelse return;
+ if (!writeWholeFile(path, bytes)) return;
+ s.core.setLastDump(path);
+}
+
+fn watchFile(ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool) void {
+ const s = shellOf(ctx);
+ _ = path; // file_watch resolves it (and a PDF's) from the pane itself
+ if (file_watch.applyEffect(s.core, s.io, s.gpa, s.inotify_fd, s.watches, pane, on))
+ s.queue.push(.files_changed);
+}
+
+fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void {
+ const s = shellOf(ctx);
+ if (file_watch.applyThemeEffect(
+ s.core,
+ s.gpa,
+ s.inotify_fd,
+ s.watches,
+ generation,
+ on,
+ s.threads_ok,
+ )) s.queue.push(.files_changed);
+}
+
+fn dumpThemes(ctx: ?*anyopaque, pane: u8) void {
+ const s = shellOf(ctx);
+ const config_dir = s.core.opts.config_dir orelse return;
+ const out_dir = user_config.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| {
+ s.core.reportError(pane, "dump themes", err);
+ return;
+ };
+ defer s.gpa.free(out_dir);
+ var mbuf: [256]u8 = undefined;
+ s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir));
+}
+
+fn setClipboard(ctx: ?*anyopaque, text: []const u8) void {
+ const s = shellOf(ctx);
+ if (s.gui == null) return;
+ const z = s.gpa.dupeZ(u8, text) catch return;
+ defer s.gpa.free(z);
+ _ = c.SDL_SetClipboardText(z.ptr);
+}
+
+fn readClipboard(ctx: ?*anyopaque) void {
+ const s = shellOf(ctx);
+ if (s.gui == null) return;
+ // SDL answers synchronously, so the paste the core is waiting on lands
+ // inside this same drain — nothing to remember, no reply path to plumb.
+ // SDL3 hands over an OWNED copy that is ours to SDL_free, and reports "no
+ // text" as an EMPTY string rather than null, so the length check is what
+ // actually rejects a miss.
+ const raw = c.SDL_GetClipboardText() orelse return;
+ defer c.SDL_free(raw);
+ const text = std.mem.span(raw);
+ if (text.len == 0) return;
+ s.core.update(.{ .paste = text });
+}
+
+fn openLink(ctx: ?*anyopaque, url: []const u8) void {
+ _ = ctx;
+ look.openLink(url); // desktop browser
+}
+
+fn lsp(ctx: ?*anyopaque, req: host_api.LspRequest) void {
+ const s = shellOf(ctx);
+ if (s.threads_ok) spawnLsp(s.core, s.queue, req);
+}
+
+fn pipe(ctx: ?*anyopaque, id: u32) void {
+ const s = shellOf(ctx);
+ if (s.threads_ok) spawnPipe(s.core, s.io, s.gpa, s.queue, s.pipe_tasks, id);
}
fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty {
@@ -3272,7 +3467,7 @@ fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.Pro
/// Live cwd for tags/look: a cheap per-pane process lookup, polled every frame
/// because a tagline draws it. Whether a pane's tty still belongs to the prompt
-/// pardes forked is deliberately NOT polled with it — see `ttyTakenAt`.
+/// pardes forked is deliberately NOT polled with it — see `ttyTaken`.
fn pollCwds(core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty) void {
for (ptys, 0..) |slot, id| if (slot) |pt| {
var lbuf: [1024]u8 = undefined;
@@ -3280,16 +3475,6 @@ fn pollCwds(core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty) void {
};
}
-/// The core's `tty_query`: is a program (vim, a pager, an agent) holding this
-/// pane's tty instead of the shell we forked? Asked by the core only where it is
-/// about to type a command line, which is why the /proc walk behind it is not in
-/// pollCwds above: nothing draws this answer, and an Exec is a rare frame.
-fn ttyTakenAt(ctx: ?*anyopaque, pane: usize) bool {
- const table: *const [pardes.MAX_PANES]?Pty = @ptrCast(@alignCast(ctx orelse return false));
- const pt = table[pane] orelse return false;
- return look.ttyTaken(pt.pid, pt.fd);
-}
-
// =====================================================================
// fractional scroll: whole rows for the core, sub-row offsets for the picture
// =====================================================================