diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-24 10:56:05 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-25 09:42:07 -0300 |
| commit | c3d0b84b7961ae26d2d654e7120821cc2d83d20d (patch) | |
| tree | 0b9a060a8ff0ee7d83c8df5ca797a99e0d66c53e /src/gui/gui.zig | |
| parent | 70bde600793ea70bd68832018a154671c6bf1512 (diff) | |
| download | pardes-c3d0b84b7961ae26d2d654e7120821cc2d83d20d.tar.gz pardes-c3d0b84b7961ae26d2d654e7120821cc2d83d20d.zip | |
host: the core owns the event loop; every platform becomes a vtable of optional methods
Diffstat (limited to 'src/gui/gui.zig')
| -rw-r--r-- | src/gui/gui.zig | 903 |
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 // ===================================================================== |
