From c3d0b84b7961ae26d2d654e7120821cc2d83d20d Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 24 Aug 2026 10:56:05 -0300 Subject: host: the core owns the event loop; every platform becomes a vtable of optional methods --- src/CHANGELOG.md | 34 ++ src/builtins.zig | 60 ++- src/config.zig | 41 +- src/file_pane.zig | 5 +- src/grammar_manifest.zig | 1 + src/gui/gui.zig | 903 ++++++++++++++++++++++--------------- src/host.zig | 289 ++++++++++++ src/look.zig | 14 +- src/macos.zig | 525 +++++++++++++--------- src/output_pane.zig | 12 + src/pardes.zig | 1107 ++++++++++++++++++++++++++++++++++++---------- src/source_manifest.zig | 44 ++ src/syntax.zig | 191 +++++++- src/term_pane.zig | 99 ++++- src/tty/tty.zig | 1086 +++++++++++++++++++++++++-------------------- src/web.zig | 381 +++++----------- src/web/app.mjs | 106 ++--- 17 files changed, 3246 insertions(+), 1652 deletions(-) create mode 100644 src/CHANGELOG.md create mode 100644 src/host.zig create mode 100644 src/source_manifest.zig (limited to 'src') diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md new file mode 100644 index 00000000..61e670d6 --- /dev/null +++ b/src/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +## 0.0.1 + +- Version tracking: the compiled version is embedded from build.zig.zon and shown by Changelog. +- New builtins: Changelog, Newtty, and Joincol. +- Markdown tree-sitter highlighting, including nested fenced code blocks. +- `.patch` and `.diff` files are highlighted. +- Coloring dispatch keys off the buffer title. +- Opening a new file only splits into a new column when both panes would be at least 100 columns wide. +- Terminal ANSI colors render only in tty mode; normal mode is a plain editing + view, so an edit can never shift a shell row's colour. +- `New` and `Newcol` open an empty scratch buffer instead of a shell or a + temporary file. Its directory is inherited from the pane it was opened from. +- `Save` on a scratch asks for a path, prefilled with that inherited + directory, and the buffer becomes an ordinary file once written. +- `Save` on a terminal writes its plaintext scrollback to a prompted path and + leaves the pane a terminal. +- A dropped pane's memory outlives its frame, so a pane pointer taken during + that frame stays valid until the next one repairs it. +- The core owns the event loop. Each platform is now a `Host` of optional + function pointers; an unimplemented method falls back to an in-process + default, so a host with no methods at all is still a complete pardes. +- Ctrl-V and Ctrl-Shift-V paste in a tty pane: the default register and the + system clipboard, typed at the program the way a terminal emulator pastes + (bracketed under mode 2004). A paste arriving from the desktop reaches the + shell instead of an edit buffer the pty cannot see. +- The default filesystem is pardes's own source, embedded from an allowlist + (src/source_manifest.zig): what a host with no file method reads, and what + the browser has always read. It replaced a build-time generator that embedded + every tracked .zig file. +- `zig build run-isolated` builds and runs `pardes-isolate`: the same source + with one comptime option, whose host supplies only the terminal. The + filesystem is not disabled in it, it is absent — the libc paths compile away. diff --git a/src/builtins.zig b/src/builtins.zig index ca3cf931..b4447289 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -520,38 +520,37 @@ pub const ClipReplace = struct { pub const Save = struct { pub fn run(c: Ctx) void { - // an output buffer has no file behind it — nothing to write if (c.pane.file) |*f| { - if (!output_pane.fileTraits(f.output).saves) return; + if (f.output != null) { + // an unsavable output (search/help) has nothing to write; a + // scratch (savable output) asks for a path and becomes a file + if (output_pane.fileTraits(f.output).saves) c.p.startSavePrompt(c.pane); + return; + } + // a real file: write in place, and this edit is now on disk c.p.emit(.{ .save_file = .{ .pane = @intCast(c.id) } }); - // ...and this edit is now the one on disk. See File.saved_revision - // for why the mark goes here rather than after the write. f.saved_revision = f.revision; + return; } + // a terminal: write its plaintext scrollback to a prompted path + if (c.pane.isTerminal()) c.p.startSavePrompt(c.pane); } }; -/// Ask the native shell for an atomically-created empty temporary file. The -/// pane and its lifetime serial are the placement token: by the time IO -/// finishes another column may be active, or this slot may even have been -/// reused, and neither is permission to put the document somewhere else. +/// An empty scratch buffer below the calling pane, inheriting its directory. +/// No file exists yet, so Save asks for a path prefilled with that directory +/// and, once written, the buffer becomes an ordinary file pane. pub const New = struct { + pub const output: OutputTraits = .{ .name = config.scratch_buffer, .doc = true, .saves = true }; pub fn run(c: Ctx) void { - if (c.p.freeSlot() == null) return; - c.p.emit(.{ .new_file = .{ .pane = @intCast(c.id), .serial = c.pane.serial } }); + c.p.newScratchBelow(c.id); } }; +/// The same empty scratch, opened in a fresh column beside the calling pane. pub const Newcol = struct { pub fn run(c: Ctx) void { - const free = c.p.freeSlot() orelse return; - if (!c.p.layoutCanSplitColumn(c.id)) return; - const nt = c.p.newShell(free, "") catch return; - nt.greet = true; - // The shared preflight above is the complete geometry refusal set. - // Pane creation does not alter layout, so the same split is infallible. - std.debug.assert(c.p.layoutSplitColumn(c.id, free, false)); - c.p.active = free; + c.p.newScratchColumn(c.id); } }; @@ -601,6 +600,22 @@ pub const Delcol = struct { } }; +/// A shell in the calling pane's directory, raw from the first frame. On every +/// pane's tagline: the fast path from wherever you are to a prompt there. +pub const Newtty = struct { + pub fn run(c: Ctx) void { + c.p.spawnTty(c.id); + } +}; + +/// Fold the active pane's column into the one on its right, keeping its panes. +/// The horizontal mirror of the vertical stacking `New` does. +pub const Joincol = struct { + pub fn run(c: Ctx) void { + c.p.joinCol(); + } +}; + pub const Tutor = struct { pub fn run(c: Ctx) void { const free = c.p.freeSlot() orelse return; @@ -633,6 +648,15 @@ pub const Config = struct { } }; +/// This build's version and what changed to reach it, printed into an output +/// buffer the same way Config prints the live settings. +pub const Changelog = struct { + pub const output: OutputTraits = .{ .name = config.changelog_buffer }; + pub fn run(c: Ctx) void { + output_pane.openChangelog(c.p, c.id) catch |err| c.p.reportError(c.id, "changelog", err); + } +}; + /// Source code for the concrete backend implementation of a Panel*/scene /// effect. The bytes are embedded at build time, so this works from an /// installed executable rather than depending on a source checkout. diff --git a/src/config.zig b/src/config.zig index a01db954..685d083a 100644 --- a/src/config.zig +++ b/src/config.zig @@ -131,14 +131,17 @@ pub const leader_path = paths: { // New here, which frees `w` for the window group (SPC w h/j/k/l). .Save = "fs", .New = "fn", + .Newtty = "nt", .Find = "ff", .Grep = "fg", // the config FILE joins the file group: `SPC f c` says where pardes // read (or would read) its startup commands from. .Config = "fc", .Tutor = "ht", + .Changelog = "hc", .Newcol = "cn", .Delcol = "cd", + .Joincol = "cj", .Debug = "td", // `Msg` takes the text to post, so it has no path, for the reason // `Theme` and the two acme verbs below have none: a key path names a @@ -336,6 +339,28 @@ pub const tty_toggle_default: u21 = 'b'; /// means what Escape always means. pub const tty_toggle_alt: []const Chord = &.{.{ .cp = Key.escape, .shift = true }}; +/// Paste INTO the program a tty pane is running. `SPC p` and the acme 1-3 +/// chord cannot be reached there — the pty owns every keystroke and every +/// button — so raw tty mode needs its own pair, and these are the two a +/// terminal user already has in their hands. +/// +/// The split is the one the whole clipboard design rests on: Ctrl-V types the +/// DEFAULT register (what `y` put there, no round trip, no desktop involved), +/// Ctrl-Shift-V asks for the SYSTEM clipboard. Two spellings for the second +/// because a host may or may not fold the shift into the codepoint, and it +/// must be tested BEFORE the first: `hit` ignores an unasked shift, so plain +/// Ctrl-V matches a shifted key too. +/// +/// What this TAKES: forwardKey encoded both as the same byte, 0x16, so +/// Ctrl-Shift-V was a duplicate ^V and costs nothing to claim. Ctrl-V was +/// readline's quoted-insert, and that one is now unreachable in a tty pane — +/// the trade a terminal user expects, and one line to give back. +pub const tty_paste: []const Chord = &.{.{ .cp = 'v', .ctrl = true }}; +pub const tty_paste_clipboard: []const Chord = &.{ + .{ .cp = 'v', .ctrl = true, .shift = true }, + .{ .cp = 'V', .ctrl = true }, +}; + /// What LEAVING raw tty mode hides on the shell's prompt rows. /// /// A prompt is CHROME. `user@host ~/src $` is redrawn on every keystroke, says @@ -381,16 +406,16 @@ pub const tty_blank: enum { prompt, prompt_and_input } = .prompt; // thing that would tell you the leader exists, is exactly what you cannot // press. Row 0 is not a pane, so a middle-click on it is dispatched before any // pane's mode is consulted: Help works in tty mode, which earns its width. -pub const topbar_str = "New Newcol Find Grep Help Tutor Dump NextColor Debug Kill"; +pub const topbar_str = "New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill"; /// the default editable tail of a pane's tag, per kind (an output buffer has /// no file to Save, so it gets the plain one). Terminals alone expose Filter, /// the pane-local theme-keyed colour projection. Save stays first on real /// files: `:` parks at the tail boundary, so the established `:w` /// spelling still walks to Save while New remains visible on every pane. -pub const pane_builtins_str = "New Del"; -pub const file_pane_builtins_str = "Save New Del"; -pub const terminal_pane_builtins_str = "New Del Filter"; +pub const pane_builtins_str = "New Newtty Del"; +pub const file_pane_builtins_str = "Save New Newtty Del"; +pub const terminal_pane_builtins_str = "New Newtty Del Filter"; /// Columns kept clear to the RIGHT of a tagline's builtins. The path stays at /// the left edge and the builtins are pushed over to end this far short of the @@ -719,6 +744,10 @@ pub const find_marker = " Find /"; pub const grep_marker = " Grep /"; pub const rename_marker = " Rename /"; pub const symbol_marker = " WsSymbols /"; +/// Save on a scratch buffer or a terminal: the tail is the whole PATH to write +/// (no `/` separator, since a path is made of them), prefilled with the pane's +/// directory so only a filename need be typed. +pub const save_marker = " Save "; /// helix `s` / `S`. The only two markers whose word is NOT a builtin — there /// is no Select/Split command to run from a tag, they name the key that armed /// the input so the tag still reads as what it is about to do. They also mark @@ -744,6 +773,10 @@ pub const fonts_buffer = "+Fonts"; pub const pdf_sections_buffer = "+PdfSections"; pub const hover_buffer = "+Hover"; pub const lsp_buffer = "+Lsp"; +pub const changelog_buffer = "+Changelog"; +/// The empty buffer New and Newcol open: no file behind it yet, so Save asks +/// for a path (prefilled with the inherited directory). +pub const scratch_buffer = "+New"; // ============================================================================ // PART 3 — THE HELIX KEYMAP. READ THIS BEFORE RETARGETING ANYTHING BELOW. diff --git a/src/file_pane.zig b/src/file_pane.zig index fd0ab2b7..42e5dba9 100644 --- a/src/file_pane.zig +++ b/src/file_pane.zig @@ -545,7 +545,10 @@ pub fn refreshHighlights(p: *Pardes) void { const slack: usize = if (f.highlights.len == 0) 0 else pane.rows; const start = lineStart(p.gpa, f, f.scroll -| slack); const end = @max(start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS + slack)); - const new_highlights = syntax.highlightFileRange(p.tree_sitter_gpa, f.path, f.content, start, end) catch { + const new_highlights = (switch (pane.colorAlgo()) { + .diff => syntax.highlightDiff(p.tree_sitter_gpa, f.content, start, end), + else => syntax.highlightFileRange(p.tree_sitter_gpa, f.path, f.content, start, end), + }) catch { f.syntax_dirty = false; continue; }; diff --git a/src/grammar_manifest.zig b/src/grammar_manifest.zig index 101cd6f6..01df9e34 100644 --- a/src/grammar_manifest.zig +++ b/src/grammar_manifest.zig @@ -33,6 +33,7 @@ pub const all = [_]Grammar{ .{ .name = "json", .dep = "ts_json", .exts = &.{".json"}, .tier = .full }, .{ .name = "kotlin", .dep = "ts_kotlin", .exts = &.{ ".kt", ".kts" }, .tier = .full, .scanner = true }, .{ .name = "ocaml", .dep = "ts_ocaml", .exts = &.{ ".ml", ".mli" }, .tier = .full, .src = "grammars/ocaml/src", .scanner = true }, + .{ .name = "markdown", .dep = "ts_markdown", .exts = &.{ ".md", ".markdown" }, .tier = .full, .src = "tree-sitter-markdown/src", .scanner = true, .query = "tree-sitter-markdown/queries/highlights.scm" }, .{ .name = "pascal", .dep = "ts_pascal", .exts = &.{ ".pas", ".pp", ".p" }, .tier = .full }, .{ .name = "php", .dep = "ts_php", .exts = &.{ ".php", ".phtml", ".php3", ".php4", ".php5" }, .tier = .full, .src = "php/src", .scanner = true }, .{ .name = "powershell", .dep = "ts_powershell", .exts = &.{ ".ps1", ".psm1", ".psd1" }, .tier = .full, .scanner = true }, 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 // ===================================================================== diff --git a/src/host.zig b/src/host.zig new file mode 100644 index 00000000..d3e93d36 --- /dev/null +++ b/src/host.zig @@ -0,0 +1,289 @@ +//! THE HOST SEAM: everything the core cannot do itself, as one struct of +//! OPTIONAL function pointers — `std.mem.Allocator`/`std.Io` shape, and the +//! generalization of two vtables this codebase already grew on its own +//! (`pardes.TtyQuery`, and the macOS shell's `Runtime`). +//! +//! Every method is optional, and a null method is not an error: the core +//! substitutes a default backed by ordinary data structures in this process +//! (`Fallback` below). So a host implements only what it actually has, and the +//! core cannot tell the difference — a `Save` lands in a real file under the +//! tty host and in `Fallback.files` under a host that never wrote a filesystem +//! method, and every path above that behaves identically. +//! +//! Two consequences worth having on purpose: +//! * The zero-method host IS the test harness. A `Host{}` is a complete, +//! deterministic, in-process pardes with a virtual filesystem, a virtual +//! clipboard and silent ptys. +//! * `Fallback` lives on the Pardes instance, not here, so N cores driven by +//! one fan-out host each keep their own state and can run in parallel. +//! +//! WHAT IS NOT HERE, and why: whether a capability EXISTS in this build stays +//! comptime and stays next to the code it shapes (`pardes.platform`, +//! `pardes.pdf_enabled`, `builtins.capabilities`, `PdfSlot`/`HapticSlot`). +//! A vtable cannot make a field zero-sized or a builtin absent from an enum. +//! The rule is: comptime decides what a BUILD has, this vtable decides who +//! SERVES it at runtime. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const source_manifest = @import("source_manifest.zig"); + +pub const LspRequest = struct { + id: u32, + kind: pardes.lsp.Kind, + pane: u8, + offset: u32, + arg: []const u8, +}; + +pub const Host = struct { + ctx: ?*anyopaque = null, + vtable: *const VTable = &.{}, + + /// One optional method per thing a host can do. Adding a method here is + /// additive for every existing host: they keep it null and get the default. + /// + /// EVERY name says how a fan-out must route it, and the compiler enforces + /// that it does (see Fanout.isPull): + /// `push_` every wrapped host gets it, and it returns nothing — a push + /// with an answer would have N answers and no way to pick one. + /// `pull_` exactly ONE host serves it, because there is one of whatever + /// comes back: one value, one sleep that ends, one `Event.paste` + /// for one Ctrl-V, one `lsp_resp` per request id. + pub const VTable = struct { + // ---- the loop's own three seams ---- + /// Block until there is input or `timeout_ms` elapses, translating + /// whatever arrives into `Pardes.update`/`postEvent` calls. This is the + /// ONLY place the process is allowed to sleep: the core never spins. + /// A pull because one host does the sleeping — fanned out, the second + /// host would not be serviced until the first happened to wake. + pull_wait_input: ?*const fn (ctx: ?*anyopaque, timeout_ms: u32) void = null, + push_present: ?*const fn (ctx: ?*anyopaque, surface: *const pardes.Surface) void = null, + /// After the frame is on screen (panel-presentation acknowledgement, + /// pointer refresh); split from `push_present` because it must observe + /// a frame the user has actually seen. + push_post_present: ?*const fn (ctx: ?*anyopaque) void = null, + /// Per-frame host bookkeeping with no event of its own: cwd polling, a + /// capability handshake landing, gamepad state. + push_poll_frame: ?*const fn (ctx: ?*anyopaque) void = null, + + // ---- pseudo-terminals ---- + push_spawn: ?*const fn (ctx: ?*anyopaque, pane: u8, cwd: []const u8) void = null, + push_pty_write: ?*const fn (ctx: ?*anyopaque, pane: u8, bytes: []const u8) void = null, + push_pty_resize: ?*const fn (ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void = null, + /// Is this pane's terminal still the prompt the host forked, or has a + /// program (vim, a pager, an agent) taken its tty? An effect cannot + /// answer it — the `execute` that asks must choose a destination inside + /// its own update, and effects drain after. A pushed fact would mean + /// every host probing every pane's processes every frame to answer a + /// question asked when a human middle-clicks a word. So the host leaves + /// a way to be asked and the core asks where it decides. The answer + /// must not re-enter the core. + pull_tty_taken: ?*const fn (ctx: ?*anyopaque, pane: u8) bool = null, + + // ---- the filesystem ---- + /// `pane` travels with the bytes only so a host that posts a "saved" + /// message row can name the right pane; the core already resolved the + /// path and the content, so save_file and save_text both land here. + push_write_file: ?*const fn (ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void = null, + /// The session dump. Separate because the host also chooses WHERE it + /// goes (dump.outPath is libc-bound; the freestanding core cannot). + push_write_dump: ?*const fn (ctx: ?*anyopaque, bytes: []const u8) void = null, + push_watch_file: ?*const fn (ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool) void = null, + push_watch_theme: ?*const fn (ctx: ?*anyopaque, generation: u32, on: bool) void = null, + push_dump_themes: ?*const fn (ctx: ?*anyopaque, pane: u8) void = null, + + // ---- the desktop ---- + push_set_clipboard: ?*const fn (ctx: ?*anyopaque, text: []const u8) void = null, + /// Ask; the answer arrives later as an ordinary `Event.paste`, which is + /// why this returns nothing and is still a pull: two hosts answering + /// would paste the clipboard twice. Null answers immediately from the + /// in-process clipboard instead, so a request never goes unanswered. + pull_read_clipboard: ?*const fn (ctx: ?*anyopaque) void = null, + push_open_link: ?*const fn (ctx: ?*anyopaque, url: []const u8) void = null, + + // ---- work that must leave the loop ---- + /// Both answer exactly once, keyed by the id they carry, so both are + /// pulls: a second host's reply would arrive for a request already + /// completed and the core would apply it to whatever holds that id now. + pull_lsp: ?*const fn (ctx: ?*anyopaque, req: LspRequest) void = null, + pull_pipe: ?*const fn (ctx: ?*anyopaque, id: u32) void = null, + }; +}; + +/// Where a host with no `write_dump` puts a session dump. Named here so the +/// core writes it and reports it as one path. +pub const fallback_dump_path = "pardes.dump.zon"; + +/// The in-process implementations behind every null method: a virtual +/// filesystem, a virtual clipboard, and a record of what was asked of ptys and +/// the desktop. Ordinary data structures, one set per Pardes instance. +/// +/// The filesystem is not empty. It is pardes's own source, embedded — see +/// source_manifest.zig — with `files` holding only what this session WROTE, so +/// a Save shadows the built-in copy and reading it back returns the edit. That +/// is what makes a host with no file methods a usable pardes rather than one +/// staring at an empty buffer. +/// +/// Only `files` grows, and it grows by REPLACING a path's content, so no +/// session accumulates. A pane whose child does not exist is SILENT: its bytes +/// are dropped rather than transcribed, because nothing reads a transcript back +/// and a browser session would then carry every keystroke forever. +pub const Fallback = struct { + gpa: std.mem.Allocator, + files: std.StringHashMapUnmanaged([]u8) = .empty, + clipboard: std.ArrayListUnmanaged(u8) = .empty, + /// Last link a host with no browser was asked to open. + link: std.ArrayListUnmanaged(u8) = .empty, + spawned: [pardes.MAX_PANES]bool = @splat(false), + watched: [pardes.MAX_PANES]bool = @splat(false), + + pub fn deinit(f: *Fallback) void { + var it = f.files.iterator(); + while (it.next()) |e| { + f.gpa.free(e.key_ptr.*); + f.gpa.free(e.value_ptr.*); + } + f.files.deinit(f.gpa); + f.clipboard.deinit(f.gpa); + f.link.deinit(f.gpa); + } + + pub fn writeFile(f: *Fallback, path: []const u8, bytes: []const u8) void { + const copy = f.gpa.dupe(u8, bytes) catch return; + if (f.files.getEntry(path)) |e| { + f.gpa.free(e.value_ptr.*); + e.value_ptr.* = copy; + return; + } + const key = f.gpa.dupe(u8, path) catch { + f.gpa.free(copy); + return; + }; + f.files.put(f.gpa, key, copy) catch { + f.gpa.free(key); + f.gpa.free(copy); + }; + } + + /// What this path holds now: the session's own write, else the embedded + /// source. Borrowed — the bytes live in the map or in the binary. + pub fn get(f: *const Fallback, path: []const u8) ?[]const u8 { + if (f.files.get(path)) |written| return written; + return source_manifest.find(path); + } + + pub fn setClipboard(f: *Fallback, text: []const u8) void { + f.clipboard.clearRetainingCapacity(); + f.clipboard.appendSlice(f.gpa, text) catch {}; + } + + pub fn setLink(f: *Fallback, url: []const u8) void { + f.link.clearRetainingCapacity(); + f.link.appendSlice(f.gpa, url) catch {}; + } +}; + +/// Fan out one core's host calls to several real hosts at once — the debugging +/// arrangement: every input reaches every host, and each host answers into its +/// own state. +/// +/// It advertises a method only when some wrapped host actually implements it, +/// so wrapping does NOT mask the core's per-method fallback: fan out two hosts +/// that never opened a link and the link still lands in `Fallback`. +pub const Fanout = struct { + hosts: []const Host, + vt: Host.VTable = .{}, + + pub fn init(hosts: []const Host) Fanout { + var f: Fanout = .{ .hosts = hosts }; + inline for (@typeInfo(Host.VTable).@"struct".fields) |field| { + for (hosts) |h| if (@field(h.vtable, field.name) != null) { + @field(f.vt, field.name) = @field(all, field.name); + break; + }; + } + return f; + } + + pub fn host(f: *const Fanout) Host { + return .{ .ctx = @ptrCast(@constCast(f)), .vtable = &f.vt }; + } + + fn self(ctx: ?*anyopaque) *const Fanout { + return @ptrCast(@alignCast(ctx.?)); + } + + /// A wrapper for every method, whether or not this fan-out advertises it. + /// Synthesized, so adding a method to `Host.VTable` needs no code here. + const all: Host.VTable = blk: { + var t: Host.VTable = .{}; + for (@typeInfo(Host.VTable).@"struct".fields) |field| { + @field(t, field.name) = fan(field.name); + } + break :blk t; + }; + + fn Method(comptime name: []const u8) std.builtin.Type.Fn { + const ptr = @typeInfo(@FieldType(Host.VTable, name)).optional.child; + return @typeInfo(@typeInfo(ptr).pointer.child).@"fn"; + } + + /// How to route a method, read off its own name. A method that is neither + /// is a COMPILE ERROR rather than a silent push, because the failure of a + /// forgotten pull is invisible in every unit test and obvious only to the + /// user: one Ctrl-V pasting twice. + fn isPull(comptime name: []const u8) bool { + if (std.mem.startsWith(u8, name, "pull_")) return true; + if (std.mem.startsWith(u8, name, "push_")) { + if (Method(name).return_type.? != void) @compileError("Host.VTable." ++ + name ++ " reaches every host, so it cannot return a value: whose answer would it be?"); + return false; + } + @compileError("Host.VTable." ++ name ++ " must be named push_… (every host gets it) " ++ + "or pull_… (exactly one host serves it, because there is one of whatever comes back)"); + } + + /// The walk, written once: `args` is everything after `ctx`. + fn dispatch(comptime name: []const u8, ctx: ?*anyopaque, args: anytype) Method(name).return_type.? { + for (self(ctx).hosts) |h| if (@field(h.vtable, name)) |fp| { + const answer = @call(.auto, fp, .{h.ctx} ++ args); + if (comptime isPull(name)) return answer; + }; + // `init` installs a wrapper only when some host has the method, so a + // pull always found one; a zero is the honest answer if that changes. + const R = Method(name).return_type.?; + if (comptime R != void) return std.mem.zeroes(R); + } + + /// One wrapper, built from the method's own signature: the parameter list + /// is the only part that cannot be derived, so there is one shape per + /// arity rather than one per method. + fn fan(comptime name: []const u8) @FieldType(Host.VTable, name) { + const m = Method(name); + const R = m.return_type.?; + const P = m.params; + return switch (P.len) { + 1 => struct { + fn w(c: ?*anyopaque) R { + return dispatch(name, c, .{}); + } + }.w, + 2 => struct { + fn w(c: ?*anyopaque, a: P[1].type.?) R { + return dispatch(name, c, .{a}); + } + }.w, + 3 => struct { + fn w(c: ?*anyopaque, a: P[1].type.?, b: P[2].type.?) R { + return dispatch(name, c, .{ a, b }); + } + }.w, + 4 => struct { + fn w(c: ?*anyopaque, a: P[1].type.?, b: P[2].type.?, d: P[3].type.?) R { + return dispatch(name, c, .{ a, b, d }); + } + }.w, + else => @compileError("Fanout has no wrapper shape for " ++ name ++ "'s arity"), + }; + } +}; diff --git a/src/look.zig b/src/look.zig index ece2ee0f..fbcc80a9 100644 --- a/src/look.zig +++ b/src/look.zig @@ -17,10 +17,9 @@ const libc = std.c; const pardes = @import("pardes.zig"); const config = @import("config.zig"); const pdf_enabled = @import("pardes_config").mupdf; -const embedded_sources = if (pardes.platform == .web) @import("embedded_sources") else struct { - pub const Source = struct { path: []const u8, contents: []const u8 }; - pub const all = [_]Source{}; -}; +/// The virtual filesystem, on every platform: the browser has only this, and +/// `run-isolated` chooses it (see `isolated` below). +const embedded_sources = @import("source_manifest.zig"); extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; extern "c" fn fork() c_int; @@ -557,7 +556,12 @@ fn findEmbeddedSource(path: []const u8, allow_root_suffix: bool) ?embedded_sourc return null; } -const platform_has_fs = switch (pardes.platform) { +/// Whether there is a real filesystem to reach at all. An ISOLATED build has +/// none by construction — the option is comptime, so every libc path below is +/// dead code the compiler removes rather than a branch that could be taken by +/// accident. The browser has never had one either, and both then read the same +/// embedded source. +const platform_has_fs = !pardes.isolated and switch (pardes.platform) { .tty, .gui, .macos => true, .web => false, }; diff --git a/src/macos.zig b/src/macos.zig index 2569e6b3..e54e013c 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -25,7 +25,6 @@ const posix = std.posix; const libc = std.c; const pardes = @import("pardes.zig"); const look = @import("look.zig"); -const temp_file = @import("temp_file.zig"); const shell_bin = @import("shell_bin.zig"); const message = @import("message.zig"); const nested = @import("nested.zig"); @@ -499,7 +498,9 @@ const State = struct { threaded: *std.Io.Threaded, io: std.Io, core: *pardes.Pardes, - arena: std.heap.ArenaAllocator, + /// False for the one effect drain inside pardes_init and nothing else: no + /// reader task exists yet, and the first theme file must land without a fade. + started: bool = false, runtime: Runtime, cells: []Cell = &.{}, /// Frozen canonical grid paired with an encoded change mask while a content or @@ -609,6 +610,9 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { var opts: pardes.Options = .{ .tty_only = true, + // The purpose-built 16 MiB stack-fallback buffer this host has always + // rendered out of; the core builds its per-frame Surface arena on it. + .frame_allocator = allocs.frame, .image_allocator = allocs.image, .pdf_allocator = allocs.pdf, .tree_sitter_allocator = allocs.tree_sitter, @@ -653,15 +657,15 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { .threaded = threaded, .io = io, .core = core, - .arena = .init(allocs.frame), .config_arena = config_arena, .prompt_rcs = prompt_rcs, .runtime = if (runtime) |r| r.* else .{}, }; const st = &state.?; - // the core's one way to ask about this host's ptys, pulled at the Exec that - // cares rather than pushed with the cwd above - core.tty_query = .{ .ctx = st, .taken = &ttyTakenAt }; + // Every capability this host has, including the tty pull the core makes at + // the Exec that cares rather than at the cwd read above. Assigned here and + // not left to `pump`, because the spawns below happen outside one. + core.host = hostFor(st); // The real grid, delivered as an EVENT and not as Options.cols/rows: the // core defers an integrated shell's greeting until this resize and OSC @@ -678,7 +682,8 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { // itself — chdir and execv, raw syscalls with nothing allocated between // fork and exec — not the thread count. Ordering it this way anyway keeps // the two backends readable side by side. - _ = drainEffects(st, false); + while (core.nextEffect()) |effect| core.perform(effect); + st.started = true; for (&st.ptys, 0..) |*slot, id| if (slot.*) |*pt| startReader(st, pt, @intCast(id)); // Last, because it is the one thing here that publishes this process to @@ -750,7 +755,6 @@ export fn pardes_deinit() void { if (st.previous_cells.len > 0) st.gpa.free(st.previous_cells); if (st.changed_cells.len > 0) st.gpa.free(st.changed_cells); if (st.images.len > 0) st.gpa.free(st.images); - st.arena.deinit(); st.core.deinit(); pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.stop(); @@ -870,7 +874,8 @@ export fn pardes_panel_animation_failed() void { st.core.abandonPanelAnimations(); } -/// Re-read the cwd of every shell that just spoke, and only those. +/// The core's per-frame poll: re-read the cwd of every shell that just spoke, +/// and only those. /// /// A pane's tag shows this and a relative `Look` resolves against it, so it has /// to follow the shell around rather than stay at the directory the pane was @@ -881,8 +886,9 @@ export fn pardes_panel_animation_failed() void { /// and a busy one costs one libproc call per pane per burst. /// /// Whether a shell's tty is still that shell is NOT refreshed here: nothing -/// draws it, so the core pulls it instead (see `ttyTakenAt`). -fn refreshCwds(st: *State) void { +/// draws it, so the core pulls it instead (see `ttyTaken`). +fn refreshCwds(ctx: ?*anyopaque) void { + const st = hostState(ctx); for (&st.cwd_stale, 0..) |*stale, id| { if (!stale.*) continue; stale.* = false; @@ -908,17 +914,9 @@ fn watchInitialGeneration(st: *State, pane: u8, path: []const u8) ?file_watch.Ge return null; } -fn setFileWatch(st: *State, pane: u8, on: bool) void { - if (!on) { - const generation = st.file_watches.stop(st.gpa, pane); - hostWatchFile(pane, generation, null, 0); - return; - } - const path = paneWatchPath(st.core, pane) orelse { - const generation = st.file_watches.stop(st.gpa, pane); - hostWatchFile(pane, generation, null, 0); - return; - }; +/// Start watching the path the core resolved for this pane. Turning a watch off +/// is the caller's business (`watchFile`); everything here is the start. +fn setFileWatch(st: *State, pane: u8, path: []const u8) void { const value = st.core.panes[pane] orelse return; const generation_on_disk = watchInitialGeneration(st, pane, path) orelse return; const generation = st.file_watches.replace( @@ -1091,32 +1089,13 @@ export fn pardes_watch_changed(pane: u8, generation: u32) void { if (st.file_watches.notify(pane, generation)) wake(st); } -/// The core's `tty_query`, asked only where a command line is about to be typed: -/// is a program holding this pane's tty instead of the prompt we forked? -/// `look.ttyTaken` answers `false` on darwin until it grows a libproc -/// implementation, so this host behaves exactly as it did — the wiring is here -/// so it cannot rot, and it costs nothing until then. -fn ttyTakenAt(ctx: ?*anyopaque, pane: usize) bool { - const st: *const State = @ptrCast(@alignCast(ctx orelse return false)); - const pt = st.ptys[pane] orelse return false; - return look.ttyTaken(pt.pid, pt.file.handle); -} - -/// Drain what the reader tasks collected into the core, then perform whatever -/// the core queued in response. Returns whether this tick did any IO. -/// -/// NOT a repaint signal, however tempting: the core changes the grid on its own -/// for a cursor move, a selection, a mode change and a scroll, none of which -/// queue an effect or read a pty, so all four return false here. The macOS host -/// learned that the expensive way — see the comment on pump() in -/// src/macos/Sources/AppDelegate.swift. -export fn pardes_tick() bool { - const st = &(state orelse return false); - // Cleared before the drain: a reader that pushes during this tick must be - // able to schedule the next one. - st.inbox.wake_pending.store(false, .release); +/// What arrived off the loop thread since the last tick: pty output, a reaped +/// shell, a nested `Look`, and the file-watch edges Swift debounced. Every one +/// of them carries borrowed bytes, so they go straight into `update` rather +/// than through the core's event queue. +fn drainInbox(st: *State) bool { var batch = st.inbox.take(); - var changed = batch.len > 0; + var did = batch.len > 0; for (batch.slice()) |msg| { defer msg.free(st.gpa); switch (msg) { @@ -1141,14 +1120,45 @@ export fn pardes_tick() bool { for (0..pardes.MAX_PANES) |pane| { const id: u8 = @intCast(pane); if (!st.file_watches.takeDirty(id)) continue; - if (reloadWatchedFile(st, id, true)) changed = true; + if (reloadWatchedFile(st, id, true)) did = true; } if (st.file_watches.takeDirty(theme_watch_pane)) { - if (reloadWatchedTheme(st, true)) changed = true; + if (reloadWatchedTheme(st, true)) did = true; } - refreshCwds(st); - if (drainEffects(st, true)) changed = true; - return changed; + return did; +} + +/// Hand the core what arrived off-thread, then perform whatever it queued in +/// response. Returns whether this tick had IO to do, which is what bounds the +/// app's "pump until quiet" drain loop. +/// +/// It deliberately does NOT render. AppKit wants to be TOLD the view is dirty +/// and to draw once per display refresh: a pty burst is a dozen wakeups and a +/// dozen ticks, and rendering inside each of them would encode eleven grids +/// nobody ever sees. The render is `pardes_frame`, which the draw callback +/// calls at display cadence — the coalescing this whole boundary is shaped +/// around, and what src/macos/pardes.h has always said pardes_frame is. +/// +/// NOT a repaint signal, however tempting: the core changes the grid on its own +/// for a cursor move, a selection, a mode change and a scroll, none of which +/// queue an effect or read a pty, so all four return false here. The macOS host +/// learned that the expensive way — see the comment on pump() in +/// src/macos/Sources/AppDelegate.swift. +export fn pardes_tick() bool { + const st = &(state orelse return false); + // Cleared before the drain: a reader that pushes during this tick must be + // able to schedule the next one. + st.inbox.wake_pending.store(false, .release); + var did = drainInbox(st); + // Straight to `perform`, not through `pump`: the effects are the IO half of + // a tick and the render is not. `core.host` was seated once at init and is + // this host for the life of the session, so both this loop and the + // `tty_taken` pull the next keystroke makes land here. + while (st.core.nextEffect()) |effect| { + did = true; + st.core.perform(effect); + } + return did; } /// Advance exactly one display-clock frame. Event pumps deliberately never @@ -1381,22 +1391,44 @@ export fn pardes_resize(cols_arg: u16, rows_arg: u16, cell_w: u16, cell_h: u16) // ---------------------------------------------------------------- frame out +/// Render one frame, and the only place this host renders: AppKit's draw +/// callback, which is the one call it coalesces. A burst of input or pty output +/// marks the view dirty many times and is drawn once, so however much work the +/// ticks above drained, the grid is encoded once per display refresh. +/// +/// It is the core's whole loop iteration — drain, perform, poll, render, +/// present — and it cannot block: `wait_input` is null, because AppKit +/// delivered the events before it called us and sleeping inside a run-loop +/// callback is a beachball. `present` copies the result into the flat buffers +/// the accessors below describe (presentFrame); returns their cell count, or 0 +/// if the render failed. export fn pardes_frame() u32 { const st = &(state orelse return 0); - _ = st.arena.reset(.retain_capacity); - // The accessors below must never describe a different frame than the count - // this returns, so a failure empties all of them together rather than - // leaving last frame's buffer behind a fresh cols/rows. + st.core.pump(hostFor(st)) catch |err| { + log.err("render failed: {t}", .{err}); + clearFrame(st); + return 0; + }; + return @intCast(st.frame_len); +} + +/// Everything the accessors below describe is emptied together, so a failure +/// can never leave last frame's buffer behind a fresh cols/rows. +fn clearFrame(st: *State) void { st.frame_len = 0; st.frame_cols = 0; st.frame_rows = 0; st.images_len = 0; st.panel_tracks_len = 0; st.panel_diff_len = 0; - const surface = st.core.render(st.arena.allocator()) catch |err| { - log.err("render failed: {t}", .{err}); - return 0; - }; +} + +/// Copy one rendered frame into the flat buffers the native renderer reads. +/// Core-owned Cell layout is never borrowed across the ABI, so the grid, the +/// panel diff, the attachments and the tracks are all encoded here. +fn presentFrame(ctx: ?*anyopaque, surface: *const pardes.Surface) void { + const st = hostState(ctx); + clearFrame(st); const count: usize = @as(usize, surface.cols) * surface.rows; if (count != st.cells.len) { if (count == 0) { @@ -1407,7 +1439,7 @@ export fn pardes_frame() u32 { st.gpa.alloc(Cell, count) else st.gpa.realloc(st.cells, count); - st.cells = resized catch return 0; + st.cells = resized catch return; } } st.frame_len = count; @@ -1417,7 +1449,6 @@ export fn pardes_frame() u32 { collectPanelDiff(st, surface, count); collectImages(st, surface); collectPanelTracks(st, surface); - return @intCast(count); } /// Flatten tracks into the C-visible order the shader composites them. Pane @@ -1709,147 +1740,191 @@ fn activeFilePath(st: *State) ?[]const u8 { return null; } -// ---------------------------------------------------------------- effects - -/// Perform the IO the core queued. `threads_ok` is false for the one drain -/// inside pardes_init, which runs before any reader task exists. +// ---------------------------------------------------------------- host seam + +/// What this host can do, for the core's own loop to call. What it deliberately +/// cannot: +/// * `wait_input` — AppKit delivered the events before it called us and owns +/// the sleep; blocking inside a run-loop callback is a beachball. +/// * `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. /// -/// ponytail: lsp and pipe still do no work here. Each wants real machinery — a +/// 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; -/// this main-thread drain owns the bytes, hash and shared text/PDF core event. -fn drainEffects(st: *State, threads_ok: bool) bool { +/// 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, + .push_spawn = spawnShell, + .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, +}; + +fn hostFor(st: *State) pardes.Host { + return .{ .ctx = st, .vtable = &vtable }; +} + +fn hostState(ctx: ?*anyopaque) *State { + return @ptrCast(@alignCast(ctx.?)); +} + +fn spawnShell(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { + const st = hostState(ctx); const core = st.core; - var did = false; - while (core.nextEffect()) |effect| { - did = true; - switch (effect) { - .spawn => |sp| { - // The core reuses pane ids and has no close effect, so a - // deleted pane's shell lives in its slot until a respawn lands - // here. Reap it: cancel joins the reader, and the generation - // bump makes its late bytes and eof unreadable. - reap(st, sp.pane); - st.gens[sp.pane] +%= 1; - const gen = st.gens[sp.pane]; - - const cwd = sp.cwd.slice(); - var cwd_buf: [256:0]u8 = undefined; - var cwd_z: ?[*:0]const u8 = null; - // <= because writing the sentinel slot of a [N:0]u8 is legal, - // and Effect's cwd buffer is exactly 256: `<` would silently - // drop a maximal path and start the shell wherever the app - // bundle was launched from instead. - 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 child = forkShell(core, sp.pane, &st.prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w); - st.ptys[sp.pane] = .{ - .file = child.file, - .pid = child.pid, - .gen = gen, - .reader = .{ .any_future = null, .result = {} }, - }; - // Report the pane's starting directory back to the core (tags); - // the slot needs no occupancy reset, nothing is remembered. - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd); - if (threads_ok) if (st.ptys[sp.pane]) |*pt| startReader(st, pt, sp.pane); - }, - .write => |w| { - if (st.ptys[w.pane]) |pt| writeFd(pt.file.handle, w.bytes.slice()); - }, - .resize_pty => |rs| { - if (st.ptys[rs.pane]) |pt| { - const ws: posix.winsize = .{ .row = rs.rows, .col = rs.cols, .xpixel = 0, .ypixel = 0 }; - _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws)); - } - }, - .open_link => |url| look.openLink(url.slice()), - .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); - // The directory source will observe our own close. Move its - // baseline first so that notification is a hash no-op instead - // of manufacturing an external reload and undo boundary. - st.file_watches.restampText( - sf.pane, - f.path, - std.hash.Wyhash.hash(0, f.content), - ); - // 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); - _ = libc.close(fd); - core.setLastDump(path); - }, - .dump_themes => |request| { - const config_dir = core.opts.config_dir orelse continue; - const out_dir = user_config.dumpThemes(st.io, st.gpa, config_dir, pardes.themes) catch |err| { - core.reportError(request.pane, "dump themes", err); - continue; - }; - defer st.gpa.free(out_dir); - var mbuf: [256]u8 = undefined; - core.setMessage(request.pane, message.stamp(&mbuf, "dumped themes", out_dir)); - }, - .set_clipboard => { - const cb = st.runtime.set_clipboard orelse continue; - const y = core.yank orelse continue; - cb(st.runtime.userdata, y.ptr, y.len); - }, - // The host answers with pardes_paste, which the AppDelegate calls - // straight back inside this call: NSPasteboard reads are - // synchronous, so the paste event lands here, mid-drain. That is - // safe and deliberate — pardes_paste only feeds core.update, and - // whatever that queues is picked up by this same loop rather than - // waiting a tick. A host with a null callback simply never pastes. - .read_clipboard => { - const cb = st.runtime.read_clipboard orelse continue; - cb(st.runtime.userdata); - }, - // An empty answer, immediately: the honest reply from a shell with - // no worker, and the only safe one. Tab after a `.` DIVERTS to the - // backend instead of indenting and indents late, when the answer - // comes back empty (lspResponse); drop the effect and lsp_wait - // stays armed, the retroactive indent never fires, and every - // dot-Tab for the rest of the session does nothing at all. - .lsp => |q| core.update(.{ .lsp_resp = .{ .id = q.id, .rows = "" } }), - .watch => |watch| setFileWatch(st, watch.pane, watch.on), - .theme_file => |request| setThemeFileWatch(st, request.generation, request.on, threads_ok), - .pipe => {}, - .quit => {}, - } + // The core reuses pane ids and has no close effect, so a deleted pane's + // shell lives in its slot until a respawn lands here. Reap it: cancel joins + // the reader, and the generation bump makes its late bytes and eof + // unreadable. + reap(st, pane); + st.gens[pane] +%= 1; + const gen = st.gens[pane]; + + var cwd_buf: [256:0]u8 = undefined; + var cwd_z: ?[*:0]const u8 = null; + // <= because writing the sentinel slot of a [N:0]u8 is legal, and Effect's + // cwd buffer is exactly 256: `<` would silently drop a maximal path and + // start the shell wherever the app bundle was launched from instead. + 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); } - return did; + const child = forkShell(core, pane, &st.prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w); + st.ptys[pane] = .{ + .file = child.file, + .pid = child.pid, + .gen = gen, + .reader = .{ .any_future = null, .result = {} }, + }; + // Report the pane's starting directory back to the core (tags); the slot + // needs no occupancy reset, nothing is remembered. + var lbuf: [1024]u8 = undefined; + if (look.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(pane, wd); + if (st.started) if (st.ptys[pane]) |*pt| startReader(st, pt, pane); +} + +fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { + const st = hostState(ctx); + if (st.ptys[pane]) |pt| writeFd(pt.file.handle, bytes); +} + +fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { + const st = hostState(ctx); + const pt = st.ptys[pane] orelse return; + const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws)); +} + +/// Asked only where a command line is about to be typed: is a program holding +/// this pane's tty instead of the prompt we forked? `look.ttyTaken` answers +/// `false` on darwin until it grows a libproc implementation, so this host +/// behaves exactly as it did — the wiring is here so it cannot rot, and it +/// costs nothing until then. +fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { + const st = hostState(ctx); + const pt = st.ptys[pane] orelse return false; + return look.ttyTaken(pt.pid, pt.file.handle); +} + +/// A file pane's save and a scrollback's both land here; the core has already +/// resolved which path and which bytes. +fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { + const st = hostState(ctx); + var pathbuf: [4096:0]u8 = undefined; + if (path.len >= pathbuf.len) return; + @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; + writeFd(fd, bytes); + _ = libc.close(fd); + // The directory source will observe our own close. Move its baseline first + // so that notification is a hash no-op instead of manufacturing an external + // reload and undo boundary. + st.file_watches.restampText(pane, path, std.hash.Wyhash.hash(0, bytes)); + // After the write, not beside it: every early return above is a save that + // did not happen and must not be reported as one. + var mbuf: [256]u8 = undefined; + st.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); +} + +fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { + const st = hostState(ctx); + var pbuf: [1024:0]u8 = undefined; + const path = pardes.dump.outPath(&pbuf) orelse return; + const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) return; + writeFd(fd, bytes); + _ = libc.close(fd); + st.core.setLastDump(path); +} + +fn watchFile(ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool) void { + const st = hostState(ctx); + // No path is a pane with nothing on disk to watch (an output buffer, an + // image), which is the same answer as being turned off. + if (!on or path.len == 0) { + const generation = st.file_watches.stop(st.gpa, pane); + hostWatchFile(pane, generation, null, 0); + return; + } + setFileWatch(st, pane, path); +} + +fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { + const st = hostState(ctx); + setThemeFileWatch(st, generation, on, st.started); +} + +fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { + const st = hostState(ctx); + const config_dir = st.core.opts.config_dir orelse return; + const out_dir = user_config.dumpThemes(st.io, st.gpa, config_dir, pardes.themes) catch |err| { + st.core.reportError(pane, "dump themes", err); + return; + }; + defer st.gpa.free(out_dir); + var mbuf: [256]u8 = undefined; + st.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir)); +} + +fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { + const st = hostState(ctx); + const cb = st.runtime.set_clipboard orelse return; + cb(st.runtime.userdata, text.ptr, text.len); +} + +/// The host answers with pardes_paste, which the AppDelegate calls straight +/// back inside this call: NSPasteboard reads are synchronous, so the paste +/// event lands mid-pump. That is safe and deliberate — pardes_paste only feeds +/// core.update, and whatever that queues is picked up by the same effect loop +/// rather than waiting a tick. A host with a null callback simply never pastes. +fn readClipboard(ctx: ?*anyopaque) void { + const st = hostState(ctx); + const cb = st.runtime.read_clipboard orelse return; + cb(st.runtime.userdata); +} + +fn openLink(_: ?*anyopaque, url: []const u8) void { + look.openLink(url); } // ---------------------------------------------------------------- workers @@ -2406,3 +2481,59 @@ test "the dial flings in proportion to the release, and not at all when placed" try std.testing.expect(travel(100_000) / rotation_notch_degrees < 12); try std.testing.expect(travel(100_000) / rotation_notch_degrees > 8); } + +// The loop, end to end, on the one machine that can run it: the core owns the +// iteration now, so the two things this file used to spell out by hand are +// exactly what a live session has to keep proving. A frame exists because the +// DRAW rendered one — ticks drain work and never render, which is what lets +// AppKit coalesce a burst into a single encoded grid — and elapsed animation +// time is spent only by the display clock, however many times the tick runs. +// +// It really boots: a shell is forked, an inbox drains, effects are performed +// through the vtable. Everything above it is the Swift app, which needs a Mac. +test "a live session renders on the draw and animates only on the display clock" { + try std.testing.expectEqual(@as(c_int, 0), pardes_init(null, 80, 24)); + defer pardes_deinit(); + const st = &state.?; + + // The spawn effect reached forkpty rather than the core's silent fallback: + // init performs its own drain, before any reader task exists. + try std.testing.expect(st.ptys[0] != null); + // A tick drains and performs. It publishes no frame, so ten of them in a + // pty burst cost one render and not ten. + _ = pardes_tick(); + _ = pardes_tick(); + try std.testing.expectEqual(@as(u16, 0), pardes_frame_cols()); + try std.testing.expect(pardes_frame_cells() == null); + + // The draw is what renders and presents. + try std.testing.expectEqual(@as(u32, 80 * 24), pardes_frame()); + try std.testing.expectEqual(@as(u16, 80), pardes_frame_cols()); + try std.testing.expectEqual(@as(u16, 24), pardes_frame_rows()); + try std.testing.expect(pardes_frame_cells() != null); + + // Two themes, so the second retarget is a real transition whatever the + // developer's config booted this session wearing. + for ([_][]const u8{ "Theme dark", "Theme acme" }) |command| { + pardes_command(command.ptr, command.len); + _ = pardes_tick(); + _ = pardes_frame(); + } + try std.testing.expect(pardes_animating()); + const step = st.core.chrome_animation.step; + // Input and pty pumps drain work and draws encode it; neither spends a + // frame, which is what keeps a burst of keys from collapsing a ten-frame + // fade into one. + _ = pardes_tick(); + _ = pardes_frame(); + _ = pardes_tick(); + _ = pardes_frame(); + try std.testing.expectEqual(step, st.core.chrome_animation.step); + try std.testing.expectEqual(@as(usize, 0), st.core.in_len); + // Only the display clock spends it, and exactly one frame per call. + try std.testing.expect(pardes_animation_tick()); + try std.testing.expectEqual(step + 1, st.core.chrome_animation.step); + _ = pardes_tick(); + _ = pardes_frame(); + try std.testing.expectEqual(step + 1, st.core.chrome_animation.step); +} diff --git a/src/output_pane.zig b/src/output_pane.zig index ecb6f957..a7763bd3 100644 --- a/src/output_pane.zig +++ b/src/output_pane.zig @@ -25,6 +25,7 @@ const runtime_config = @import("runtime_config.zig"); const effect_sources = @import("effect_sources.zig"); const Builtin = builtins.registry.Builtin(); const config = @import("config.zig"); +const build_options = @import("pardes_config"); const dump = @import("dump.zig"); const lsp = @import("lsp/lsp.zig"); const gui_shader_source_mode = effect_sources.guiShaderSourceMode(); @@ -472,6 +473,17 @@ pub fn openConfig(p: *Pardes, id: usize) !void { return openRead(p, id, .{ .cmd = .Config }, "", content); } +/// The version banner plus the embedded CHANGELOG, so an installed binary can +/// say what it is and what changed without a repository beside it. +pub fn openChangelog(p: *Pardes, id: usize) !void { + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + try out.writer.print("pardes {s}\n\n", .{build_options.version}); + try out.writer.writeAll(@embedFile("CHANGELOG.md")); + const content = try out.toOwnedSlice(); + return openRead(p, id, .{ .cmd = .Changelog }, "", content); +} + /// Print the implementation that this build actually uses for one effect. /// Sources are build inputs embedded as bytes, so this stays useful from an /// installed binary with no repository beside it. diff --git a/src/pardes.zig b/src/pardes.zig index 968b73fb..405cc3f2 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -46,6 +46,13 @@ pub const allocators = @import("allocators.zig"); pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); pub const lsp = @import("lsp/lsp.zig"); +/// The host seam: one struct of optional function pointers, with in-core +/// defaults for every method a host leaves null. See src/host.zig. +const host_mod = @import("host.zig"); +pub const Host = host_mod.Host; +pub const Fanout = host_mod.Fanout; +pub const Fallback = host_mod.Fallback; +pub const fallback_dump_path = host_mod.fallback_dump_path; /// Tracy's frame boundary, re-exported so a host that is not a shell — the /// fling benchmark — can delimit the same frames the tty loop delimits without /// reaching around the core for src/tracy.zig and its build options. A no-op @@ -55,6 +62,12 @@ pub const frameMark = tracy.frameMark; pub const Platform = enum { tty, gui, web, macos }; pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform)); +/// A build with no host but its display: the embedded source filesystem, the +/// in-process clipboard, silent ptys. Comptime, and its own option module +/// rather than a `pardes_config` field, because it is the one setting that +/// produces a SECOND executable from the same graph — see `run-isolated`. +pub const isolated = @import("pardes_isolation").isolated; + /// Frontends that draw their own text, and can therefore be told which face to /// wear. On the tty the font belongs to the terminal emulator and in the /// browser it belongs to the page, so there the Font builtins are not @@ -147,6 +160,11 @@ const dirty_marker = " *"; // parser recognizes them as defaults while new dumps carry an explicit tail. const legacy_pane_tail = " Del"; const legacy_file_pane_tail = " Save Del"; +// The defaults from the release before Newtty joined every tagline. Recognized +// so a dump written then upgrades instead of keeping the old word as a tail. +const prev_pane_tail = " New Del"; +const prev_file_pane_tail = " Save New Del"; +const prev_terminal_pane_tail = " New Del Filter"; // Builtins: executing the name (middle-click / Tab) runs it through the ONE // dispatcher (runBuiltin, reached from execute), no matter where the name @@ -570,7 +588,7 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { p.acknowledgeShell(0, "/bin/sh", false); const pane = p.panes[0].?; - try std.testing.expectEqualStrings(" New Del Filter", Pardes.curTail(pane)); + try std.testing.expectEqualStrings(" New Newtty Del Filter", Pardes.curTail(pane)); const tag_x = p.rects[0].x + config.GUTTER; const tag_y = p.rects[0].y; @@ -580,19 +598,22 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { // the rendered tag rather than assumed to be at its left edge. const rendered = try p.tagText(p.scratch.allocator(), pane); const new_x = tag_x + @as(u16, @intCast(std.mem.indexOf(u8, rendered, "New").?)) + 1; + var panes_before: usize = 0; + for (p.panes) |s| if (s != null) { + panes_before += 1; + }; p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = new_x, .row = tag_y } }); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = new_x, .row = tag_y } }); - var saw_new = false; while (p.nextEffect()) |effect| switch (effect) { - .new_file => |request| { - try std.testing.expectEqual(@as(u8, 0), request.pane); - try std.testing.expectEqual(pane.serial, request.serial); - saw_new = true; - }, .write => return error.NewFellThroughToPty, else => {}, }; - try std.testing.expect(saw_new); + var panes_after: usize = 0; + for (p.panes) |s| if (s != null) { + panes_after += 1; + }; + try std.testing.expectEqual(panes_before + 1, panes_after); + try std.testing.expect(p.panes[p.active].?.file.?.output != null); // the scratch // Argument-taking builtins use the same gesture path: a tag sweep is the // complete command line, and Theme consumes its tail without a PTY write. @@ -629,7 +650,6 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); sent_len += w.bytes.slice().len; }, - .new_file => return error.UnknownCommandRanNew, else => {}, }; try std.testing.expectEqualStrings("DefinitelyNotABuiltin\r", sent[0..sent_len]); @@ -684,7 +704,7 @@ test "an untouched tagline ends where its layout column's widest one does" { const f = p.layoutFindTerm(p.active).?; p.layoutInsert(f.col, f.idx + 1, below_id); p.splitBelow(p.active, below); - p.setCwd(below_id, "/a/deep/directory/whose/name/eats/the/right/pad/off/the/end/of/its/own/tagline"); + p.setCwd(below_id, "/a/deep/directory/whose/name/eats/the/right/pad/the/end/of/its/own/tagline"); p.sync(); while (p.nextEffect()) |_| {} @@ -696,7 +716,7 @@ test "an untouched tagline ends where its layout column's widest one does" { // path, which is the whole point (equal at tw - tag_right_pad would prove // nothing: that is where both sat before) try std.testing.expectEqual(below_tag.len, above_tag.len); - try std.testing.expectEqualStrings(" New Del Filter", above_tag[above_tag.len - " New Del Filter".len ..]); + try std.testing.expectEqualStrings(" New Newtty Del Filter", above_tag[above_tag.len - " New Newtty Del Filter".len ..]); try std.testing.expect(above_tag.len > @as(usize, p.rects[0].w) - config.GUTTER - config.tag_right_pad); try std.testing.expect(above_tag.len <= @as(usize, p.rects[0].w) - config.GUTTER); @@ -745,7 +765,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { }); p.restoreTail(terminal, terminal_old); try std.testing.expect(!terminal.tag_init); - try std.testing.expectEqualStrings(" New Del Filter", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" New Newtty Del Filter", Pardes.curTail(terminal)); // The immediately preceding release used the generic current default on // terminals. It upgrades too, including any saved layout padding. @@ -754,7 +774,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { }); p.restoreTail(terminal, terminal_previous); try std.testing.expect(!terminal.tag_init); - try std.testing.expectEqualStrings(" New Del Filter", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" New Newtty Del Filter", Pardes.curTail(terminal)); const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{ try p.tagPrefix(terminal), @@ -786,7 +806,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { .terminal = .{ .cwd = "/historical/cwd" }, }); try std.testing.expect(!terminal.tag_init); - try std.testing.expectEqualStrings(" New Del Filter", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" New Newtty Del Filter", Pardes.curTail(terminal)); // This happens to be the historical FILE default, but on a terminal it is // user-owned text and must not be swallowed by migration. @@ -808,7 +828,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { }); p.restoreTail(file, file_old); try std.testing.expect(!file.tag_init); - try std.testing.expectEqualStrings(" Save New Del", Pardes.curTail(file)); + try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(file)); const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{ try p.tagPrefix(file), @@ -832,7 +852,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { // with only `img PATH`. Their custom tails still migrate through the // pane-specific legacy-prefix recognizer. const image_doc = try image_pane.create(p, 1, "/tmp/legacy image.ppm", &.{}); - try std.testing.expectEqualStrings(" New Del", Pardes.curTail(image_doc)); + try std.testing.expectEqualStrings(" New Newtty Del", Pardes.curTail(image_doc)); p.restoreDumpTail(image_doc, .{ .kind = .image, .tag = "img /tmp/legacy image.ppm Keep Del", @@ -843,6 +863,54 @@ test "legacy default tag tails upgrade while custom tails remain owned" { try std.testing.expectEqualStrings(" Keep Del", image_doc.tagSlice()); } +test "Joincol folds the active column into its right neighbor, keeping its panes" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + const right = p.freeSlot().?; + _ = try p.newShell(right, ""); + try std.testing.expect(p.layoutSplitColumn(0, right, false)); + while (p.nextEffect()) |_| {} + try std.testing.expectEqual(@as(usize, 2), p.ncol); + + p.active = 0; // the left column is current + p.joinCol(); + try std.testing.expectEqual(@as(usize, 1), p.ncol); + const lf = p.layoutFindTerm(0) orelse return error.LostLeftPane; + const rf = p.layoutFindTerm(right) orelse return error.LostRightPane; + try std.testing.expectEqual(lf.col, rf.col); + + p.joinCol(); // no right neighbor left: inert + try std.testing.expectEqual(@as(usize, 1), p.ncol); +} + +test "Newtty spawns a raw shell in the caller's directory" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + p.setCwd(0, "/tmp/newtty-dir"); + + p.spawnTty(0); + const free = p.active; + try std.testing.expect(free != 0); + const nt = p.panes[free] orelse return error.NoNewPane; + try std.testing.expect(nt.isTerminal()); + try std.testing.expect(nt.mode == .tty); + + var found = false; + while (p.nextEffect()) |effect| switch (effect) { + .spawn => |s| if (@as(usize, s.pane) == free) { + try std.testing.expectEqualStrings("/tmp/newtty-dir", s.cwd.slice()); + found = true; + }, + else => {}, + }; + try std.testing.expect(found); +} + test "an unsaved file marker sits between its path and builtins until Save" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); @@ -909,11 +977,13 @@ const FakeTtyQuery = struct { taken: [MAX_PANES]bool = @splat(false), asked: usize = 0, + const vtable: Host.VTable = .{ .pull_tty_taken = answer }; + fn install(f: *FakeTtyQuery, p: *Pardes) void { - p.tty_query = .{ .ctx = f, .taken = &answer }; + p.host = .{ .ctx = f, .vtable = &vtable }; } - fn answer(ctx: ?*anyopaque, pane: usize) bool { + fn answer(ctx: ?*anyopaque, pane: u8) bool { const f: *FakeTtyQuery = @ptrCast(@alignCast(ctx.?)); f.asked += 1; return f.taken[pane]; @@ -1186,145 +1256,201 @@ test "the host is asked about a tty only where a command line is about to go" { try std.testing.expectEqual(@as(usize, 2), host.asked); } -test "New completes as an empty watched file in the calling column and focuses it" { +test "New opens an empty scratch below the caller, inheriting its directory" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); - while (p.nextEffect()) |_| {} // initial shell spawns + while (p.nextEffect()) |_| {} const source: usize = 2; // the right column; active starts in the left + p.setCwd(source, "/tmp/pardes-scratch-dir"); const source_col = p.layoutFindTerm(source).?.col; + try std.testing.expect(p.executeBuiltinLine(source, "New")); - var request: ?Effect = null; - while (p.nextEffect()) |effect| switch (effect) { - .new_file => request = effect, - else => {}, - }; - const new_effect = request orelse return error.MissingNewFileEffect; - const req = new_effect.new_file; - try std.testing.expectEqual(@as(u8, @intCast(source)), req.pane); - try std.testing.expectEqual(p.panes[source].?.serial, req.serial); - - // Focus changes while the shell is doing IO. Placement must still use the - // effect's calling-pane token, never this later-active left column. - p.active = 0; - try std.testing.expect(p.openNewFile(req.pane, req.serial, "/tmp/pardes-test-new-a")); + // no shell IO: the scratch is created in-core, focused, below the caller + while (p.nextEffect()) |_| {} const id = p.active; - try std.testing.expect(id != 0 and id != source); + try std.testing.expect(id != source); try std.testing.expectEqual(source_col, p.layoutFindTerm(id).?.col); - const sf = p.layoutFindTerm(source).?; - try std.testing.expectEqual(id, p.col_terms[sf.col][sf.idx + 1]); + const np = p.panes[id].?; + try std.testing.expect(np.file.?.output != null); // an output buffer, empty + try std.testing.expectEqual(@as(usize, 0), np.file.?.content.len); + // its directory is a LIVE link to the opener, not a snapshot taken at New + try std.testing.expectEqualStrings("/tmp/pardes-scratch-dir", Pardes.paneDir(np)); + p.setCwd(source, "/tmp/pardes-moved"); + try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np)); +} - const file = p.panes[id].?.file orelse return error.NewIsNotAFile; - try std.testing.expectEqualStrings("/tmp/pardes-test-new-a", file.path); - try std.testing.expectEqual(@as(usize, 0), file.content.len); - try std.testing.expect(file.output == null); - try std.testing.expect(!p.panes[id].?.isTerminal()); +test "Save on a scratch asks for a path in its inherited dir and makes it a file" { + const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} - var watched = false; - while (p.nextEffect()) |effect| switch (effect) { - .watch => |w| if (w.pane == id and w.on) { - watched = true; - }, - else => {}, - }; - try std.testing.expect(watched); + const source: usize = 2; + p.setCwd(source, "/tmp/pardes-save-dir"); + try std.testing.expect(p.executeBuiltinLine(source, "New")); + const id = p.active; + const np = p.panes[id].?; + p.sync(); // the frame boundary that gives the new pane its geometry + while (p.nextEffect()) |_| {} - // It is an ordinary file from here on: Save uses the normal file effect, - // and the dump contains its path and empty content. + // Save on a scratch arms a PATH input, prefilled with the inherited dir try std.testing.expect(p.executeBuiltinLine(id, "Save")); + try std.testing.expect(np.hasSavePrompt()); + try std.testing.expect(std.mem.endsWith(u8, np.tagSlice(), " Save /tmp/pardes-save-dir/")); + + // typing the filename and submitting converts it into an ordinary file + try std.testing.expect(np.appendTag("note.txt")); + p.submitSave(id); + try std.testing.expect(np.file.?.output == null); + try std.testing.expectEqualStrings("/tmp/pardes-save-dir/note.txt", np.file.?.path); var saved = false; while (p.nextEffect()) |effect| switch (effect) { - .save_file => |s| if (s.pane == id) { + .save_file => |sf| if (@as(usize, sf.pane) == id) { saved = true; }, else => {}, }; try std.testing.expect(saved); - try p.dumpState(); - const state = try dump.readZon(std.testing.allocator, p.dump_out.?, "New-test"); - defer dump.free(std.testing.allocator, state); - var dumped = false; - for (state.panes) |dp| if (dp.file) |df| { - if (std.mem.eql(u8, df.path, "/tmp/pardes-test-new-a")) { - try std.testing.expectEqual(@as(usize, 0), df.content.len); - dumped = true; - } - }; - try std.testing.expect(dumped); - while (p.nextEffect()) |_| {} // write_dump - - // Del closes and unwatches, but there is intentionally no unlink effect: - // adopted temporary documents preserve user data and dump restorability. - try std.testing.expect(p.executeBuiltinLine(id, "Del")); - try std.testing.expect(p.panes[id] == null); - var unwatched = false; - while (p.nextEffect()) |effect| switch (effect) { - .watch => |w| if (w.pane == id and !w.on) { - unwatched = true; - }, - else => {}, - }; - try std.testing.expect(unwatched); } -test "New requests repeat, stay anchored, and reject stale or full completions" { - const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); +test "Save on a terminal writes its plaintext scrollback and stays a terminal" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 8 }); defer p.deinit(); while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + p.setCwd(0, "/tmp/pardes-tty-save"); + p.update(.{ .output = .{ .pane = 0, .bytes = "hello scrollback\r\n" } }); + while (p.nextEffect()) |_| {} - const source: usize = 2; - const source_col = p.layoutFindTerm(source).?.col; - try std.testing.expect(p.executeBuiltinLine(source, "New")); - try std.testing.expect(p.executeBuiltinLine(source, "New")); - var requests: [2]Effect = undefined; - var n: usize = 0; + try std.testing.expect(p.executeBuiltinLine(0, "Save")); + try std.testing.expect(pane.hasSavePrompt()); + try std.testing.expect(pane.appendTag("log.txt")); + p.submitSave(0); + + // the pane is untouched: still a terminal, no file behind it + try std.testing.expect(pane.isTerminal()); + try std.testing.expect(pane.file == null); + const st = p.save_text orelse return error.MissingSaveText; + try std.testing.expectEqualStrings("/tmp/pardes-tty-save/log.txt", st.path); + try std.testing.expect(std.mem.indexOf(u8, st.content, "hello scrollback") != null); + var asked = false; while (p.nextEffect()) |effect| switch (effect) { - .new_file => if (n < requests.len) { - requests[n] = effect; - n += 1; - }, + .save_text => asked = true, else => {}, }; - try std.testing.expectEqual(requests.len, n); - - p.active = 0; - try std.testing.expect(p.openNewFile(requests[0].new_file.pane, requests[0].new_file.serial, "/tmp/pardes-test-new-1")); - const first = p.active; - p.active = 1; // change focus again before the second completion - try std.testing.expect(p.openNewFile(requests[1].new_file.pane, requests[1].new_file.serial, "/tmp/pardes-test-new-2")); - const second = p.active; - try std.testing.expect(first != second); - try std.testing.expectEqual(source_col, p.layoutFindTerm(first).?.col); - try std.testing.expectEqual(source_col, p.layoutFindTerm(second).?.col); - try std.testing.expect(!std.mem.eql(u8, p.panes[first].?.file.?.path, p.panes[second].?.file.?.path)); + try std.testing.expect(asked); +} + +test "a host with no methods at all is a complete in-process pardes" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 }); + defer p.deinit(); while (p.nextEffect()) |_| {} - // A reused pane slot cannot answer an old request, and a malformed shell - // answer cannot manufacture a nameless file. - const old_serial = p.panes[source].?.serial; - p.panes[source].?.serial +%= 1; - try std.testing.expect(!p.openNewFile(@intCast(source), old_serial, "/tmp/pardes-test-stale")); - try std.testing.expect(!p.openNewFile(@intCast(source), p.panes[source].?.serial, "")); + // A pump against the empty host renders a frame and blocks on nothing. + try p.pump(.{}); + try std.testing.expect(!p.quit); + + // the absent child is SILENT: the bytes are dropped, and nothing appears + // on the pane's screen to suggest a program answered + const before = p.panes[0].?.vt.screens.active.cursor.y; + p.perform(.{ .write = .{ .pane = 0, .bytes = .from("ls\r") } }); + try std.testing.expectEqual(before, p.panes[0].?.vt.screens.active.cursor.y); + + // the clipboard round-trips through the in-process one + p.yank = try gpa.dupe(u8, "copied"); + p.perform(.set_clipboard); + try std.testing.expectEqualStrings("copied", p.fallback.clipboard.items); + + // ...and a save with no filesystem lands in the virtual one + const doc = try p.hxOpenFileContent("body\n"); + var doc_id: u8 = 0; + for (p.panes, 0..) |slot, i| if (slot == doc) { + doc_id = @intCast(i); + }; + p.perform(.{ .save_file = .{ .pane = doc_id } }); + try std.testing.expectEqualStrings("body\n", p.fallback.files.get(doc.file.?.path).?); + + // a language backend nobody supplied answers nothing rather than hanging + p.perform(.{ .lsp = .{ .id = 7, .kind = .hover, .pane = doc_id, .offset = 0, .arg = .{} } }); - // Exhaust every remaining slot after a request was issued: completion - // rejects it, which tells the shell to unlink the just-created candidate. - const live_serial = p.panes[source].?.serial; - for (0..MAX_PANES) |id| { - if (p.panes[id] == null) _ = try p.newDocPane(id); + p.perform(.quit); + try std.testing.expect(p.quit); +} + +const RecordHost = struct { + gpa: std.mem.Allocator, + writes: std.ArrayListUnmanaged(u8) = .empty, + /// What this host would answer a clipboard read with, and whether it was + /// ever asked — a pull must reach exactly one host. + clipboard: []const u8 = "", + asked: usize = 0, + core: ?*Pardes = null, + + const vt: Host.VTable = .{ .push_pty_write = ptyWrite, .pull_read_clipboard = readClipboard }; + + fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { + _ = pane; + const self: *RecordHost = @ptrCast(@alignCast(ctx.?)); + self.writes.appendSlice(self.gpa, bytes) catch {}; } - try std.testing.expect(!p.openNewFile(@intCast(source), live_serial, "/tmp/pardes-test-full")); - try std.testing.expect(p.executeBuiltinLine(source, "New")); - var emitted_when_full = false; - while (p.nextEffect()) |effect| switch (effect) { - .new_file => emitted_when_full = true, - else => {}, + + fn readClipboard(ctx: ?*anyopaque) void { + const self: *RecordHost = @ptrCast(@alignCast(ctx.?)); + self.asked += 1; + self.core.?.update(.{ .paste = self.clipboard }); + } + + fn host(self: *RecordHost) Host { + return .{ .ctx = self, .vtable = &vt }; + } +}; + +test "a fan-out host reaches every wrapped host, each with its own state" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 }); + defer p.deinit(); + + var a: RecordHost = .{ .gpa = gpa }; + defer a.writes.deinit(gpa); + var b: RecordHost = .{ .gpa = gpa }; + defer b.writes.deinit(gpa); + + const wrapped = [_]Host{ a.host(), b.host() }; + var fan: Fanout = .init(&wrapped); + p.host = fan.host(); + p.perform(.{ .write = .{ .pane = 0, .bytes = .from("echo hi\r") } }); + + try std.testing.expectEqualStrings("echo hi\r", a.writes.items); + try std.testing.expectEqualStrings("echo hi\r", b.writes.items); + + // A PULL reaches ONE host, and the name is what says so. Fanned out, both + // would answer and the core would paste the clipboard twice for one Ctrl-V. + a.core = p; + b.core = p; + a.clipboard = "from-a"; + b.clipboard = "from-b"; + const doc = p.panes[p.active].?; + doc.mode = .normal; + p.perform(.read_clipboard); + try std.testing.expectEqual(@as(usize, 1), a.asked); + try std.testing.expectEqual(@as(usize, 0), b.asked); + + // a method NO wrapped host implements still falls back per-method + p.perform(.{ .open_link = .from("https://example.invalid") }); + try std.testing.expectEqualStrings("https://example.invalid", p.fallback.link.items); +} + +test "a builtin that needs a pane reports capacity failure when every slot is full" { + const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + for (0..MAX_PANES) |id| if (p.panes[id] == null) { + _ = try p.newDocPane(id); }; - try std.testing.expect(!emitted_when_full); - // Builtins that need another pane surface capacity/allocation failures on - // the asking pane instead of silently doing nothing. const help = std.meta.stringToEnum(Builtin, "Help") orelse return error.MissingHelpBuiltin; - p.runBuiltin(help, source, "", null); - try std.testing.expect(std.mem.indexOf(u8, p.panes[source].?.msg[0..p.panes[source].?.msg_len], "help: NoPaneSlots") != null); + p.runBuiltin(help, 0, "", null); + try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: NoPaneSlots") != null); } test "Msg writes the transient row by hand, bare or with text, and input ends it" { @@ -1701,10 +1827,12 @@ pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: { // not a derivation — see it there for why each word is in or out. This is the // check that a rename cannot silently rot it. comptime { - @setEvalBranchQuota(20000); // the tokenizer walks the string one branch per byte, stringToEnum builds a map per name, and both sit behind the decl walk that folds Builtin + @setEvalBranchQuota(20000); // one branch per string byte, behind the decl walk that folds Builtin var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' '); - while (it.next()) |w| { - if (std.meta.stringToEnum(Builtin, w) == null) @compileError("topbar word is not a builtin: " ++ w); + words: while (it.next()) |w| { + for (@typeInfo(Builtin).@"enum".fields) |f| + if (std.mem.eql(u8, f.name, w)) continue :words; + @compileError("topbar word is not a builtin: " ++ w); } } @@ -2943,22 +3071,6 @@ pub const Event = union(enum) { tick, }; -/// The one thing the core PULLS from the host instead of being pushed or -/// emitting an effect: is a pane's terminal still the prompt the host forked, -/// or has a program (vim, a pager, an agent) taken its tty? -/// -/// An effect cannot answer it — `execute` has to choose a destination inside -/// the update that asked, and an effect is drained after. A pushed fact could, -/// and did, but only by having every host probe every pane's processes on every -/// frame to answer a question that is asked when a human middle-clicks a word. -/// So the host leaves a way to be asked, and the core asks where it decides -/// (see `Pardes.takesCommandLine`). `ctx` is the host's own pty table; the -/// answer must not re-enter the core. -pub const TtyQuery = struct { - ctx: ?*anyopaque, - taken: *const fn (ctx: ?*anyopaque, pane: usize) bool, -}; - /// IO the core wants done. Payloads are inline (fixed buffers): effects are /// queued values with no lifetime ties back into the core. pub const Effect = union(enum) { @@ -2969,11 +3081,10 @@ pub const Effect = union(enum) { /// write this pane's file content to its path; the shell reads both off /// the core (content is unbounded, effects are fixed-size values) save_file: struct { pane: u8 }, - /// Create an empty, uniquely-named temporary file. `pane` + `serial` - /// identify the pane/column that issued New even if focus moves before the - /// shell completes the IO. The shell calls openNewFile with the path and - /// unlinks it itself when that completion rejects the request. - new_file: struct { pane: u8, serial: u32 }, + /// write plaintext to a path WITHOUT converting the pane (a terminal's + /// scrollback save): the bytes and path are stashed in core.save_text, + /// read off the core exactly like save_file reads a file pane. + save_text: struct { pane: u8 }, /// a serialized state dump is ready in core.dump_out; write it to the /// path dump.outPath resolves (acme-style: another instance loads it /// with -l, or the Restore builtin loads it into this one) @@ -3155,9 +3266,14 @@ const Prompt = union(enum) { none, search: u16, pipe: u16, + /// Save on a scratch buffer or a terminal: the tail is a path to write to. + save: u16, }; pub const Pane = struct { + /// A pane's working directory. `.inherited` is a live `*Pane` link kept + /// valid by deferred teardown (see PaneAllocator) + reapPanes' fixup. + pub const Cwd = union(enum) { none, inherited: *Pane, owned: []const u8 }; vt: ghostty_vt.Terminal, stream: ghostty_vt.TerminalStream, /// The same allocator Pardes holds. A pane already owns heap (its content, @@ -3263,10 +3379,11 @@ pub const Pane = struct { ed_undo_len: usize = 0, ed_redo: [term_pane.history_max]term_pane.Snapshot = undefined, ed_redo_len: usize = 0, - /// live working directory, shell-reported (setCwd); shown in the tag and - /// the base for look-resolution of relative paths. - cwd: [1024]u8 = undefined, - cwd_len: u16 = 0, + /// Working directory: shell-reported bytes (.owned, in cwd_buf), a live + /// link to the pane it was opened from (.inherited), or unknown (.none). + /// The inherited pointer is kept valid by deferred pane teardown + fixup. + cwd: Cwd = .none, + cwd_buf: [1024]u8 = undefined, /// modal cursor, at ABSOLUTE body rows of the pane's SURFACE (file lines, /// or the terminal's shell rows with its edit buffer standing in). Tracks /// the shell cursor until pinned by a click or a key. @@ -3334,7 +3451,7 @@ pub const Pane = struct { fn promptAt(p: *const Pane) ?u16 { return switch (p.prompt) { .none => null, - .search, .pipe => |at| at, + .search, .pipe, .save => |at| at, }; } @@ -3352,6 +3469,13 @@ pub const Pane = struct { }; } + fn hasSavePrompt(p: *const Pane) bool { + return switch (p.prompt) { + .save => true, + else => false, + }; + } + fn appendTag(p: *Pane, text: []const u8) bool { if (text.len > p.tag_tail.len - p.tag_tail_len) return false; @memcpy(p.tag_tail[p.tag_tail_len..][0..text.len], text); @@ -3374,7 +3498,18 @@ pub const Pane = struct { } pub fn cwdSlice(p: *const Pane) []const u8 { - return p.cwd[0..p.cwd_len]; + return switch (p.cwd) { + .none => "", + .owned => |dir| dir, + .inherited => |src| src.cwdSlice(), + }; + } + + /// Shell-reported directory: own the bytes in cwd_buf. + pub fn setOwnedCwd(pane: *Pane, dir: []const u8) void { + const n = @min(dir.len, pane.cwd_buf.len); + @memcpy(pane.cwd_buf[0..n], dir[0..n]); + pane.cwd = .{ .owned = pane.cwd_buf[0..n] }; } pub fn isTerminal(pane: *const Pane) bool { @@ -3382,6 +3517,18 @@ pub const Pane = struct { return pane.file == null and pane.image == null and no_pdf; } + /// The one coloring choice keyed on what a pane IS, so the highlight + /// producer (refreshHighlights) and the render pass agree on the algorithm. + pub const ColorAlgo = enum { none, tty, source, diff }; + pub fn colorAlgo(pane: *const Pane) ColorAlgo { + if (pane.isTerminal()) return .tty; + if (pane.file) |f| { + if (std.mem.endsWith(u8, f.path, ".diff") or std.mem.endsWith(u8, f.path, ".patch")) return .diff; + return .source; + } + return .none; + } + pub fn pdfPath(pane: *const Pane) ?[]const u8 { if (comptime pdf_enabled) if (pane.pdf) |pv| return pv.path; return null; @@ -4689,6 +4836,9 @@ pub const Options = struct { image_allocator: ?std.mem.Allocator = null, pdf_allocator: ?std.mem.Allocator = null, tree_sitter_allocator: ?std.mem.Allocator = null, + /// Where each frame's Surface text is built. Hosts pass a purpose-built + /// stack-fallback arena; null means the general allocator. + frame_allocator: ?std.mem.Allocator = null, /// Initial grid. Shell contract: for LIVE sessions leave these at the /// defaults and deliver the real size as the first resize EVENT — the core /// defers an integrated shell's greeting until after a resize AND its OSC @@ -4742,6 +4892,32 @@ pub const Haptic = enum { none, exec, look }; /// the field, so no other shell carries it. const HapticSlot = if (platform == .macos) Haptic else void; +/// Deferred pane teardown. A dropped pane's memory outlives the frame it died +/// in: it is doomed here and actually torn down one full frame later, so any +/// `*Pane` captured that frame — an effect, another pane's inherited cwd — +/// stays valid long enough for the per-frame fixup pass to repair it. `fresh` +/// holds this frame's drops, `stale` the previous frame's, freed next reap. +pub const PaneAllocator = struct { + fresh: [2 * MAX_PANES]?*Pane = @splat(null), + stale: [2 * MAX_PANES]?*Pane = @splat(null), + + fn doom(a: *PaneAllocator, pane: *Pane) void { + for (&a.fresh) |*slot| if (slot.* == null) { + slot.* = pane; + return; + }; + // One update runs one builtin, and the widest drop is a whole column; + // 2*MAX_PANES cannot fill in a single frame. + unreachable; + } + + fn isDoomed(a: *const PaneAllocator, pane: *const Pane) bool { + for (a.fresh) |s| if (s == pane) return true; + for (a.stale) |s| if (s == pane) return true; + return false; + } +}; + pub const Pardes = struct { gpa: std.mem.Allocator, image_gpa: std.mem.Allocator, @@ -4754,6 +4930,8 @@ pub const Pardes = struct { resize_count: usize = 0, panes: [MAX_PANES]?*Pane = @splat(null), + /// Deferred teardown of dropped panes (see PaneAllocator). + pane_alloc: PaneAllocator = .{}, // layout: columns own x by weight; panes own y by vweight within a column. ncol: usize = 0, /// Fixed-point horizontal proportions. Integer sums make splitting W @@ -4879,11 +5057,6 @@ pub const Pardes = struct { /// terminal, GPU textures in SDL). Image panes dynamically fall back to /// the PETSCII matcher without it. native_images: bool = false, - /// How the core asks the host the one question about a pane it cannot - /// answer itself — see `takesCommandLine`, the only caller. Installed once - /// by the native hosts (and again after a dump replay builds a second - /// core); left null by the web shell, which has no processes. - tty_query: ?TtyQuery = null, quit: bool = false, /// The Look or Exec that has happened and not yet been felt, taken by the /// shell once per pump (takeHaptic). A pulse, not a queue: five Execs @@ -4982,6 +5155,19 @@ pub const Pardes = struct { effects_head: usize = 0, effects_len: usize = 0, + /// WHO SERVES THIS CORE. Every method optional; a null one is answered by + /// `fallback` below, so a `Host{}` is a complete in-process pardes. + host: Host = .{}, + /// The in-program answers behind every unimplemented host method. Per + /// instance, so several cores behind one fan-out host stay independent. + fallback: Fallback, + /// Input the loop has not consumed yet. Single-threaded: a host's worker + /// threads keep their own thread-safe inbox and post from the loop thread, + /// which is what keeps this ring lock-free. + in_q: [64]Event = undefined, + in_head: usize = 0, + in_len: usize = 0, + /// helix's DEFAULT register (gpa-owned): what `y`/`d`/`c` write and /// `p`/`P`/`R` read. Never the system clipboard — `SPC y`/`SPC p` are the /// two commands that cross that line. @@ -4992,6 +5178,9 @@ pub const Pardes = struct { clip_pending: ?ClipRequest = null, /// the last serialized dump (gpa-owned), read by the write_dump effect dump_out: ?[]u8 = null, + /// A terminal scrollback save awaiting the host: bytes + path, gpa-owned, + /// read by the save_text effect and cleared once written. At most one. + save_text: ?struct { content: []const u8, path: []u8 } = null, /// where the shell wrote the last dump (shell reports back after /// write_dump); shown in the topbar as `Restore ` last_dump: ?[]const u8 = null, @@ -5003,6 +5192,9 @@ pub const Pardes = struct { surface: Surface = .{}, /// per-update scratch (paneCursorLines, selection text); reset each update scratch: std.heap.ArenaAllocator, + /// The arena each frame's Surface text is built in, reset by `pump`. Hosts + /// that still own their loop pass their own arena to `render` instead. + frame_arena: std.heap.ArenaAllocator, /// the terminal motion surface, memoized against the pane it was built /// for — see term_pane.RowsCache for the lifetime rule shell_rows: term_pane.RowsCache = .{}, @@ -5021,6 +5213,8 @@ pub const Pardes = struct { .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa), + .frame_arena = .init(opts.frame_allocator orelse gpa), + .fallback = .{ .gpa = gpa }, }; errdefer p.deinit(); if (opts.file) |path| { @@ -5078,18 +5272,32 @@ pub const Pardes = struct { pub fn deinit(p: *Pardes) void { p.cancelLookHover(); for (&p.panes) |*slot| if (slot.*) |pane| { - p.deinitPane(pane); + p.teardownPane(pane); + slot.* = null; + }; + for (&p.pane_alloc.stale) |*slot| if (slot.*) |pane| { + p.teardownPane(pane); + slot.* = null; + }; + for (&p.pane_alloc.fresh) |*slot| if (slot.*) |pane| { + p.teardownPane(pane); slot.* = null; }; const gpa = p.gpa; if (p.yank) |y| gpa.free(y); if (p.dump_out) |d| gpa.free(d); + if (p.save_text) |s| { + gpa.free(s.content); + gpa.free(s.path); + } + p.fallback.deinit(); if (p.last_dump) |d| gpa.free(d); if (p.custom_theme) |theme_value| std.zon.parse.free(gpa, theme_value); if (p.chord_arg) |a| gpa.free(a); if (p.pipe_wait) |*wait| wait.deinit(gpa); p.shell_rows.reset(gpa); p.scratch.deinit(); + p.frame_arena.deinit(); gpa.free(p.surface.cells); if (p.presented_cells.len > 0) gpa.free(p.presented_cells); if (p.panel_cell_diffs.len > 0) gpa.free(p.panel_cell_diffs); @@ -5120,7 +5328,24 @@ pub const Pardes = struct { return config.topbar_str; } + /// Drop a pane: its slot is freed for reuse now, but the allocation is + /// doomed and actually torn down a frame later (reapPanes), so pointers to + /// it survive the frame. Callers still null `panes[id]` themselves. pub fn deinitPane(p: *Pardes, pane: *Pane) void { + // Logical close, while the pane is still installed: stop the file/PDF + // watch keyed to this slot and drop any hover it owns. The heap + // teardown is deferred (reapPanes) so pointers to it survive the frame. + const watched = (if (pane.file) |f| f.output == null else false) or hasPdf(pane); + if (watched) for (p.panes, 0..) |slot, id| { + if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); + }; + if (p.lookHoverPane()) |h| if (h < p.panes.len and p.panes[h] == pane) p.cancelLookHover(); + p.pane_alloc.doom(pane); + } + + /// The real teardown, run by reapPanes once the pane has been doomed for a + /// full frame (or at deinit). Frees every heap payload the pane owns. + fn teardownPane(p: *Pardes, pane: *Pane) void { if (p.lookHoverPane()) |hovered| { if (hovered < p.panes.len and p.panes[hovered] == pane) p.cancelLookHover(); @@ -5140,6 +5365,32 @@ pub const Pardes = struct { p.gpa.destroy(pane); } + /// Once a frame: repair live panes' pointers to doomed panes, then free the + /// panes doomed a full frame ago. Fixup runs first so no pointer outlives + /// the memory. `stale` (last frame's dead) is freed; `fresh` becomes stale. + fn reapPanes(p: *Pardes) void { + for (p.panes) |slot| if (slot) |pane| p.fixupPaneRefs(pane); + for (&p.pane_alloc.stale) |*slot| if (slot.*) |pane| { + p.teardownPane(pane); + slot.* = null; + }; + p.pane_alloc.stale = p.pane_alloc.fresh; + p.pane_alloc.fresh = @splat(null); + } + + /// Visit each Pane field that holds a pane pointer; when it names a doomed + /// pane, snapshot that pane's directory into our own bytes so the link can + /// die with it. One field carries a pointer today (the inherited cwd); the + /// comptime walk keeps that honest as fields come and go. + fn fixupPaneRefs(p: *Pardes, pane: *Pane) void { + inline for (@typeInfo(Pane).@"struct".fields) |f| { + if (f.type == Pane.Cwd) switch (@field(pane, f.name)) { + .inherited => |src| if (p.pane_alloc.isDoomed(src)) pane.setOwnedCwd(paneDir(src)), + else => {}, + }; + } + } + /// Put a fully constructed pane in a free slot and give it the monotonic /// identity every slot-reuse guard relies on. Pane kinds construct their /// own payloads; this registration rule remains a core invariant. @@ -5181,6 +5432,38 @@ pub const Pardes = struct { return pane; } + /// An empty output buffer opened FROM `from_id`: no file behind it, its cwd + /// a live link to the opener so Save can prefill that directory. New and + /// Newcol place it (below, or in a column). Installed in slot `free`. + fn newScratch(p: *Pardes, from_id: usize, free: usize) !*Pane { + const src = p.panes[from_id] orelse return error.MissingPane; + const content = try p.gpa.dupe(u8, ""); + errdefer p.gpa.free(content); + const np = try output_pane.open(p, free, paneDir(src), .{ .cmd = .New }, "", content); + np.cwd = .{ .inherited = src }; + np.cur_pinned = true; + return np; + } + + /// New: a scratch below the calling pane, in its column. + pub fn newScratchBelow(p: *Pardes, from_id: usize) void { + const free = p.freeSlot() orelse return; + const sf = p.layoutFindTerm(from_id) orelse return; + const np = p.newScratch(from_id, free) catch return; + p.layoutInsert(sf.col, sf.idx + 1, free); + p.splitBelow(from_id, np); + p.active = free; + } + + /// Newcol: a scratch in a fresh column beside the calling pane. + pub fn newScratchColumn(p: *Pardes, from_id: usize) void { + const free = p.freeSlot() orelse return; + if (!p.layoutCanSplitColumn(from_id)) return; + _ = p.newScratch(from_id, free) catch return; + std.debug.assert(p.layoutSplitColumn(from_id, free, false)); + p.active = free; + } + pub fn freeSlot(p: *Pardes) ?usize { return for (p.panes, 0..) |slot, i| { if (slot == null) break i; @@ -5244,11 +5527,14 @@ pub const Pardes = struct { /// changes it observes, e.g. via /proc//cwd before each frame). pub fn setCwd(p: *Pardes, id: usize, cwd: []const u8) void { const pane = p.panes[id] orelse return; - const n = @min(cwd.len, pane.cwd.len); - if (pane.cwd_len == n and std.mem.eql(u8, pane.cwd[0..n], cwd[0..n])) return; + const n = @min(cwd.len, pane.cwd_buf.len); + const cur = switch (pane.cwd) { + .owned => |dir| dir, + else => "", + }; + if (cur.len == n and std.mem.eql(u8, cur, cwd[0..n])) return; if (p.lookHoverPane() == id) p.cancelLookHover(); - @memcpy(pane.cwd[0..n], cwd[0..n]); - pane.cwd_len = @intCast(n); + pane.setOwnedCwd(cwd[0..n]); } /// Can a command line be typed into this pane RIGHT NOW: a terminal whose @@ -5273,44 +5559,7 @@ pub const Pardes = struct { fn takesCommandLine(p: *const Pardes, id: usize) bool { const pane = p.panes[id] orelse return false; if (!pane.isTerminal()) return false; - const q = p.tty_query orelse return true; - return !q.taken(q.ctx, id); - } - - /// Complete a `new_file` effect without doing IO in the core. The shell - /// has already created `path` as an empty file; we model those known empty - /// bytes directly, insert immediately below the still-live calling pane in - /// ITS column (never whichever column became active), focus it, and start - /// the same watch every ordinary file pane has. False leaves ownership - /// with the shell, which closes and unlinks the unadopted file. - pub fn openNewFile(p: *Pardes, source: u8, serial: u32, path: []const u8) bool { - if (path.len == 0 or source >= MAX_PANES) return false; - const src = p.panes[source] orelse return false; - if (src.serial != serial) return false; - const sf = p.layoutFindTerm(source) orelse return false; - const free = p.freeSlot() orelse return false; - - const path_copy = p.gpa.dupe(u8, path) catch return false; - const content = p.gpa.dupe(u8, "") catch { - p.gpa.free(path_copy); - return false; - }; - const pane = p.newDocPane(free) catch { - p.gpa.free(content); - p.gpa.free(path_copy); - return false; - }; - pane.file = .{ .path = path_copy, .content = content }; - pane.cur_pinned = true; - - // sf was captured from SOURCE, not active. Nothing above this point - // mutates layout, so it is still the exact calling column/index. - p.layoutInsert(sf.col, sf.idx + 1, free); - p.splitBelow(source, pane); - p.active = free; - p.emit(.{ .watch = .{ .pane = @intCast(free), .on = true } }); - p.sync(); - return true; + return !p.hostTtyTaken(id); } /// What a shell should exec for the next terminal — a bare name to be @@ -5505,8 +5754,8 @@ pub const Pardes = struct { } /// Post the transient message on `id`'s last row. Called by a SHELL once - /// the IO it narrates has actually happened, exactly as openNewFile above - /// completes a `new_file` effect: the core neither writes files nor owns a + /// the IO it narrates has actually happened, exactly as the shell completes + /// a `save_file` effect: the core neither writes files nor owns a /// clock, so both the outcome and the wall time in `text` come from there /// (message.zig spells it, once, for both native shells). /// @@ -5586,6 +5835,149 @@ pub const Pardes = struct { return e; } + /// Queue input for the next `pump`. Single-threaded, and a VALUE queue: an + /// event that carries a borrowed slice cannot survive the trip, so this + /// asserts rather than documents it. Hand those to `update` directly inside + /// the host's borrow window instead — which is also what keeps the pty read + /// path copy-free. + pub fn postEvent(p: *Pardes, ev: Event) void { + switch (ev) { + .key => |k| std.debug.assert(k.text.len == 0), + .output, .paste, .lsp_resp, .pipe_resp, .file_changed, .command => unreachable, + else => {}, + } + if (p.in_len == p.in_q.len) return; + p.in_q[(p.in_head + p.in_len) % p.in_q.len] = ev; + p.in_len += 1; + } + + fn nextQueued(p: *Pardes) ?Event { + if (p.in_len == 0) { + p.in_head = 0; + return null; + } + const ev = p.in_q[p.in_head]; + p.in_head = (p.in_head + 1) % p.in_q.len; + p.in_len -= 1; + return ev; + } + + /// Has a program taken this pane's tty? A host that cannot tell says no, + /// which is how pardes behaved before the probe existed. + fn hostTtyTaken(p: *const Pardes, id: usize) bool { + const f = p.host.vtable.pull_tty_taken orelse return false; + return f(p.host.ctx, @intCast(id)); + } + + fn hostWriteFile(p: *Pardes, pane: u8, path: []const u8, bytes: []const u8) void { + if (p.host.vtable.push_write_file) |f| return f(p.host.ctx, pane, path, bytes); + p.fallback.writeFile(path, bytes); + } + + /// The path a watch is about: a real file's, or a PDF's. + fn watchPath(p: *const Pardes, id: u8) ?[]const u8 { + const pane = p.panes[id] orelse return null; + if (pane.file) |f| return if (f.output == null) f.path else null; + return pane.pdfPath(); + } + + /// Perform one effect through the host, falling back per METHOD (not per + /// host) to the in-process implementation. This is the switch that used to + /// be copied into all four shells. + pub fn perform(p: *Pardes, e: Effect) void { + const v = p.host.vtable; + switch (e) { + .spawn => |s| if (v.push_spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else { + p.fallback.spawned[s.pane] = true; + }, + // A pane with no child is silent: nothing invents output on its + // screen, and the bytes are dropped rather than transcribed. + .write => |w| if (v.push_pty_write) |f| f(p.host.ctx, w.pane, w.bytes.slice()), + .resize_pty => |r| if (v.push_pty_resize) |f| f(p.host.ctx, r.pane, r.cols, r.rows), + .open_link => |u| if (v.push_open_link) |f| + f(p.host.ctx, u.slice()) + else + p.fallback.setLink(u.slice()), + .save_file => |sf| { + const pane = p.panes[sf.pane] orelse return; + const f = pane.file orelse return; + p.hostWriteFile(sf.pane, f.path, f.content); + }, + .save_text => |st| { + const s = p.save_text orelse return; + p.hostWriteFile(st.pane, s.path, s.content); + p.clearSaveText(); + }, + .write_dump => { + const out = p.dump_out orelse return; + if (v.push_write_dump) |f| { + f(p.host.ctx, out); + } else { + // A real host reports where it landed, which is what puts + // `Restore ` in the topbar; the virtual one owes the + // same, or the bytes it holds are unreachable. + p.fallback.writeFile(fallback_dump_path, out); + p.setLastDump(fallback_dump_path); + } + }, + .set_clipboard => { + const text = p.yank orelse ""; + if (v.push_set_clipboard) |f| f(p.host.ctx, text) else p.fallback.setClipboard(text); + }, + // No desktop to ask: answer from the in-process clipboard at once, + // which is the same shape as a host answering later. + .read_clipboard => if (v.pull_read_clipboard) |f| + f(p.host.ctx) + else + p.update(.{ .paste = p.fallback.clipboard.items }), + .lsp => |q| if (v.pull_lsp) |f| + f(p.host.ctx, .{ .id = q.id, .kind = q.kind, .pane = q.pane, .offset = q.offset, .arg = q.arg.slice() }) + else + p.update(.{ .lsp_resp = .{ .id = q.id, .rows = "" } }), + .pipe => |q| if (v.pull_pipe) |f| + f(p.host.ctx, q.id) + else + p.update(.{ .pipe_resp = .{ .id = q.id, .success = false, .outputs = &.{} } }), + .watch => |w| if (v.push_watch_file) |f| + f(p.host.ctx, w.pane, p.watchPath(w.pane) orelse "", w.on) + else { + p.fallback.watched[w.pane] = w.on; + }, + .theme_file => |t| if (v.push_watch_theme) |f| f(p.host.ctx, t.generation, t.on), + .dump_themes => |d| if (v.push_dump_themes) |f| f(p.host.ctx, d.pane), + // the loop's own condition; a host tears down after its own loop + .quit => p.quit = true, + } + } + + /// ONE ITERATION OF THE LOOP, and the reason the core owns it: the ORDER + /// here — wait, apply input, perform effects, poll, render, present — was + /// copied into four shells and drifted in each. A host supplies the parts + /// only it can (blocking, pixels, processes) and nothing else. + /// + /// It is one PUMP and never a `while`: no host gives up its outer loop. + /// AppKit owns NSApplication's run loop, the browser owns the frame + /// callback, and both Linux hosts keep a thin one so Restore can swap the + /// whole core between frames. + pub fn pump(p: *Pardes, h: Host) !void { + p.host = h; + const v = h.vtable; + if (v.pull_wait_input) |f| f(h.ctx, if (p.animationActive()) animation.frame_ms else 0); + while (p.nextQueued()) |ev| p.update(ev); + while (p.nextEffect()) |e| p.perform(e); + // A quitting frame has already freed what it would draw. + if (p.quit) return; + if (v.push_poll_frame) |f| f(h.ctx); + _ = p.frame_arena.reset(.retain_capacity); + const surface = try p.render(p.frame_arena.allocator()); + if (v.push_present) |f| f(h.ctx, surface); + if (v.push_post_present) |f| f(h.ctx); + // Animation TIME is not spent here. `wait_input` was told how long it + // may sleep; a display clock wakes faster than that on input, so only + // the host knows when a real frame interval has passed. Each spends it + // by handing back one `.tick`. + } + pub fn update(p: *Pardes, ev: Event) void { // Free a motion surface that went bad during the LAST update, before // anything in this one can ask for it. Nothing frees it mid-update: @@ -5785,6 +6177,26 @@ pub const Pardes = struct { p.emit(.read_clipboard); } + /// Type text at a pane's program, the way a terminal emulator pastes: + /// bracketed when the app set mode 2004 (readline/vim/helix strip the + /// markers and refuse to run what arrives), else with `\n` turned to `\r`, + /// because a raw newline in an unbracketed paste IS the Enter key and a + /// multi-line paste would run every line but the last. + pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) void { + if (text.len == 0) return; + if (pane.vt.modes.get(.bracketed_paste)) { + p.emitWrite(id, "\x1b[200~"); + p.emitWrite(id, text); + p.emitWrite(id, "\x1b[201~"); + return; + } + const cp = p.scratch.allocator().dupe(u8, text) catch return; + for (cp) |*ch| { + if (ch.* == '\n') ch.* = '\r'; + } + p.emitWrite(id, cp); + } + /// The shell answered with system-clipboard text — or the desktop pasted /// into us unasked. Either way the bytes are pasted WITHOUT going through /// the register: helix's clipboard commands and the default register are @@ -5798,6 +6210,11 @@ pub const Pardes = struct { const pane = p.panes[id] orelse return; if (req) |r| if (pane.serial != r.serial) return; p.active = id; + // A pane in tty mode has no editable buffer to paste INTO — the pty + // owns its screen. Type the bytes at the program instead, which is + // also what makes a desktop paste (Ctrl-Shift-V, middle click, the + // window manager's own) reach a shell at all. + if (pane.isTerminal() and pane.mode == .tty) return p.typeToTty(id, pane, bytes); switch (if (req) |r| r.mode else .after) { .after => p.pasteText(pane, bytes, false), .before => p.pasteText(pane, bytes, true), @@ -6645,6 +7062,15 @@ pub const Pardes = struct { } else term_pane.enterTty(p, p.active); return; } + // A shell prompt is a pane you can leave: plain Esc there is Shift-Esc. + if (pane.isTerminal() and pane.mode == .tty and hit(key, config.escape) and p.takesCommandLine(p.active)) + return p.runBuiltin(.Last, p.active, "", null); + if (pane.isTerminal() and pane.mode == .tty) { + // Clipboard first: `hit` ignores a shift no binding asked for, so + // the plain Ctrl-V below would otherwise swallow Ctrl-Shift-V. + if (hit(key, config.tty_paste_clipboard)) return p.clipRequest(p.active, .after); + if (hit(key, config.tty_paste)) return p.typeToTty(p.active, pane, p.yank orelse return); + } // `|` owns the same visible one-line tag input as search, but Enter // snapshots an asynchronous shell filter. Escape is a pure cancel: // restore the old tail and never emit a request. @@ -6657,6 +7083,19 @@ pub const Pardes = struct { pane.pending = 0; return; } + // a save input in flight (scratch or terminal): Enter writes the path, + // Esc abandons; both drop the prompt text and return to the body. + if (pane.hasSavePrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) { + const prompt_at = pane.promptAt().?; + if (hit(key, config.search_submit)) + p.submitSave(p.active) + else + pane.tag_tail_len = @min(prompt_at, pane.tag_tail_len); + exitTagEdit(pane); + pane.mode = .normal; + pane.pending = 0; + return; + } // a search input in flight (`/` or Find): Enter searches, Esc abandons; // both restore the tag tail and hand focus back to the body. if (pane.hasSearchPrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) { @@ -8366,6 +8805,12 @@ pub const Pardes = struct { const SearchStart = enum { top, cursor }; fn paneDir(pane: *const Pane) []const u8 { + // an inherited cwd is a live link that outranks a scratch buffer's own + // synthetic path: follow it to the pane it was opened from. + switch (pane.cwd) { + .inherited => |src| return paneDir(src), + else => {}, + } if (pane.file) |f| return std.fs.path.dirname(f.path) orelse "/"; if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fs.path.dirname(pv.path) orelse "/"; @@ -8390,6 +8835,89 @@ pub const Pardes = struct { pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len); } + /// Save on a scratch buffer or a terminal arms a PATH input in the tag, + /// prefilled with the pane's directory (an inherited scratch follows the + /// pane it was opened from). submitSave writes it. + pub fn startSavePrompt(p: *Pardes, pane: *Pane) void { + p.seedTail(pane); + if (!pane.tag_init) return; + const prompt_at: u16 = @intCast(pane.tag_tail_len); + if (!pane.appendTag(config.save_marker)) return; + const dir = paneDir(pane); + _ = pane.appendTag(dir); + if (dir.len == 0 or dir[dir.len - 1] != '/') _ = pane.appendTag("/"); + pane.prompt = .{ .save = prompt_at }; + pane.tag_edit = true; + pane.tag_sel = false; + pane.mode = .insert; + pane.pending = 0; + pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len); + } + + /// Enter on a save input: the path is everything past the marker. + fn submitSave(p: *Pardes, id: usize) void { + const pane = p.panes[id] orelse return; + const at = switch (pane.prompt) { + .save => |a| a, + else => return, + }; + const armed = pane.tag_tail[at..pane.tag_tail_len]; + pane.tag_tail_len = at; // drop the prompt back to the seeded tail + if (!std.mem.startsWith(u8, armed, config.save_marker)) return; + const path = std.mem.trim(u8, armed[config.save_marker.len..], " \t\r\n"); + if (path.len == 0) return; + p.performSave(id, path); + } + + /// Commit a prompted path. A scratch buffer's own bytes go there and it + /// BECOMES an ordinary file pane; a terminal's plaintext scrollback goes + /// there and it stays a terminal. + fn performSave(p: *Pardes, id: usize, path: []const u8) void { + const pane = p.panes[id] orelse return; + if (pane.file) |*f| { + const owned = p.gpa.dupe(u8, path) catch return; + p.gpa.free(f.path); + f.path = owned; + f.output = null; // an ordinary file pane from here on + pane.cwd = .none; // its directory is now its own path's dirname + f.saved_revision = f.revision; + pane.tag_init = false; // re-derive the tag as a plain file + pane.tag_tail_len = 0; + p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); + p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); + return; + } + p.saveScrollback(id, path); + } + + /// A terminal's plaintext scrollback to `path`, leaving the pane a terminal. + /// The core cannot do IO, so it stashes the bytes and asks the host to + /// write them (save_text) the same way save_file hands off a file pane. + fn saveScrollback(p: *Pardes, id: usize, path: []const u8) void { + const pane = p.panes[id] orelse return; + if (!pane.isTerminal()) return; + const content = pane.vt.screens.active.dumpStringAlloc(p.gpa, .{ .screen = .{} }) catch return; + const owned = p.gpa.dupe(u8, path) catch { + p.gpa.free(content); + return; + }; + if (p.save_text) |old| { + p.gpa.free(old.content); + p.gpa.free(old.path); + } + p.save_text = .{ .content = content, .path = owned }; + p.emit(.{ .save_text = .{ .pane = @intCast(id) } }); + } + + /// The host has consumed the pending scrollback save: free and clear it. + pub fn clearSaveText(p: *Pardes) void { + if (p.save_text) |s| { + p.gpa.free(s.content); + p.gpa.free(s.path); + } + p.save_text = null; + } + /// Enter on an armed input: the pattern is everything past the marker's /// `/` (so a pattern may itself contain slashes), and the marker names the /// search — " Find /" walks the filesystem for NAMES, " Grep /" for @@ -11466,17 +11994,7 @@ pub const Pardes = struct { p.emitWrite(s.id, &[_]u8{ 0x1b, '[', 'M', 32, cb, rb, 0x1b, '[', 'M', 35, cb, rb }); } } - if (m.get(.bracketed_paste)) { - p.emitWrite(s.id, "\x1b[200~"); - p.emitWrite(s.id, y); - p.emitWrite(s.id, "\x1b[201~"); - } else { - const cp = p.scratch.allocator().dupe(u8, y) catch return; - for (cp) |*c| { - if (c.* == '\n') c.* = '\r'; - } - p.emitWrite(s.id, cp); - } + p.typeToTty(s.id, pane, y); return; } if (!s.chorded) { @@ -11568,6 +12086,40 @@ pub const Pardes = struct { } } + /// Newtty: a shell in the caller's directory, raw from the first frame, + /// stacked below the caller like Alt-n's new shell. + pub fn spawnTty(p: *Pardes, from: usize) void { + const src = p.panes[from] orelse return; + const free = p.freeSlot() orelse return; + const nt = p.newShell(free, paneDir(src)) catch return; + nt.greet = true; + nt.mode = .tty; + const parent = p.splitParent(from); + const f = p.layoutFindTerm(parent).?; + p.layoutInsert(f.col, f.idx + 1, free); + p.splitBelow(parent, nt); + p.active = free; + } + + /// Joincol: fold the active pane's column into the one on its right, + /// carrying its panes and width across. Inert without a right neighbour. + pub fn joinCol(p: *Pardes) void { + const f = p.layoutFindTerm(p.active) orelse return; + const c = f.col; + if (c + 1 >= p.ncol) return; + const dst = c + 1; + p.col_weight[dst] +|= p.col_weight[c]; + for (0..p.col_n[c]) |k| p.col_terms[dst][p.col_n[dst] + k] = p.col_terms[c][k]; + p.col_n[dst] += p.col_n[c]; + var j = c; + while (j + 1 < p.ncol) : (j += 1) { + p.col_terms[j] = p.col_terms[j + 1]; + p.col_n[j] = p.col_n[j + 1]; + p.col_weight[j] = p.col_weight[j + 1]; + } + p.ncol -= 1; + } + /// Whether `source_id` can donate half its width to a new column. This is /// public so callers that must create a pane first can reject before that /// creation emits any native-side work. @@ -12274,6 +12826,15 @@ pub const Pardes = struct { /// left, shells right). Only the calling/source column donates half its /// width; every other column keeps its boundary. A full column bar, or a /// result list whose source died, stacks into the leftmost. + /// A new file opens a column only when the split leaves both sides wide + /// enough to read; otherwise it stacks as a pane. Halving is exact, so the + /// narrower side is floor(width/2) >= min_cells iff width >= 2*min_cells. + fn columnFitsHalves(p: *Pardes, source_id: usize, min_cells: u16) bool { + const f = p.layoutFindTerm(source_id) orelse return false; + p.computeGeom(); + return p.col_w[f.col] >= min_cells * 2; + } + pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { const doc = if (nt.file) |f| output_pane.fileTraits(f.output).doc else true; // an image is a doc var src_id: ?usize = null; @@ -12335,7 +12896,7 @@ pub const Pardes = struct { p.active = free; return; } - if (doc and p.ncol < MAX_COLS) { + if (doc and p.ncol < MAX_COLS and p.columnFitsHalves(from_id, 100)) { if (!p.layoutSplitColumn(from_id, free, true)) return p.stackDocLeft(free, nt); p.active = free; return; @@ -12437,6 +12998,8 @@ pub const Pardes = struct { .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa), + .frame_arena = .init(opts.frame_allocator orelse gpa), + .fallback = .{ .gpa = gpa }, }; errdefer p.deinit(); const st = try dump.readZon(gpa, zon_bytes, "load"); @@ -12586,12 +13149,12 @@ pub const Pardes = struct { // content, and a default that came back padded is still a default. const said = std.mem.trimStart(u8, rest, " "); const defaults: []const []const u8 = switch (class) { - .generic => &.{ pane_tail, legacy_pane_tail }, - .savable_file => &.{ file_pane_tail, legacy_file_pane_tail }, + .generic => &.{ pane_tail, prev_pane_tail, legacy_pane_tail }, + .savable_file => &.{ file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, // Terminal dumps written before Filter used the generic current - // tail, and the earliest version used just Del. Both upgrade; - // other pane classes still treat this new default as user text. - .terminal => &.{ terminal_pane_tail, pane_tail, legacy_pane_tail }, + // tail, and the earliest version used just Del. All upgrade; other + // pane classes still treat these as user text. + .terminal => &.{ terminal_pane_tail, prev_terminal_pane_tail, pane_tail, prev_pane_tail, legacy_pane_tail }, }; for (defaults) |default| if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return; @@ -12740,6 +13303,7 @@ pub const Pardes = struct { /// Recompute geometry, push grid-size changes to each emulator + pty, fire /// deferred greetings. The mirror of the prototype's loop epilogue. fn sync(p: *Pardes) void { + p.reapPanes(); p.computeGeom(); p.syncPanelAnimations(); p.trackJump(); @@ -13836,18 +14400,22 @@ pub const Pardes = struct { _ = s.print(tx, body_y + i, tw, line, body_style); } - // the file-only passes and the terminal-only one: each is the - // whole reason its pane kind renders differently at all, so each lives - // with its kind. Order is load-bearing — gutter, then syntax over the - // content, then the emulator's own colors — and the selection/cursor - // passes below still win over all three. - if (pane.file) |*f| { - file_pane.drawGutter(p, pane, r, tx, tw, body_h, active); - if (p.settings.colors) file_pane.recolorSyntax(p, pane, f, r, tx, tw, body_h); - file_pane.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg); + // Coloring is one algorithm per pane, chosen by title (colorAlgo): the + // terminal projects its own ANSI, a file lays tree-sitter or diff + // shading over its content. source and diff share this pass because + // 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. + switch (pane.colorAlgo()) { + .tty => if (p.settings.colors and pane.mode == .tty) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h), + .source, .diff => { + const f = &pane.file.?; + file_pane.drawGutter(p, pane, r, tx, tw, body_h, active); + if (p.settings.colors) file_pane.recolorSyntax(p, pane, f, r, tx, tw, body_h); + file_pane.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg); + }, + .none => {}, } - const off = pane.scroll(); - if (p.settings.colors and pane.isTerminal()) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h, off); // mouse selections (pane-local coords), one pass per button — later // buttons win on overlap. A left .done stays highlighted after release; @@ -14363,6 +14931,81 @@ test "only the SPC clipboard commands cross to the system clipboard" { try std.testing.expect(p.clip_pending == null); } +/// Every `.write` a keystroke produced, joined — what the pane's program would +/// actually have read off its pty. +fn drainWrites(p: *Pardes, buf: []u8) []const u8 { + var n: usize = 0; + while (p.nextEffect()) |effect| switch (effect) { + .write => |w| { + const b = w.bytes.slice(); + if (n + b.len > buf.len) break; + @memcpy(buf[n..][0..b.len], b); + n += b.len; + }, + else => {}, + }; + return buf[0..n]; +} + +test "Ctrl-V and Ctrl-Shift-V paste into the program a tty pane is running" { + if (platform == .web) return; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); + var buf: [256]u8 = undefined; + _ = drainWrites(p, &buf); + + const pane = p.panes[0].?; + term_pane.enterTty(p, 0); + try std.testing.expectEqual(Mode.tty, pane.mode); + p.setYank("one\ntwo"); + + // Ctrl-V types the DEFAULT REGISTER at the program. Unbracketed, so the + // newline becomes Enter's \r — a raw \n would run `one` and leave `two` + // half-typed. + p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); + try std.testing.expectEqualStrings("one\rtwo", drainWrites(p, &buf)); + // and it asked the desktop for nothing on the way + try std.testing.expect(p.clip_pending == null); + + // Under mode 2004 the same keystroke brackets instead, which is what stops + // readline from RUNNING a multi-line paste. + pane.vt.modes.set(.bracketed_paste, true); + p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); + try std.testing.expectEqualStrings("\x1b[200~one\ntwo\x1b[201~", drainWrites(p, &buf)); + + // Ctrl-Shift-V is the other store: it ASKS, types nothing yet, and the + // answer lands at the program rather than in an edit buffer. + pane.vt.modes.set(.bracketed_paste, false); + p.update(.{ .key = .{ .cp = 'v', .ctrl = true, .shift = true } }); + try std.testing.expect(p.clip_pending != null); + try std.testing.expectEqualStrings("", drainWrites(p, &buf)); + p.update(.{ .paste = "from-desktop" }); + try std.testing.expectEqualStrings("from-desktop", drainWrites(p, &buf)); + // the clipboard did not clobber the register on its way through + try std.testing.expectEqualStrings("one\ntwo", p.yank orelse ""); +} + +test "an unasked desktop paste reaches a tty pane's program, not its buffer" { + if (platform == .web) return; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); + var buf: [256]u8 = undefined; + _ = drainWrites(p, &buf); + + const pane = p.panes[0].?; + term_pane.enterTty(p, 0); + // No request behind it: the window manager's own paste, or SDL answering a + // Ctrl-Shift-V the desktop handled. It still has to reach the shell. + p.update(.{ .paste = "ls -la" }); + try std.testing.expectEqualStrings("ls -la", drainWrites(p, &buf)); + // and nothing was typed into an overlay the pty does not know about + try std.testing.expect(pane.ovl == null); +} + test "leaving tty hides the prompt and keeps the command typed at it" { if (platform == .web) return; const gpa = std.testing.allocator; diff --git a/src/source_manifest.zig b/src/source_manifest.zig new file mode 100644 index 00000000..5de560ac --- /dev/null +++ b/src/source_manifest.zig @@ -0,0 +1,44 @@ +//! The virtual filesystem: pardes's own source, embedded, as an ALLOWLIST. +//! +//! This is the default filesystem — what a host that implements no file method +//! reads and writes, and the only one the browser has ever had. It replaced a +//! build-time generator that embedded every tracked `.zig` file: 64 files and +//! 2.4 MB of generated Zig, rediscovered on every consumer build because the +//! tracked set can change without any known input changing. +//! +//! An allowlist instead, for the reason the generator's own comment gave away: +//! nothing needs all of them. What earns a place here is a file that explains +//! how pardes works to someone reading it inside pardes — the core, the host +//! seam, the keymap, the build. `src/pardes.zig` alone is 722 KB of the total, +//! and it is the one file worth that: it IS the program. +//! +//! Paths are as a user would type them, repo-root-relative, which is what +//! `look` resolves a click against. + +pub const Source = struct { path: []const u8, contents: []const u8 }; + +pub const all = [_]Source{ + .{ .path = "build.zig", .contents = @embedFile("root-build.zig") }, + .{ .path = "build.zig.zon", .contents = @embedFile("root-build.zig.zon") }, + .{ .path = "src/pardes.zig", .contents = @embedFile("pardes.zig") }, + .{ .path = "src/host.zig", .contents = @embedFile("host.zig") }, + .{ .path = "src/config.zig", .contents = @embedFile("config.zig") }, + .{ .path = "src/main.zig", .contents = @embedFile("main.zig") }, + .{ .path = "src/builtins.zig", .contents = @embedFile("builtins.zig") }, + .{ .path = "src/grammar_manifest.zig", .contents = @embedFile("grammar_manifest.zig") }, + .{ .path = "src/source_manifest.zig", .contents = @embedFile("source_manifest.zig") }, + .{ .path = "src/CHANGELOG.md", .contents = @embedFile("CHANGELOG.md") }, +}; + +/// The written-file override wins, then the allowlist. Callers own no memory +/// here: every byte is static or lives in the Fallback that answered. +pub fn find(path: []const u8) ?[]const u8 { + for (all) |s| if (eql(s.path, path)) return s.contents; + return null; +} + +fn eql(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + for (a, b) |x, y| if (x != y) return false; + return true; +} diff --git a/src/syntax.zig b/src/syntax.zig index def11420..1494ebd3 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -23,6 +23,7 @@ const LanguageFn = *const fn () callconv(.c) *const ts.Language; pub const Syn = enum(u8) { none, keyword, string, number, comment }; const Spec = struct { + name: []const u8, exts: []const []const u8, language: *const fn () callconv(.c) *const ts.Language, query_src: []const u8, @@ -51,6 +52,7 @@ fn initSpecs() [specCount()]Spec { inline for (grammar_manifest.all) |g| { if (grammarSelected(g)) { out[i] = .{ + .name = g.name, .exts = g.exts, .language = @extern(LanguageFn, .{ .name = "tree_sitter_" ++ g.name }), .query_src = @field(ts_queries, g.name ++ "_highlights"), @@ -139,17 +141,17 @@ pub fn stop() void { } } -const Selected = struct { lang: *const ts.Language, query: *ts.Query }; +const Selected = struct { name: []const u8, lang: *const ts.Language, query: *ts.Query }; // NOTE: don't lang.destroy() — the tree_sitter_*() languages are static // singletons reused on every open; destroying one use-after-frees the next. fn ensure(spec: *Spec) !Selected { const lang = spec.language(); - if (spec.compiled_query) |query| return .{ .lang = lang, .query = query }; + if (spec.compiled_query) |query| return .{ .name = spec.name, .lang = lang, .query = query }; var error_offset: u32 = 0; const query = try ts.Query.create(lang, spec.query_src, &error_offset); spec.compiled_query = query; - return .{ .lang = lang, .query = query }; + return .{ .name = spec.name, .lang = lang, .query = query }; } fn forExt(ext: []const u8) !?Selected { @@ -161,6 +163,56 @@ fn forExt(ext: []const u8) !?Selected { return null; } +const LangAlias = struct { []const u8, []const u8 }; +const lang_aliases = [_]LangAlias{ + .{ "js", "javascript" }, + .{ "jsx", "javascript" }, + .{ "mjs", "javascript" }, + .{ "py", "python" }, + .{ "sh", "bash" }, + .{ "shell", "bash" }, + .{ "zsh", "bash" }, + .{ "bash", "bash" }, + .{ "rs", "rust" }, + .{ "c++", "cpp" }, + .{ "cxx", "cpp" }, + .{ "cc", "cpp" }, + .{ "cs", "c_sharp" }, + .{ "kt", "kotlin" }, + .{ "rb", "ruby" }, + .{ "ml", "ocaml" }, + .{ "hs", "haskell" }, +}; + +fn forLang(name: []const u8) !?Selected { + var canonical = name; + for (lang_aliases) |a| { + if (std.ascii.eqlIgnoreCase(name, a[0])) { + canonical = a[1]; + break; + } + } + for (&specs) |*spec| { + if (std.ascii.eqlIgnoreCase(canonical, spec.name)) return try ensure(spec); + } + return null; +} + +fn runQuery(styles: []u8, sel: Selected, tree: *ts.Tree, base: usize) void { + const cursor = ts.QueryCursor.create(); + defer cursor.destroy(); + cursor.exec(sel.query, tree.rootNode()); + while (cursor.nextMatch()) |match| { + for (match.captures) |cap| { + const syn = synFor(sel.query.captureNameForId(cap.index) orelse ""); + if (syn == .none) continue; + var b: usize = base + cap.node.startByte(); + const end = @min(base + @as(usize, cap.node.endByte()), styles.len); + while (b < end) : (b += 1) styles[b] = @intFromEnum(syn); + } + } +} + // Queries compile on first use (`ensure`), never at startup. Pre-compiling the // compact tier in Pardes.init cost EVERY boot ~120ms of ts_query__perform_analysis // (55% of a Debug startup) to save ~40ms on the first .zig/.c/.cpp open — a pane @@ -180,6 +232,9 @@ fn synFor(name: []const u8) Syn { .{ "include", .keyword }, .{ "conditional", .keyword }, .{ "repeat", .keyword }, + .{ "title", .keyword }, + .{ "uri", .string }, + .{ "reference", .number }, }) |m| { if (std.mem.indexOf(u8, name, m[0]) != null) return m[1]; } @@ -207,21 +262,86 @@ pub fn highlightFileRange(gpa: std.mem.Allocator, path: []const u8, content: []c const tree = parser.parseString(source, null) orelse return styles; defer tree.destroy(); - const cursor = ts.QueryCursor.create(); - defer cursor.destroy(); - cursor.exec(selected.query, tree.rootNode()); - while (cursor.nextMatch()) |match| { - for (match.captures) |cap| { - const syn = synFor(selected.query.captureNameForId(cap.index) orelse ""); - if (syn == .none) continue; - var b: usize = cap.node.startByte(); - const end = @min(@as(usize, cap.node.endByte()), styles.len); - while (b < end) : (b += 1) styles[b] = @intFromEnum(syn); + runQuery(styles, selected, tree, 0); + + if (std.mem.eql(u8, selected.name, "markdown")) injectCodeBlocks(styles, source, tree.rootNode()); + return styles; +} + +fn injectCodeBlocks(styles: []u8, source: []const u8, node: ts.Node) void { + if (std.mem.eql(u8, node.kind(), "fenced_code_block")) { + highlightCodeBlock(styles, source, node); + return; + } + var i: u32 = 0; + const count = node.childCount(); + while (i < count) : (i += 1) { + if (node.child(i)) |c| injectCodeBlocks(styles, source, c); + } +} + +fn childOfKind(node: ts.Node, kind: []const u8) ?ts.Node { + var i: u32 = 0; + const count = node.childCount(); + while (i < count) : (i += 1) { + if (node.child(i)) |c| { + if (std.mem.eql(u8, c.kind(), kind)) return c; } } + return null; +} + +fn highlightCodeBlock(styles: []u8, source: []const u8, block: ts.Node) void { + const info = childOfKind(block, "info_string") orelse return; + const lang_node = childOfKind(info, "language") orelse return; + const content_node = childOfKind(block, "code_fence_content") orelse return; + const langtext = source[lang_node.startByte()..lang_node.endByte()]; + const sub_sel = (forLang(langtext) catch return) orelse return; + const cs: usize = content_node.startByte(); + const ce: usize = content_node.endByte(); + if (ce > source.len or cs > ce) return; + + const parser = ts.Parser.create(); + defer parser.destroy(); + parser.setLanguage(sub_sel.lang) catch return; + const tree = parser.parseString(source[cs..ce], null) orelse return; + defer tree.destroy(); + runQuery(styles, sub_sel, tree, cs); +} + +/// One Syn byte per byte of content[start, end) for unified diffs/patches. +/// Pure byte scan; independent of tree-sitter and the `enabled` flag. Caller frees. +pub fn highlightDiff(gpa: std.mem.Allocator, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 { + const start_byte = @min(start_byte_raw, content.len); + const end_byte = @max(start_byte, @min(end_byte_raw, content.len)); + const source = content[start_byte..end_byte]; + const styles = try gpa.alloc(u8, source.len); + errdefer gpa.free(styles); + @memset(styles, 0); + + var offset: usize = 0; + var lines = std.mem.splitScalar(u8, source, '\n'); + while (lines.next()) |line| { + const syn = diffLineSyn(line); + if (syn != .none) @memset(styles[offset .. offset + line.len], @intFromEnum(syn)); + offset += line.len + 1; + } return styles; } +fn diffLineSyn(line: []const u8) Syn { + if (std.mem.startsWith(u8, line, "@@")) return .keyword; + if (std.mem.startsWith(u8, line, "+++") or + std.mem.startsWith(u8, line, "---") or + std.mem.startsWith(u8, line, "diff ") or + std.mem.startsWith(u8, line, "index ") or + std.mem.startsWith(u8, line, "\\ No newline")) return .comment; + if (line.len == 0) return .none; + if (line[0] == '+') return .string; + if (line[0] == '-') return .number; + return .none; +} + test "tree-sitter allocator callbacks preserve and free exact allocations" { syntax_allocator = std.testing.allocator; defer syntax_allocator = undefined; @@ -263,3 +383,48 @@ test "default full grammar set highlights Typst source" { defer std.testing.allocator.free(short_ext); try std.testing.expectEqual(Syn.keyword, @as(Syn, @enumFromInt(short_ext[keyword_at]))); } + +test "markdown highlights headings and injects fenced code blocks" { + if (!enabled or !full_grammars) return; + start(std.testing.allocator); + defer stop(); + + const source = "# Heading\n\n```zig\nconst answer = 42;\n```\n"; + const styles = try highlightFileRange(std.testing.allocator, "doc.md", source, 0, source.len); + defer std.testing.allocator.free(styles); + + const title_at = std.mem.indexOf(u8, source, "Heading").?; + const num_at = std.mem.indexOf(u8, source, "42").?; + // the heading is markdown's own highlight; the number proves the zig + // grammar was injected into the fenced block and colored its contents. + try std.testing.expectEqual(Syn.keyword, @as(Syn, @enumFromInt(styles[title_at]))); + try std.testing.expectEqual(Syn.number, @as(Syn, @enumFromInt(styles[num_at]))); +} + +test "highlightDiff colors unified diff lines by prefix" { + const diff = + "diff --git a/x b/x\n" ++ + "--- a/x\n" ++ + "+++ b/x\n" ++ + "@@ -1,3 +1,3 @@\n" ++ + " context\n" ++ + "-old line\n" ++ + "+new line\n"; + const styles = try highlightDiff(std.testing.allocator, diff, 0, diff.len); + defer std.testing.allocator.free(styles); + + const byteSyn = struct { + fn at(s: []const u8, src: []const u8, needle: []const u8) Syn { + const i = std.mem.indexOf(u8, src, needle).?; + return @enumFromInt(s[i]); + } + }.at; + + try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "diff --git")); + try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "--- a/x")); + try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "+++ b/x")); + try std.testing.expectEqual(Syn.keyword, byteSyn(styles, diff, "@@ -1,3")); + try std.testing.expectEqual(Syn.none, byteSyn(styles, diff, " context")); + try std.testing.expectEqual(Syn.number, byteSyn(styles, diff, "-old line")); + try std.testing.expectEqual(Syn.string, byteSyn(styles, diff, "+new line")); +} diff --git a/src/term_pane.zig b/src/term_pane.zig index cc0cc24e..7662292e 100644 --- a/src/term_pane.zig +++ b/src/term_pane.zig @@ -954,14 +954,12 @@ fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize { return written; } -/// tty colors: recolor each body cell from the emulator's own style so shell -/// output keeps its ansi colors (gated on the Colors toggle by the caller). -/// Rows the edit buffer stands in for are OURS, not the emulator's: they keep -/// the plain body style, and the rows under them are read from the shell row -/// the surface actually shows there. -pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, off: i32) void { +/// tty colors: recolor each visible body cell from the emulator's own style so +/// raw output keeps its ansi colors. Runs ONLY in tty mode (the caller gates +/// it) and reads the live viewport row for row: normal/insert editing shows +/// plain text, so nothing an edit does can move a shell row's colour. +pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void { const s = &p.surface; - const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); 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: { @@ -970,25 +968,15 @@ pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bo } else null; var vr: u16 = 0; while (vr < body_h and vr < pane.rows) : (vr += 1) { - const grid = pane.gridRow(off + @as(i32, vr)) - goff; - if (pane.mode != .tty) if (pane.ovl) |o| { - if (off + @as(i32, vr) >= o.row and off + @as(i32, vr) < o.row + @as(i32, @intCast(modal.lineCount(o.text)))) continue; - }; - if (grid < 0) continue; var c: u16 = 0; while (c < tw) : (c += 1) { - const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(grid) } }) orelse continue; - // bodyText already gave a wide glyph's empty surface tail the - // head's body style. Ordinary ANSI projection historically leaves - // that alone; Filter must replace it too or one half of the cell - // can retain a host/default colour (Ghostty gives the tail the - // same style id as its wide head). + const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(vr) } }) orelse continue; + // Ghostty gives a wide glyph's spacer tail the head's style id; + // ordinary projection leaves it, a filter must repaint it too. if (ci.cell.wide == .spacer_tail and filtered == null) continue; const cell = s.at(tx + c, body_y + vr); - // In ordinary ANSI mode retain the old sparse projection: bodyText - // already painted every visible glyph. A filter paints the whole - // VT viewport so even empty/default and background-only cells are - // RGB-keyed by the theme rather than leaking the host defaults. + // sparse projection: bodyText already painted every glyph, so only + // a filter (which theme-keys blank/default cells too) touches these. if (cell.default and filtered == null) continue; cell.default = false; cell.style = cellStyle(p, ci, filtered); @@ -1340,6 +1328,73 @@ 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 "tty ansi colors render only in tty mode, never in normal mode" { + 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].?; + pane.tty_filter = false; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32mPP\x1b]133;B\x1b\\\x1b[31mR\x1b[34mB\x1b[0m out" } }); + + 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; + const red: pardes.Color = .{ .index = 1 }; + const blue: pardes.Color = .{ .index = 4 }; + + // tty mode projects the emulator's ansi colours cell for cell. + pane.mode = .tty; + p.shell_rows.stale = true; + const tty = try p.render(frame.allocator()); + try testing.expectEqual(red, tty.at(tx + 2, body_y).style.fg); + try testing.expectEqual(blue, tty.at(tx + 3, body_y).style.fg); + + // normal mode is a plain editing view: no ansi projection at all, so an + // edit made here cannot change what tty mode renders. + pane.mode = .normal; + p.shell_rows.stale = true; + _ = frame.reset(.retain_capacity); + const norm = try p.render(frame.allocator()); + try testing.expectEqualStrings("R", norm.at(tx, body_y).grapheme()); + try testing.expect(!std.meta.eql(red, norm.at(tx, body_y).style.fg)); + try testing.expect(!std.meta.eql(blue, norm.at(tx + 1, body_y).style.fg)); +} + +test "an edit overlay never changes tty-mode ansi colors" { + 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].?; + pane.tty_filter = false; + pane.mode = .tty; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); + + 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; + const blue: pardes.Color = .{ .index = 4 }; + + p.shell_rows.stale = true; + const before = try p.render(frame.allocator()); + try testing.expectEqualStrings("C", before.at(tx, body_y + 2).grapheme()); + try testing.expectEqual(blue, before.at(tx, body_y + 2).style.fg); + + // A lingering multi-line edit overlay must not move any shell row's colour. + pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, "e\nd\ni\nt") }; + p.shell_rows.stale = true; + _ = frame.reset(.retain_capacity); + const after = try p.render(frame.allocator()); + try testing.expectEqualStrings("C", after.at(tx, body_y + 2).grapheme()); + try testing.expectEqual(blue, after.at(tx, body_y + 2).style.fg); +} pub fn palColor(p: *Pardes, idx: u8) pardes.Color { if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] }; diff --git a/src/tty/tty.zig b/src/tty/tty.zig index a0159f46..404fe582 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -11,7 +11,6 @@ const vaxis = @import("vaxis"); const pardes = @import("../pardes.zig"); const tracy = @import("../tracy.zig"); const look = @import("../look.zig"); -const temp_file = @import("../temp_file.zig"); const shell_bin = @import("../shell_bin.zig"); const message = @import("../message.zig"); const file_watch = @import("../file_watch.zig"); @@ -19,6 +18,7 @@ const user_config = @import("../user_config.zig"); const selection_pipe = @import("../selection_pipe.zig"); const nested = @import("../nested.zig"); const panel_compositor = @import("panel_compositor.zig"); +const host_api = @import("../host.zig"); extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; @@ -445,6 +445,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { options.image_allocator = allocs.image; options.pdf_allocator = allocs.pdf; options.tree_sitter_allocator = allocs.tree_sitter; + // the 16 MiB static buffer behind every per-frame Surface + options.frame_allocator = allocs.frame; // SIGWINCH must never run vaxis's signal handler: it posts the winsize // event through std.Io.Mutex/Condition, and when the signal lands on a @@ -499,9 +501,6 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { } else try pardes.Pardes.init(allocs.pardes, options); defer core.deinit(); - var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame); - defer frame_arena.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(); @@ -516,54 +515,56 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { while (iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id); kitty_handles.deinit(); } - var ptys: [pardes.MAX_PANES]?Pty = @splat(null); - // per-slot spawn generation: a reused pane id ignores the old shell's - // late pty_eof (which would otherwise close the NEW pty on that slot) - var gens: [pardes.MAX_PANES]u32 = @splat(0); - // ...and the core's one way to ask about those ptys: is a pane's tty still - // the prompt we forked? Installed here rather than polled per frame (see - // the cwd loop) and re-installed on a replay core, which owns a fresh set - // of panes over this same table. - core.tty_query = .{ .ctx = &ptys, .taken = &ttyTakenAt }; - // the single in-flight language query (see the .lsp effect) - var lsp_task: ?std.Io.Future(anyerror!void) = null; - // Selection filters may overlap: a second submit supersedes the first in - // core without synchronously canceling a possibly slow shell command. - var pipe_tasks: PipeTasks = .{}; - // One inotify instance for every watched pane, opened here — before any - // thread exists — so the pre-loop effect drain below can already mark the - // file a positional path argument opened. -1 off linux: watchPane goes - // quiet and the core simply never gets a file_changed event. - var inotify_fd: c_int = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1; - var watches: file_watch.Table = @splat(null); - var watch_task: ?std.Io.Future(anyerror!void) = null; + var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame); + defer frame_arena.deinit(); + var paste_buf: std.Io.Writer.Allocating = .init(gpa); + defer paste_buf.deinit(); var loop: Loop = .init(io, &tty, &vx); + + var sh: Shell = .{ + .io = io, + .gpa = gpa, + .lsp_gpa = allocs.lsp, + .core = core, + .prompt_rcs = &prompt_rcs, + .loop = &loop, + .vx = &vx, + .tty = &tty, + .kitty = &kitty_handles, + .frame = &frame_arena, + .paste_buf = &paste_buf, + // One inotify instance for every watched pane, opened here — before + // any thread exists — so the pre-loop effect drain below can already + // mark the file a positional path argument opened. -1 off linux: + // watchPane goes quiet and the core simply never gets a file_changed. + .inotify_fd = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1, + }; defer { // reap the reader tasks (cancel interrupts a blocked read) before // closing the masters — the runtime joins those threads on exit and a // reader stuck in read(2) would hang the process — then drain the // queue: leftover events own gpa bytes and would dump as leaks. - for (&ptys) |*slot| if (slot.*) |*pt| { + for (&sh.ptys) |*slot| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; _ = libc.close(pt.file.handle); slot.* = null; }; // join the query worker BEFORE the drain below, or its late post // lands in a queue nobody empties again and the rows leak - if (lsp_task) |*t| { + if (sh.lsp_task) |*t| { t.cancel(io) catch {}; - lsp_task = null; + sh.lsp_task = null; } - pipe_tasks.cancelAll(io); + sh.pipe_tasks.cancelAll(io); // same contract as the pty readers: cancel unblocks the watcher's // read, and only then is the fd safe to close - if (watch_task) |*t| { + if (sh.watch_task) |*t| { t.cancel(io) catch {}; - watch_task = null; + sh.watch_task = null; } - if (inotify_fd >= 0) { - _ = libc.close(inotify_fd); - inotify_fd = -1; + if (sh.inotify_fd >= 0) { + _ = libc.close(sh.inotify_fd); + sh.inotify_fd = -1; } while (loop.tryEvent() catch null) |ev| switch (ev) { .pty_read => |pr| gpa.free(pr.bytes), @@ -577,6 +578,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { else => {}, }; } + const host = sh.host(); // The socket a pardes launched inside this one connects to (nested.zig). // Declared AFTER the drain above so its teardown runs BEFORE it — the @@ -588,8 +590,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { defer nested.unlisten(sock_fd); // Perform the initial spawns BEFORE any worker thread exists: forkpty from - // a multithreaded process can wedge the child before exec. - drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, false); + // a multithreaded process can wedge the child before exec. `pump` installs + // the host on every pass; this drain runs outside it, so install it here. + core.host = host; + while (core.nextEffect()) |effect| core.perform(effect); try loop.start(); defer loop.stop(); @@ -624,8 +628,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // terminals answer in order, so when vaxis's reader flips queries_done // every earlier reply is already applied — no window left to miss at any // latency, and the enable lands before the next frame (see caps_pending - // in the render path). A terminal that never answers keeps the defaults, - // which is what the 2ms timeout produced anyway, and with no caps + // in pollFrame). A terminal that never answers keeps the defaults, which + // is what the 2ms timeout produced anyway, and with no caps // enableDetectedFeatures writes no bytes — the snapshot goldens, where // nothing ever answers, do not move. Startup gets 2ms faster, not slower. // @@ -640,202 +644,23 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // caps.unicode — is never called. If that ever changes, wake the loop on // vx.query_futex from a one-shot thread instead. try vx.queryTerminalSend(tty.writer()); - var caps_pending = true; // now threads are fine: start a reader task per pty - for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| { - pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, gens[id], &loop }); + sh.threads_ok = true; + for (&sh.ptys, 0..) |*slot, id| if (slot.*) |*pt| { + pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, sh.gens[id], &loop }); }; // ...and the one file watcher. Started here rather than lazily on the // first watched pane because the fd already exists and an unwatched // inotify instance just parks in read(2) — one thread for the process, // however many panes come and go. - if (inotify_fd >= 0) watch_task = io.concurrent(watchFiles, .{ io, inotify_fd, &loop }) catch null; + if (sh.inotify_fd >= 0) sh.watch_task = io.concurrent(watchFiles, .{ io, sh.inotify_fd, &loop }) catch null; - var check_files = false; - var pending: ?@TypeOf(Command.value) = .tick; - // Where a bracketed paste is assembled. It has to outlive one drain pass: - // the burst arrives over as many passes as the terminal takes to write it, - // and the markers are the only thing that says where it ends. - var paste_buf: std.Io.Writer.Allocating = .init(gpa); - defer paste_buf.deinit(); - var in_paste = false; - // 4 MiB ceiling, past which the tail is dropped rather than grown into. A - // paste that large is a mis-click on a file, not an edit, and the core - // would have to hold the whole of it as one undo entry. - const max_paste_bytes: usize = 4 << 20; + // The core owns the loop ORDER (see Pardes.pump); the outer `while` stays + // here rather than being `core.run` for one reason: Restore swaps the + // whole core, and a core cannot replace itself from inside its own frame. while (!core.quit) { - var event = if (pending) |ev| blk: { - pending = null; - break :blk ev; - } else try loop.nextEvent(); - const tz_event = tracy.zone(@src(), "event"); - // Apply every queued INPUT event, then render ONCE — the gui shell - // drains SDL's queue the same way. Without this a wheel flick is fifty - // full render+repaint (and re-highlight) cycles instead of one. A - // native PDF page crossing is the one input boundary below: its next - // wheel report needs geometry produced by this render. Pty output also - // keeps a frame per chunk so progress paints as it arrives. - var stop = false; - var batch: usize = 0; - while (true) { - var output = false; - var native_pdf_page_changed = false; - switch (event) { - .nop => {}, - .tick => core.update(.tick), - .quit => stop = true, - .focus_in => {}, - .focus_out => core.update(.pointer_leave), - .winsize => |ws| { - try vx.resize(gpa, tty.writer(), ws); - if (comptime pardes.pdf_enabled) - updateCoreTerminalSize(core, ws.cols, ws.rows, ws.x_pixel, ws.y_pixel) - else - core.update(.{ .resize = .{ .cols = ws.cols, .rows = ws.rows } }); - }, - .pty_read => |pr| { - core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); - gpa.free(pr.bytes); - output = true; - }, - .pty_eof => |e| if (gens[e.id] == e.gen) { - if (ptys[e.id]) |*pt| { - pt.reader.await(io) catch {}; // reader just finished; join it or its future leaks - _ = libc.close(pt.file.handle); - ptys[e.id] = null; - } - core.update(.{ .eof = .{ .pane = @intCast(e.id) } }); - }, - .key_press => |key| if (in_paste) { - // Between the markers a key is DATA, never a command. Same - // two inputs as the dispatch below, so a pasted character - // is exactly the character the core would have been given. - const text = key.text orelse ""; - const cp = mapKey(effCp(key)); - const bytes: []const u8 = if (text.len > 0) - text - else if (cp == pardes.Key.tab) - "\t" - else if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) - // vaxis gives control bytes no text at all: a line - // break inside a paste reaches the ground parser as a - // bare CR (-> Key.enter) or, from a terminal that does - // not translate them, a bare LF — which that parser - // reports as ctrl+j. Nothing in here is a real - // keypress, so both of them are just a newline. - "\n" - else - // arrows, F-keys, a stray escape: noise a paste has no - // business carrying, dropped rather than smuggled in. - ""; - const room = max_paste_bytes -| paste_buf.written().len; - paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {}; - } else core.update(.{ .key = .{ - .cp = mapKey(effCp(key)), - .text = key.text orelse "", - .ctrl = key.mods.ctrl, - .alt = key.mods.alt, - .shift = key.mods.shift, - } }), - .mouse => |m| { - const pdf_before = nativePdfWheelTarget(core, m); - const button: ?pardes.Mouse.Button = switch (m.button) { - .left => .left, - .middle => .middle, - .right => .right, - .wheel_up => .wheel_up, - .wheel_down => .wheel_down, - .wheel_left => .wheel_left, - .wheel_right => .wheel_right, - .none => .none, // button-less motion: hover tracking - else => null, - }; - if (button) |b| core.update(.{ .mouse = .{ - .button = b, - .kind = switch (m.type) { - .press => .press, - .release => .release, - .motion => .motion, - .drag => .drag, - }, - .col = @intCast(m.col), - .row = @intCast(m.row), - .ctrl = m.mods.ctrl, - } }); - if (pdf_before) |before| { - if (core.panes[before.pane]) |pane| { - if (pane.pdfPage()) |page| - native_pdf_page_changed = page != before.page; - } - } - }, - .paste => |bytes| { - core.update(.{ .paste = bytes }); - gpa.free(@constCast(bytes)); - }, - .paste_start => { - in_paste = true; - paste_buf.clearRetainingCapacity(); - }, - .paste_end => { - in_paste = false; - // ONE event for the whole paste — the core borrows the - // bytes for the call, exactly like the OSC 52 arm above. - const pasted = paste_buf.written(); - if (pasted.len > 0) core.update(.{ .paste = pasted }); - paste_buf.clearRetainingCapacity(); - }, - .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, - .lsp_done => |d| { - core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); - allocs.lsp.free(d.rows); - // the worker is finished; join it so its future does not - // leak (same contract as pty_eof above) - if (lsp_task) |*t| { - t.await(io) catch {}; - lsp_task = null; - } - }, - .pipe_done => |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); - }, - } - batch += 1; - // A paste in flight keeps draining WITHOUT rendering: a hundred - // thousand pasted characters are one edit, not a hundred thousand - // render-worthy events. That cannot spin — the drain still ends - // the moment the queue runs dry (tryEvent below) — so a terminal - // which sends paste_start and never paste_end costs one frame. - if (stop or output or native_pdf_page_changed or (!in_paste and batch >= 64)) break; - event = (try loop.tryEvent()) orelse break; - } - tz_event.end(); - if (stop) break; - - if (check_files) { - check_files = false; - if (file_watch.reloadChanged(core, io, gpa, &watches)) - loop.postEvent(.files_changed) catch {}; - } - - drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, true); - if (core.quit) break; - + try core.pump(host); // Restore builtin: swap in a core rebuilt from the dump; the live // shells die with their masters (readers canceled, gens bumped so // their late eofs never touch the replay panes) @@ -846,31 +671,31 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { o.cols = core.screen_w; o.rows = core.screen_h; // pre-size: dump panes never greet const nc = pardes.Pardes.initFromDump(allocs.pardes, o, bytes) catch break :blk; - for (&ptys, 0..) |*slot, pid| if (slot.*) |*pt| { + for (&sh.ptys, 0..) |*slot, pid| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; _ = libc.close(pt.file.handle); slot.* = null; - gens[pid] +%= 1; + sh.gens[pid] +%= 1; }; // the replay core's pane ids mean new things, and the dying core's // `watch off` effects go into a queue nobody drains — drop the lot // here. The new core emits its own `on`s as it builds its panes. for (0..pardes.MAX_PANES) |wid| file_watch.watchPane( - inotify_fd, - &watches, + sh.inotify_fd, + &sh.watches, @intCast(wid), null, 0, .{ .text = 0 }, ); - _ = file_watch.applyThemeEffect(core, gpa, inotify_fd, &watches, 0, false, false); + _ = file_watch.applyThemeEffect(core, gpa, sh.inotify_fd, &sh.watches, 0, false, false); var image_iterator = kitty_handles.valueIterator(); while (image_iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id); kitty_handles.clearRetainingCapacity(); nc.native_images = vx.caps.kitty_graphics; - nc.tty_query = .{ .ctx = &ptys, .taken = &ttyTakenAt }; core.deinit(); core = nc; + sh.core = nc; if (comptime pardes.pdf_enabled) { // A restored core did not receive the terminal's earlier // winsize event. Reapply both the grid and physical cells @@ -879,14 +704,304 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { updateCoreTerminalSize(core, vx.screen.width, vx.screen.height, vx.screen.width_pix, vx.screen.height_pix); } } + } +} + +/// Everything the terminal shell owns and the core cannot: the ptys, the +/// inotify table, the worker futures, and the one thread allowed to touch +/// `tty.writer()`. This struct IS the `Host.ctx`. +const Shell = struct { + io: std.Io, + gpa: std.mem.Allocator, + lsp_gpa: std.mem.Allocator, + /// live core; Restore replaces it (see run) + core: *pardes.Pardes, + prompt_rcs: *const shell_bin.PromptRcs, + loop: *Loop, + vx: *vaxis.Vaxis, + tty: *vaxis.Tty, + kitty: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image), + frame: *std.heap.ArenaAllocator, + /// Where a bracketed paste is assembled. It has to outlive one drain pass: + /// the burst arrives over as many passes as the terminal takes to write + /// it, and the markers are the only thing that says where it ends. + paste_buf: *std.Io.Writer.Allocating, + inotify_fd: c_int, + ptys: [pardes.MAX_PANES]?Pty = @splat(null), + /// per-slot spawn generation: a reused pane id ignores the old shell's + /// late pty_eof (which would otherwise close the NEW pty on that slot) + gens: [pardes.MAX_PANES]u32 = @splat(0), + watches: file_watch.Table = @splat(null), + watch_task: ?std.Io.Future(anyerror!void) = null, + /// the single in-flight language query (see the lsp method) + lsp_task: ?std.Io.Future(anyerror!void) = null, + /// Selection filters may overlap: a second submit supersedes the first in + /// core without synchronously canceling a possibly slow shell command. + pipe_tasks: PipeTasks = .{}, + /// false during the pre-loop drain: no worker exists to answer to yet + threads_ok: bool = false, + caps_pending: bool = true, + check_files: bool = false, + in_paste: bool = false, + /// the tracks the frame in flight was composed from + tracks: []const pardes.panel_animation.Track = &.{}, + + /// 4 MiB ceiling, past which the tail is dropped rather than grown into. A + /// paste that large is a mis-click on a file, not an edit, and the core + /// would have to hold the whole of it as one undo entry. + const max_paste_bytes: usize = 4 << 20; + + fn of(ctx: ?*anyopaque) *Shell { + return @ptrCast(@alignCast(ctx.?)); + } + + fn host(s: *Shell) host_api.Host { + return .{ .ctx = s, .vtable = if (comptime pardes.isolated) &isolated_vtable else &vtable }; + } + + /// An ISOLATED build (`zig build run-isolated`): the terminal this draws on + /// and the keys it reads, and nothing else. Every other method stays null, + /// so the core answers it itself — the embedded source filesystem, the + /// in-process clipboard, silent ptys. Nothing is forked, nothing on disk is + /// opened or written, and no clipboard leaves the process. The option is + /// comptime, so the other vtable is not even built into that binary. + const isolated_vtable: host_api.Host.VTable = .{ + .pull_wait_input = waitInput, + .push_present = present, + .push_post_present = postPresent, + }; + + const vtable: host_api.Host.VTable = .{ + .pull_wait_input = waitInput, + .push_present = present, + .push_post_present = postPresent, + .push_poll_frame = pollFrame, + .push_spawn = spawn, + .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, + }; + + // ---- input ------------------------------------------------------------ + + /// Block for one event, then apply the whole pending batch. Everything + /// goes through `core.update` rather than `postEvent`: a pty chunk borrows + /// its bytes for the call, and mixing queued with immediate delivery would + /// reorder a keystroke against the output it caused. + fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { + const s = of(ctx); + var batch: usize = 0; + if (timeout_ms == 0) { + // A failed read is a DEAD event source — the reader is gone and no + // event can ever arrive again, so nothing could set `quit` and the + // outer `while (!core.quit)` would spin at full speed. End the + // session, exactly as the pre-vtable `try loop.nextEvent()` did. + const first = s.loop.nextEvent() catch { + s.core.quit = true; + return s.reloadWatched(); + }; + batch = 1; + if (s.apply(first)) return s.reloadWatched(); + } else { + // Animating: the frame clock IS the wait, and the `.tick` that + // spends it is ours to post — `pump` cannot, because only this + // host knows when a real frame interval has passed. Anything that + // queues during the short sleep is drained below, in this pass. + std.Io.sleep(s.io, .fromMilliseconds(timeout_ms), .awake) catch {}; + _ = s.apply(.tick); + batch = 1; + } + // Apply every queued INPUT event, then render ONCE — the gui shell + // drains SDL's queue the same way. Without this a wheel flick is fifty + // full render+repaint (and re-highlight) cycles instead of one. A + // paste in flight keeps draining WITHOUT rendering: a hundred thousand + // pasted characters are one edit. That cannot spin — the drain still + // ends the moment the queue runs dry. + while (s.in_paste or batch < 64) { + const ev = (s.loop.tryEvent() catch null) orelse break; + batch += 1; + if (s.apply(ev)) break; + } + s.reloadWatched(); + } + + /// Apply one vaxis event. Returns true when the batch must end here: the + /// session is over, a pty chunk wants its own frame so progress paints as + /// it arrives, or a native PDF page crossing needs geometry this render + /// has not produced yet. + fn apply(s: *Shell, event: @TypeOf(Command.value)) bool { + const core = s.core; + const tz_event = tracy.zone(@src(), "event"); + defer tz_event.end(); + switch (event) { + .nop => {}, + .tick => core.update(.tick), + .quit => { + core.quit = true; + return true; + }, + .focus_in => {}, + .focus_out => core.update(.pointer_leave), + .winsize => |ws| { + s.vx.resize(s.gpa, s.tty.writer(), ws) catch {}; + if (comptime pardes.pdf_enabled) + updateCoreTerminalSize(core, ws.cols, ws.rows, ws.x_pixel, ws.y_pixel) + else + core.update(.{ .resize = .{ .cols = ws.cols, .rows = ws.rows } }); + }, + .pty_read => |pr| { + core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); + s.gpa.free(pr.bytes); + return true; + }, + .pty_eof => |e| if (s.gens[e.id] == e.gen) { + if (s.ptys[e.id]) |*pt| { + pt.reader.await(s.io) catch {}; // reader just finished; join it or its future leaks + _ = libc.close(pt.file.handle); + s.ptys[e.id] = null; + } + core.update(.{ .eof = .{ .pane = @intCast(e.id) } }); + }, + .key_press => |key| if (s.in_paste) { + // Between the markers a key is DATA, never a command. Same + // two inputs as the dispatch below, so a pasted character + // is exactly the character the core would have been given. + const text = key.text orelse ""; + const cp = mapKey(effCp(key)); + const bytes: []const u8 = if (text.len > 0) + text + else if (cp == pardes.Key.tab) + "\t" + else if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) + // vaxis gives control bytes no text at all: a line + // break inside a paste reaches the ground parser as a + // bare CR (-> Key.enter) or, from a terminal that does + // not translate them, a bare LF — which that parser + // reports as ctrl+j. Nothing in here is a real + // keypress, so both of them are just a newline. + "\n" + else + // arrows, F-keys, a stray escape: noise a paste has no + // business carrying, dropped rather than smuggled in. + ""; + const room = max_paste_bytes -| s.paste_buf.written().len; + s.paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {}; + } else core.update(.{ .key = .{ + .cp = mapKey(effCp(key)), + .text = key.text orelse "", + .ctrl = key.mods.ctrl, + .alt = key.mods.alt, + .shift = key.mods.shift, + } }), + .mouse => |m| { + const pdf_before = nativePdfWheelTarget(core, m); + const button: ?pardes.Mouse.Button = switch (m.button) { + .left => .left, + .middle => .middle, + .right => .right, + .wheel_up => .wheel_up, + .wheel_down => .wheel_down, + .wheel_left => .wheel_left, + .wheel_right => .wheel_right, + .none => .none, // button-less motion: hover tracking + else => null, + }; + if (button) |b| core.update(.{ .mouse = .{ + .button = b, + .kind = switch (m.type) { + .press => .press, + .release => .release, + .motion => .motion, + .drag => .drag, + }, + .col = @intCast(m.col), + .row = @intCast(m.row), + .ctrl = m.mods.ctrl, + } }); + if (pdf_before) |before| { + if (core.panes[before.pane]) |pane| { + if (pane.pdfPage()) |page| { + if (page != before.page) return true; + } + } + } + }, + .paste => |bytes| { + core.update(.{ .paste = bytes }); + s.gpa.free(@constCast(bytes)); + }, + .paste_start => { + s.in_paste = true; + s.paste_buf.clearRetainingCapacity(); + }, + .paste_end => { + s.in_paste = false; + // ONE event for the whole paste — the core borrows the + // bytes for the call, exactly like the OSC 52 arm above. + const pasted = s.paste_buf.written(); + if (pasted.len > 0) core.update(.{ .paste = pasted }); + s.paste_buf.clearRetainingCapacity(); + }, + .command => |line| { + 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 => s.check_files = true, + .lsp_done => |d| { + core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); + s.lsp_gpa.free(d.rows); + // the worker is finished; join it so its future does not + // leak (same contract as pty_eof above) + if (s.lsp_task) |*t| { + t.await(s.io) catch {}; + s.lsp_task = null; + } + }, + .pipe_done => |response_value| { + var response = response_value; + core.update(.{ .pipe_resp = .{ + .id = response.id, + .success = response.success, + .outputs = response.outputs, + } }); + response.deinit(s.gpa); + s.pipe_tasks.finish(s.io, response_value.id); + }, + } + return false; + } + + fn reloadWatched(s: *Shell) void { + if (!s.check_files) return; + s.check_files = false; + if (file_watch.reloadChanged(s.core, s.io, s.gpa, &s.watches)) + s.loop.postEvent(.files_changed) catch {}; + } + // ---- the frame --------------------------------------------------------- + + fn pollFrame(ctx: ?*anyopaque) void { + const s = of(ctx); // live cwd for tags/look: cheap per-pane lookup, per frame. Whether the // pane's tty still belongs to the prompt we forked is NOT polled here — // it is a walk through /proc and nothing draws it, so the core pulls it - // through ttyQuery below, at the Exec that cares. - for (&ptys, 0..) |*slot, id| if (slot.*) |pt| { + // through tty_taken instead, at the Exec that cares. + for (&s.ptys, 0..) |*slot, id| if (slot.*) |pt| { var lbuf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); + if (look.shellCwd(pt.pid, &lbuf)) |cwd| s.core.setCwd(id, cwd); }; // The handshake landed (vaxis's reader flips queries_done on DA1, the @@ -897,27 +1012,31 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // matters as much as the enable: earlier frames were drawn under the // pre-handshake caps, and vaxis's shadow grid has to be re-established // under the new ones or it keeps skipping cells it thinks are current. - if (caps_pending and vx.queries_done.load(.unordered)) { - caps_pending = false; - vx.enableDetectedFeatures(tty.writer()) catch {}; + if (s.caps_pending and s.vx.queries_done.load(.unordered)) { + s.caps_pending = false; + s.vx.enableDetectedFeatures(s.tty.writer()) catch {}; // The core was constructed before the asynchronous handshake. // Advertise native pixels only now, after every reply preceding // DA1 has updated the capability set. - core.native_images = vx.caps.kitty_graphics; - vx.queueRefresh(); + s.core.native_images = s.vx.caps.kitty_graphics; + s.vx.queueRefresh(); } + } - // ---- render: surface -> vaxis, cell for cell ---- - _ = frame_arena.reset(.retain_capacity); - const tz_core = tracy.zone(@src(), "core.render"); - const canonical = try core.render(frame_arena.allocator()); - tz_core.end(); - const surface = try panel_compositor.compose( - frame_arena.allocator(), - canonical, - canonical.panelTracks(), - core.theme().bg, - ); + /// The canonical surface -> vaxis, cell for cell, with no interpretation. + fn present(ctx: ?*anyopaque, canonical: *const pardes.Surface) void { + const s = of(ctx); + const vx = s.vx; + s.tracks = canonical.panelTracks(); + _ = s.frame.reset(.retain_capacity); + // compose only READS the canonical surface; the mutable pointer is so + // it can hand it straight back when no panel is mid-transition. + const surface = panel_compositor.compose( + s.frame.allocator(), + @constCast(canonical), + s.tracks, + s.core.theme().bg, + ) catch return; const tz_cells = tracy.zone(@src(), "surface->vaxis"); const win = vx.window(); win.clear(); @@ -949,16 +1068,16 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { if (!kittyImageRepresentable(place)) continue; } const key = place.cacheKey(); - if (!kitty_handles.contains(key) and vx.caps.kitty_graphics) { + if (!s.kitty.contains(key) and vx.caps.kitty_graphics) { const enc = std.base64.standard.Encoder; - if (gpa.alloc(u8, enc.calcSize(place.rgba.len))) |b64| { - defer gpa.free(b64); + if (s.gpa.alloc(u8, enc.calcSize(place.rgba.len))) |b64| { + defer s.gpa.free(b64); _ = enc.encode(b64, place.rgba); - if (vx.transmitPreEncodedImage(tty.writer(), b64, @intCast(place.iw), @intCast(place.ih), .rgba) catch null) |handle| - kitty_handles.put(key, handle) catch vx.freeImage(tty.writer(), handle.id); + if (vx.transmitPreEncodedImage(s.tty.writer(), b64, @intCast(place.iw), @intCast(place.ih), .rgba) catch null) |handle| + s.kitty.put(key, handle) catch vx.freeImage(s.tty.writer(), handle.id); } else |_| {} } - if (kitty_handles.get(key)) |cached| { + if (s.kitty.get(key)) |cached| { if (comptime !pardes.pdf_enabled) { const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h }); cached.draw(child, .{ .scale = .contain }) catch {}; @@ -990,15 +1109,15 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { while (true) { var stale: [pardes.MAX_PANES]pardes.ImageCacheKey = undefined; var stale_len: usize = 0; - var image_iterator = kitty_handles.iterator(); + var image_iterator = s.kitty.iterator(); while (image_iterator.next()) |entry| { if (surfaceHasKittyKey(surface, entry.key_ptr.*)) continue; stale[stale_len] = entry.key_ptr.*; stale_len += 1; if (stale_len == stale.len) break; } - for (stale[0..stale_len]) |key| if (kitty_handles.fetchRemove(key)) |removed| - vx.freeImage(tty.writer(), removed.value.id); + for (stale[0..stale_len]) |key| if (s.kitty.fetchRemove(key)) |removed| + vx.freeImage(s.tty.writer(), removed.value.id); if (stale_len < stale.len) break; } if (surface.cursor) |cur| { @@ -1007,231 +1126,235 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { win.setCursorShape(if (cur.bar) .beam else .default); } const tz_render = tracy.zone(@src(), "vx.render"); - try vx.render(tty.writer()); + vx.render(s.tty.writer()) catch {}; tz_render.end(); - core.acknowledgePanelPresentation(canonical.panelTracks()); + } + + fn postPresent(ctx: ?*anyopaque) void { + const s = of(ctx); + s.core.acknowledgePanelPresentation(s.tracks); tracy.frameMark(); + } - // Active core animation is the only reason this otherwise event-driven - // frontend redraws on a clock. Sleep on the loop thread, then make the - // next pass a tick; input and worker events can queue during the short - // sleep and are drained in that pass. No timer worker can outlive the - // loop, and at the exact endpoint we return to an indefinite wait. - if (core.animationActive()) { - std.Io.sleep(io, .fromMilliseconds(pardes.animation.frame_ms), .awake) catch {}; - pending = .tick; + // ---- pseudo-terminals --------------------------------------------------- + + fn spawn(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { + const s = of(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 — reap + // it (cancel joins the reader; its late eof is ignored by gen) + if (s.ptys[pane]) |*old| { + old.reader.cancel(s.io) catch {}; + _ = libc.close(old.file.handle); + 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 child = forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w); + s.ptys[pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } }; + // report the pane's starting directory back to the core (tags). The + // slot needs no occupancy reset: nothing is remembered, and the next + // Exec asks about the shell that is there now. + var lbuf: [1024]u8 = undefined; + if (look.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); + if (s.threads_ok) { + if (s.ptys[pane]) |*pt| { + pt.reader = s.io.concurrent(readPty, .{ s.io, s.gpa, pt.file, @as(usize, pane), s.gens[pane], s.loop }) catch pt.reader; + } } } -} -fn drainEffects( - core: *pardes.Pardes, - prompt_rcs: *const shell_bin.PromptRcs, - ptys: *[pardes.MAX_PANES]?Pty, - gens: *[pardes.MAX_PANES]u32, - io: std.Io, - gpa: std.mem.Allocator, - lsp_allocator: std.mem.Allocator, - loop: *Loop, - vx: *vaxis.Vaxis, - tty: *vaxis.Tty, - lsp_task: *?std.Io.Future(anyerror!void), - pipe_tasks: *PipeTasks, - 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 — reap - // it (cancel joins the reader; its late eof is ignored by gen) - if (ptys[sp.pane]) |*old| { - old.reader.cancel(io) catch {}; - _ = libc.close(old.file.handle); - 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 child = forkShell(core, sp.pane, prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w); - ptys[sp.pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } }; - // report the pane's starting directory back to the core (tags). The - // slot needs no occupancy reset: nothing is remembered, and the next - // Exec asks about the shell that is there now. - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd); - if (threads_ok) { - if (ptys[sp.pane]) |*pt| { - pt.reader = io.concurrent(readPty, .{ io, gpa, pt.file, @as(usize, sp.pane), gens[sp.pane], loop }) catch pt.reader; - } - } - }, - .write => |w| { - if (ptys[w.pane]) |pt| writeFd(pt.file.handle, 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.file.handle, 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) }, + fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { + const s = of(ctx); + if (s.ptys[pane]) |pt| writeFd(pt.file.handle, bytes); + } + + fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { + const s = of(ctx); + if (s.ptys[pane]) |pt| { + const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws)); + } + } + + /// Is a pane's tty still the prompt we forked? Lazy by construction — it + /// runs only where the core is about to type a command line, so the /proc + /// walk costs nothing on an ordinary frame. A pane with no pty of ours (a + /// document, a slot whose shell already died) is not a terminal a program + /// can be holding. + fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { + const s = of(ctx); + const pt = s.ptys[pane] orelse return false; + return look.ttyTaken(pt.pid, pt.file.handle); + } + + // ---- the filesystem ----------------------------------------------------- + + fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { + const s = of(ctx); + var pathbuf: [4096:0]u8 = undefined; + if (path.len >= pathbuf.len) return; + @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; + writeFd(fd, bytes); + _ = 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". Only when + // this IS the pane's watched file — a `Save ` must not + // silence a real change to the file the pane has open. + if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) { + if (s.watches[pane]) |*w| if (w.serial == pn.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); - _ = libc.close(fd); - core.setLastDump(path); - }, - .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 => { - // mirror the core's yank register out via OSC 52 - if (core.yank) |y| vx.copyToSystemClipboard(tty.writer(), y, gpa) catch {}; - }, - .read_clipboard => { - // ...and the other direction, OSC 52 read. The answer arrives on - // vaxis's reader thread as an ordinary `.paste` event and reaches - // the core through the same path an outer bracketed paste does — - // this request is the only wiring it needs. "The answer arrives" - // is the optimistic reading: a clipboard READ is an exfiltration - // primitive and terminals treat it as one (ghostty prompts by - // default, xterm ships it off, a multiplexer or ssh link may eat - // it), and a refusal looks exactly like silence. So the core's - // pending request is dropped by the next keystroke rather than - // pasting minutes late, and `SPC p` in a locked-down terminal - // honestly does nothing. - vx.requestSystemClipboard(tty.writer()) catch {}; - }, - .lsp => |q| { - if (!threads_ok) continue; // pre-loop drain: nothing to answer to yet - const pane = core.panes[q.pane] orelse continue; - // 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 continue; - job.* = .{ - .id = q.id, - .kind = q.kind, - .offset = q.offset, - .path = lsp_allocator.dupe(u8, if (f) |ff| ff.path else "") catch { - lsp_allocator.destroy(job); - continue; - }, - .source = lsp_allocator.dupeZ(u8, if (f) |ff| ff.content else "") catch { - lsp_allocator.free(job.path); - lsp_allocator.destroy(job); - continue; - }, - .arg = lsp_allocator.dupe(u8, q.arg.slice()) catch { - lsp_allocator.free(job.path); - lsp_allocator.free(job.source); - lsp_allocator.destroy(job); - continue; - }, - .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); - continue; - }, - }; - // 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 - // abandoned. Queries are milliseconds; make this a real pool the - // day a backend takes long enough to notice. - if (lsp_task.*) |*old| { - old.cancel(io) catch {}; - lsp_task.* = null; - } - lsp_task.* = io.concurrent(lspWorker, .{ lsp_allocator, job, loop }) catch { - job.free(lsp_allocator); - continue; - }; - }, - .pipe => |request| { - if (!threads_ok) continue; - if (pipe_tasks.full()) { - core.update(.{ .pipe_resp = .{ .id = request.id, .success = false, .outputs = &.{} } }); - continue; - } - const view = core.pipeRequest(request.id) orelse continue; - const job = selection_pipe.Job.copy(gpa, view) catch continue; - const future = io.concurrent(pipeWorker, .{ io, gpa, job, loop }) catch { - job.deinit(gpa); - continue; - }; - pipe_tasks.add(.{ .id = request.id, .future = future }); - }, - .watch => |w| { - if (file_watch.applyEffect(core, io, gpa, inotify_fd, watches, w.pane, w.on)) - loop.postEvent(.files_changed) catch {}; - }, - .theme_file => |request| { - if (file_watch.applyThemeEffect( - core, - gpa, - inotify_fd, - watches, - request.generation, - request.on, - threads_ok, - )) loop.postEvent(.files_changed) catch {}; - }, - .quit => {}, - }; -} + }; + // ...and say so on the pane's message row. AFTER the write, not beside + // it: every early return above is a save that did not happen and 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 = of(ctx); + var pbuf: [1024:0]u8 = undefined; + const path = pardes.dump.outPath(&pbuf) orelse return; + const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) return; + writeFd(fd, bytes); + _ = libc.close(fd); + s.core.setLastDump(path); + } + + fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool) void { + const s = of(ctx); + if (file_watch.applyEffect(s.core, s.io, s.gpa, s.inotify_fd, &s.watches, pane, on)) + s.loop.postEvent(.files_changed) catch {}; + } + + fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { + const s = of(ctx); + if (file_watch.applyThemeEffect(s.core, s.gpa, s.inotify_fd, &s.watches, generation, on, s.threads_ok)) + s.loop.postEvent(.files_changed) catch {}; + } + + fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { + const s = of(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)); + } + + // ---- the desktop -------------------------------------------------------- + + /// mirror the core's yank register out via OSC 52 + fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { + const s = of(ctx); + if (text.len == 0) return; + s.vx.copyToSystemClipboard(s.tty.writer(), text, s.gpa) catch {}; + } + + /// ...and the other direction, OSC 52 read. The answer arrives on vaxis's + /// reader thread as an ordinary `.paste` event and reaches the core + /// through the same path an outer bracketed paste does — this request is + /// the only wiring it needs. "The answer arrives" is the optimistic + /// reading: a clipboard READ is an exfiltration primitive and terminals + /// treat it as one (ghostty prompts by default, xterm ships it off, a + /// multiplexer or ssh link may eat it), and a refusal looks exactly like + /// silence. So the core's pending request is dropped by the next keystroke + /// rather than pasting minutes late, and `SPC p` in a locked-down terminal + /// honestly does nothing. + fn readClipboard(ctx: ?*anyopaque) void { + const s = of(ctx); + s.vx.requestSystemClipboard(s.tty.writer()) catch {}; + } + + fn openLink(_: ?*anyopaque, url: []const u8) void { + look.openLink(url); // desktop browser + } + + // ---- work that must leave the loop -------------------------------------- + + 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; + }, + }; + // 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 + // abandoned. Queries are milliseconds; make this a real pool the day a + // backend takes long enough to notice. + if (s.lsp_task) |*old| { + old.cancel(s.io) catch {}; + s.lsp_task = null; + } + s.lsp_task = s.io.concurrent(lspWorker, .{ a, job, s.loop }) catch { + job.free(a); + return; + }; + } + + fn pipe(ctx: ?*anyopaque, id: u32) void { + const s = of(ctx); + if (!s.threads_ok) return; + if (s.pipe_tasks.full()) { + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return; + } + const view = s.core.pipeRequest(id) orelse return; + const job = selection_pipe.Job.copy(s.gpa, view) catch return; + const future = s.io.concurrent(pipeWorker, .{ s.io, s.gpa, job, s.loop }) catch { + job.deinit(s.gpa); + return; + }; + s.pipe_tasks.add(.{ .id = id, .future = future }); + } +}; /// 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 @@ -1331,17 +1454,6 @@ fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { } } -/// The core's `tty_query`: answer for one pane out of this host's pty table. -/// Lazy by construction — it runs only where the core is about to type a -/// command line, so the /proc walk costs nothing on an ordinary frame. A pane -/// with no pty of ours (a document, a slot whose shell already died) is not a -/// terminal a program can be holding. -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.file.handle); -} - fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } { var master: c_int = undefined; // resolved BEFORE the fork, into this frame, which the child inherits: diff --git a/src/web.zig b/src/web.zig index 24ae7296..d642e108 100644 --- a/src/web.zig +++ b/src/web.zig @@ -1,9 +1,12 @@ //! Freestanding WebAssembly boundary for the DOM backend. //! -//! This file deliberately contains no browser imports. JavaScript owns the -//! clock and every IO operation, feeds events through this ABI, and reads one -//! packed cell buffer after `pardes_frame`. Keeping that boundary flat makes -//! the same module directly instantiable by the dependency-free Node harness. +//! JavaScript owns the clock and nothing else: it calls `pardes_tick` (one +//! `Pardes.pump`) and reads the packed cell buffer `present` left behind. The +//! host vtable below is only what a page genuinely has — pixels, the +//! clipboard, links, downloads. Processes, a real filesystem, a language +//! backend and subprocess pipes are left null, so the core answers them itself +//! with a virtual filesystem, a virtual clipboard and silent ptys instead of +//! forwarding effects that JavaScript cannot honour anyway. const std = @import("std"); const pardes = @import("pardes.zig"); @@ -96,13 +99,10 @@ comptime { const State = struct { core: *pardes.Pardes, - arena: std.heap.ArenaAllocator, cells: []WebCell = &.{}, frame_len: usize = 0, frame_cols: u16 = 0, frame_rows: u16 = 0, - effect_data: []u8 = &.{}, - effect_len: usize = 0, fn init(cols: u16, rows: u16) !State { const allocs = pardes.allocators.init(gpa); @@ -117,20 +117,16 @@ const State = struct { const core = try pardes.Pardes.initFromDump(allocs.pardes, .{ .cols = cols, .rows = rows, + .frame_allocator = allocs.frame, .image_allocator = allocs.image, .pdf_allocator = allocs.pdf, .tree_sitter_allocator = allocs.tree_sitter, }, embedded_dump); - return .{ - .core = core, - .arena = .init(allocs.frame), - }; + return .{ .core = core }; } fn deinit(s: *State) void { if (s.cells.len > 0) gpa.free(s.cells); - if (s.effect_data.len > 0) gpa.free(s.effect_data); - s.arena.deinit(); s.core.deinit(); pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.stop(); @@ -142,8 +138,6 @@ const State = struct { var state: ?State = null; var input: [input_capacity]u8 = undefined; var last_error: u32 = 0; -var effect_aux0: u32 = 0; -var effect_aux1: u32 = 0; export fn pardes_init(cols_arg: u32, rows_arg: u32) u32 { if (state) |*old| old.deinit(); @@ -210,69 +204,6 @@ export fn pardes_paste(len_arg: u32) void { s.core.update(.{ .paste = input[0..len] }); } -export fn pardes_output(pane: u32, len_arg: u32) void { - const s = &(state orelse return); - if (pane >= pardes.MAX_PANES) return; - const len: usize = @min(len_arg, input.len); - s.core.update(.{ .output = .{ .pane = @intCast(pane), .bytes = input[0..len] } }); -} - -export fn pardes_lsp_response(id: u32, len_arg: u32) void { - const s = &(state orelse return); - const len: usize = @min(len_arg, input.len); - s.core.update(.{ .lsp_resp = .{ .id = id, .rows = input[0..len] } }); -} - -/// Complete host-handled effect kind 12. On success `input` is -/// `[count:u32][count * output_len:u32][output bytes...]`, little-endian. -/// A static browser has no process host and simply never calls this; an -/// embedding such as Electron can run the request without another WASM ABI. -export fn pardes_pipe_response(id: u32, success_arg: u32, len_arg: u32) void { - const s = &(state orelse return); - if (success_arg == 0) { - s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); - return; - } - const len: usize = @min(len_arg, input.len); - if (len < 4) return; - const count: usize = std.mem.readInt(u32, input[0..4], .little); - if (count == 0 or count > pardes.MAX_SELS) return; - const header_len = std.math.add(usize, 4, std.math.mul(usize, count, 4) catch return) catch return; - if (header_len > len) return; - var outputs: [pardes.MAX_SELS][]const u8 = undefined; - var at = header_len; - for (0..count) |i| { - const off = 4 + i * 4; - const encoded_len: *const [4]u8 = @ptrCast(&input[off]); - const output_len: usize = std.mem.readInt(u32, encoded_len, .little); - const end = std.math.add(usize, at, output_len) catch return; - if (end > len) return; - outputs[i] = input[at..end]; - at = end; - } - if (at != len) return; - s.core.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs[0..count] } }); -} - -export fn pardes_file_changed(pane: u32, len_arg: u32) void { - const s = &(state orelse return); - if (pane >= pardes.MAX_PANES) return; - const len: usize = @min(len_arg, input.len); - s.core.update(.{ .file_changed = .{ .pane = @intCast(pane), .bytes = input[0..len] } }); -} - -export fn pardes_set_cwd(pane: u32, len_arg: u32) void { - const s = &(state orelse return); - if (pane >= pardes.MAX_PANES) return; - const len: usize = @min(len_arg, input.len); - s.core.setCwd(@intCast(pane), input[0..len]); -} - -export fn pardes_eof(pane: u32) void { - const s = &(state orelse return); - if (pane < pardes.MAX_PANES) s.core.update(.{ .eof = .{ .pane = @intCast(pane) } }); -} - /// Buttons and kinds are their boundary enum ordinals. Invalid values are /// ignored rather than reaching an unchecked enum cast inside wasm. export fn pardes_mouse(button_arg: u32, kind_arg: u32, col_arg: u32, row_arg: u32, mods: u32) void { @@ -314,54 +245,41 @@ export fn pardes_pointer_leave() void { s.core.update(.pointer_leave); } +/// One iteration of the core's own loop: queued input, effects, render, +/// present. JS calls it once per animation frame, after spending that frame's +/// animation ticks. export fn pardes_tick() void { + const s = &(state orelse return); + s.core.pump(host(s)) catch |err| { + last_error = @intFromError(err); + }; +} + +/// Spend one fixed 60 Hz animation step, the only writer of animated time in +/// this host. Separate from the pump for the reason the AppKit shell keeps them +/// separate: if every input pump also advanced the transition, a burst of keys +/// would collapse a ten-frame fade into one display frame. +export fn pardes_animation_tick() void { const s = &(state orelse return); s.core.update(.tick); } -/// Render the canonical Surface into a stable, packed JS-facing buffer. -/// Returns the number of cells, or zero if initialization/rendering failed. +export fn pardes_animating() u32 { + const s = &(state orelse return 0); + return @intFromBool(s.core.animationActive()); +} + +/// The Exit builtin, or the last pane closing. JS stops its frame loop. +export fn pardes_should_quit() u32 { + const s = &(state orelse return 0); + return @intFromBool(s.core.quit); +} + +/// The number of cells in the frame `present` last packed, or zero before the +/// first tick. `pardes_frame_ptr` is valid until the next one. export fn pardes_frame() u32 { const s = &(state orelse return 0); - _ = s.arena.reset(.retain_capacity); - s.frame_len = 0; - s.frame_cols = 0; - s.frame_rows = 0; - const surface = s.core.render(s.arena.allocator()) catch |err| { - last_error = @intFromError(err); - return 0; - }; - const count: usize = @as(usize, surface.cols) * surface.rows; - if (count != s.cells.len) { - if (count == 0) { - if (s.cells.len > 0) gpa.free(s.cells); - s.cells = &.{}; - } else { - const resized = if (s.cells.len == 0) - gpa.alloc(WebCell, count) - else - gpa.realloc(s.cells, count); - s.cells = resized catch |err| { - last_error = @intFromError(err); - return 0; - }; - } - } - s.frame_len = count; - s.frame_cols = surface.cols; - s.frame_rows = surface.rows; - for (surface.cells, s.cells[0..count]) |cell, *out| { - out.* = .{ - .text = @splat(0), - .fg = encodeColor(cell.style.fg), - .bg = encodeColor(cell.style.bg), - .attrs = encodeAttrs(cell.style), - .len = if (cell.default) 1 else cell.len, - .flags = @intFromBool(cell.default), - }; - if (cell.default) out.text[0] = ' ' else @memcpy(out.text[0..cell.len], cell.grapheme()); - } - return @intCast(count); + return @intCast(s.frame_len); } export fn pardes_frame_ptr() u32 { @@ -405,99 +323,94 @@ export fn pardes_chrome_target(col_arg: u32, row_arg: u32) u32 { return (@as(u32, target.row) << 16) | target.col; } -// Effect codes consumed by app.mjs. Unknown/host-specific effects are still -// surfaced as `pardes-io` CustomEvents, so embedding the static shell in a -// process-capable host does not require another wasm boundary. -export fn pardes_effect_next() u32 { - const s = &(state orelse return 0); - clearEffect(s); - effect_aux0 = 0; - effect_aux1 = 0; - const effect = s.core.nextEffect() orelse return 0; - return switch (effect) { - .spawn => |e| blk: { - effect_aux0 = e.pane; - putEffect(s, e.cwd.slice()); - break :blk 1; - }, - .write => |e| blk: { - effect_aux0 = e.pane; - putEffect(s, e.bytes.slice()); - break :blk 2; - }, - .resize_pty => |e| blk: { - effect_aux0 = e.pane; - effect_aux1 = (@as(u32, e.cols) << 16) | e.rows; - break :blk 3; - }, - .open_link => |e| blk: { - putEffect(s, e.slice()); - break :blk 4; - }, - .save_file => |e| blk: { - effect_aux0 = e.pane; - if (s.core.panes[e.pane]) |pane| if (pane.file) |file| - putSaveEffect(s, file.path, file.content); - break :blk 5; - }, - .write_dump => blk: { - if (s.core.dump_out) |bytes| putEffect(s, bytes); - break :blk 6; - }, - .set_clipboard => blk: { - if (s.core.yank) |bytes| putEffect(s, bytes); - break :blk 7; - }, - // 13, and no payload either way: the browser cannot hand the clipboard - // over synchronously, so the answer arrives later as an ordinary - // pardes_paste — or never, if the permission prompt says no. - .read_clipboard => 13, - .lsp => |e| blk: { - effect_aux0 = e.id; - effect_aux1 = e.pane; - putEffect(s, e.arg.slice()); - break :blk 8; - }, - // Process-capable embeddings receive exact command/cwd/input bytes: - // [command_len][cwd_len][count][input lengths...][all payloads...]. - .pipe => |e| blk: { - effect_aux0 = e.id; - const request = s.core.pipeRequest(e.id) orelse break :blk 12; - putPipeEffect(s, request); - break :blk 12; - }, - .watch => |e| blk: { - effect_aux0 = e.pane; - effect_aux1 = @intFromBool(e.on); - if (e.on) if (s.core.panes[e.pane]) |pane| if (pane.file) |file| putEffect(s, file.path); - break :blk 9; - }, - .new_file => |e| blk: { - effect_aux0 = e.pane; - effect_aux1 = e.serial; - break :blk 11; - }, - .theme_file, .dump_themes => unreachable, - .quit => 10, - }; +// ---- the host ---- +// +// Four imports, because four things here are the browser's and not the core's. +// Everything else in `Host.VTable` stays null: see the file comment. +extern "pardes" fn host_open_link(url: [*]const u8, url_len: usize) callconv(.c) void; +extern "pardes" fn host_set_clipboard(text: [*]const u8, text_len: usize) callconv(.c) void; +extern "pardes" fn host_read_clipboard() callconv(.c) void; +/// A file a page writes is a download. `path` names it; both slices are +/// borrowed for the length of the call. +extern "pardes" fn host_download( + path: [*]const u8, + path_len: usize, + bytes: [*]const u8, + bytes_len: usize, +) callconv(.c) void; + +const vtable: pardes.Host.VTable = .{ + .push_present = present, + .push_write_file = writeFile, + .push_write_dump = writeDump, + .push_set_clipboard = setClipboard, + .pull_read_clipboard = readClipboard, + .push_open_link = openLink, +}; + +fn host(s: *State) pardes.Host { + return .{ .ctx = s, .vtable = &vtable }; } -export fn pardes_effect_ptr() u32 { - const s = &(state orelse return 0); - return if (s.effect_len == 0) 0 else @intCast(@intFromPtr(s.effect_data.ptr)); +/// Pack the canonical Surface into the stable, JS-facing cell array. +fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { + const s: *State = @ptrCast(@alignCast(ctx.?)); + s.frame_len = 0; + s.frame_cols = 0; + s.frame_rows = 0; + const count: usize = @as(usize, surface.cols) * surface.rows; + if (count != s.cells.len) { + if (count == 0) { + if (s.cells.len > 0) gpa.free(s.cells); + s.cells = &.{}; + } else { + const resized = if (s.cells.len == 0) + gpa.alloc(WebCell, count) + else + gpa.realloc(s.cells, count); + s.cells = resized catch |err| { + last_error = @intFromError(err); + return; + }; + } + } + s.frame_len = count; + s.frame_cols = surface.cols; + s.frame_rows = surface.rows; + for (surface.cells, s.cells[0..count]) |cell, *out| { + out.* = .{ + .text = @splat(0), + .fg = encodeColor(cell.style.fg), + .bg = encodeColor(cell.style.bg), + .attrs = encodeAttrs(cell.style), + .len = if (cell.default) 1 else cell.len, + .flags = @intFromBool(cell.default), + }; + if (cell.default) out.text[0] = ' ' else @memcpy(out.text[0..cell.len], cell.grapheme()); + } } -export fn pardes_effect_len() u32 { - const s = &(state orelse return 0); - return @intCast(s.effect_len); +fn writeFile(_: ?*anyopaque, _: u8, path: []const u8, bytes: []const u8) void { + host_download(path.ptr, path.len, bytes.ptr, bytes.len); } -export fn pardes_effect_aux0() u32 { - return effect_aux0; +fn writeDump(_: ?*anyopaque, bytes: []const u8) void { + const name = "pardes-dump.zon"; + host_download(name.ptr, name.len, bytes.ptr, bytes.len); } -export fn pardes_effect_aux1() u32 { - return effect_aux1; +fn setClipboard(_: ?*anyopaque, text: []const u8) void { + host_set_clipboard(text.ptr, text.len); +} + +/// Asynchronous and permission-gated, unlike everything else here: the answer +/// arrives later as an ordinary `pardes_paste`, or never if the prompt says no. +fn readClipboard(_: ?*anyopaque) void { + host_read_clipboard(); +} + +fn openLink(_: ?*anyopaque, url: []const u8) void { + host_open_link(url.ptr, url.len); } fn encodeColor(color: pardes.Color) u32 { @@ -521,63 +434,3 @@ fn encodeAttrs(style: pardes.CellStyle) u16 { attrs |= @as(u16, @intFromEnum(style.ul)) << 8; return attrs; } - -fn clearEffect(s: *State) void { - if (s.effect_data.len > 0) gpa.free(s.effect_data); - s.effect_data = &.{}; - s.effect_len = 0; -} - -fn allocEffect(s: *State, len: usize) ?[]u8 { - std.debug.assert(s.effect_data.len == 0 and s.effect_len == 0); - if (len == 0) return s.effect_data; - s.effect_data = gpa.alloc(u8, len) catch return null; - s.effect_len = len; - return s.effect_data; -} - -fn putEffect(s: *State, bytes: []const u8) void { - const out = allocEffect(s, bytes.len) orelse return; - @memcpy(out, bytes); -} - -fn writeEffectU32(out: []u8, at: *usize, value: u32) void { - const word: *[4]u8 = @ptrCast(out[at.*..].ptr); - std.mem.writeInt(u32, word, value, .little); - at.* += 4; -} - -fn putSaveEffect(s: *State, path: []const u8, content: []const u8) void { - var total = std.math.add(usize, 4, path.len) catch return; - total = std.math.add(usize, total, content.len) catch return; - const out = allocEffect(s, total) orelse return; - var at: usize = 0; - writeEffectU32(out, &at, @intCast(path.len)); - @memcpy(out[at..][0..path.len], path); - at += path.len; - @memcpy(out[at..][0..content.len], content); -} - -fn putPipeEffect(s: *State, request: anytype) void { - var total = std.math.mul(usize, request.inputs.len, 4) catch return; - total = std.math.add(usize, total, 12) catch return; - total = std.math.add(usize, total, request.command.len) catch return; - total = std.math.add(usize, total, request.cwd.len) catch return; - for (request.inputs) |selection| - total = std.math.add(usize, total, selection.bytes.len) catch return; - const out = allocEffect(s, total) orelse return; - var at: usize = 0; - writeEffectU32(out, &at, @intCast(request.command.len)); - writeEffectU32(out, &at, @intCast(request.cwd.len)); - writeEffectU32(out, &at, @intCast(request.inputs.len)); - for (request.inputs) |selection| writeEffectU32(out, &at, @intCast(selection.bytes.len)); - @memcpy(out[at..][0..request.command.len], request.command); - at += request.command.len; - @memcpy(out[at..][0..request.cwd.len], request.cwd); - at += request.cwd.len; - for (request.inputs) |selection| { - @memcpy(out[at..][0..selection.bytes.len], selection.bytes); - at += selection.bytes.len; - } - std.debug.assert(at == out.len); -} diff --git a/src/web/app.mjs b/src/web/app.mjs index 631c6067..fcb9c23d 100644 --- a/src/web/app.mjs +++ b/src/web/app.mjs @@ -116,8 +116,6 @@ export class PardesRuntime { this.root = root; this.wasm = wasm; this.renderer = new DomRenderer(root, wasm); - this.inputPtr = wasm.pardes_input_ptr(); - this.inputCapacity = wasm.pardes_input_capacity(); this.cellWidth = 1; this.cellHeight = 1; this.running = false; @@ -132,10 +130,9 @@ export class PardesRuntime { const { cols, rows } = this.gridSize(); const error = this.wasm.pardes_init(cols, rows); if (error) throw new Error(`Pardes wasm init failed (${error})`); - this.inputPtr = this.wasm.pardes_input_ptr(); this.resize(); + this.wasm.pardes_tick(); this.renderer.render(); - this.drainEffects(); this.running = true; this.animationClock = { last: null, lag: 0 }; this.frameHandle = requestAnimationFrame((timestamp) => this.frame(timestamp)); @@ -150,11 +147,15 @@ export class PardesRuntime { frame(timestamp) { if (!this.running) return; + // JS owns the animation clock; the pump that follows applies those ticks, + // runs the core's effects and presents one frame however fast the display + // is. Both halves are needed: an idle core still has input and effects. for (let ticks = animationTicks(this.animationClock, timestamp); ticks > 0; ticks -= 1) { - this.wasm.pardes_tick(); + this.wasm.pardes_animation_tick(); } + this.wasm.pardes_tick(); + if (this.wasm.pardes_should_quit()) this.running = false; this.renderer.render(); - this.drainEffects(); this.frameHandle = requestAnimationFrame((nextTimestamp) => this.frame(nextTimestamp)); } @@ -183,10 +184,7 @@ export class PardesRuntime { } writeInput(text) { - const data = encoder.encode(text); - if (data.length > this.inputCapacity) throw new Error("browser input exceeds wasm transfer buffer"); - new Uint8Array(this.wasm.memory.buffer, this.inputPtr, data.length).set(data); - return data.length; + return putInput(this.wasm, text); } key(event) { @@ -313,44 +311,6 @@ export class PardesRuntime { this.root.addEventListener("wheel", (event) => this.wheel(event), { passive: false }); globalThis.addEventListener?.("resize", () => this.resize()); } - - drainEffects() { - for (;;) { - const kind = this.wasm.pardes_effect_next(); - if (!kind) return; - const ptr = this.wasm.pardes_effect_ptr(); - const len = this.wasm.pardes_effect_len(); - const data = new Uint8Array(len); - if (len) data.set(new Uint8Array(this.wasm.memory.buffer, ptr, len)); - const aux0 = this.wasm.pardes_effect_aux0(); - const aux1 = this.wasm.pardes_effect_aux1(); - this.handleEffect(kind, data, aux0, aux1); - } - } - - handleEffect(kind, data, aux0, aux1) { - if (kind === 4) { - globalThis.open?.(decoder.decode(data), "_blank", "noopener,noreferrer"); - } else if (kind === 5) { - const view = new DataView(data.buffer, data.byteOffset, data.byteLength); - const pathLength = data.length >= 4 ? view.getUint32(0, true) : 0; - const path = decoder.decode(data.subarray(4, 4 + pathLength)); - download(data.subarray(4 + pathLength), path.split("/").pop() || "pardes.txt", "text/plain;charset=utf-8"); - } else if (kind === 6) { - download(data, "pardes-dump.zon", "text/plain;charset=utf-8"); - } else if (kind === 7) { - navigator.clipboard?.writeText(decoder.decode(data)).catch(() => {}); - } else if (kind === 13) { - // Async and permission-gated, unlike every other effect here: a refused - // or unsupported read is simply a paste that never happens, which is - // what the core already tolerates from an empty clipboard. - navigator.clipboard?.readText().then((text) => this.wasm.pardes_paste(this.writeInput(text))).catch(() => {}); - } else if (kind === 10) { - this.running = false; - } else { - this.root.dispatchEvent(new CustomEvent("pardes-io", { detail: { kind, data, aux0, aux1 } })); - } - } } /// Spend monotonic requestAnimationFrame time as fixed 60 Hz core ticks. A @@ -372,17 +332,56 @@ export function animationTicks(clock, timestamp) { return ticks; } +/// The four things the core asks the page for, as the module's imports. The +/// holder exists because the import object has to be built before the instance +/// whose memory those imports read. +export function browserHost() { + const host = { wasm: null }; + const view = (ptr, len) => new Uint8Array(host.wasm.memory.buffer, ptr, len); + const text = (ptr, len) => decoder.decode(view(ptr, len)); + host.imports = { + pardes: { + host_open_link: (ptr, len) => { + globalThis.open?.(text(ptr, len), "_blank", "noopener,noreferrer"); + }, + host_set_clipboard: (ptr, len) => { + globalThis.navigator?.clipboard?.writeText(text(ptr, len)).catch(() => {}); + }, + // Async and permission-gated, unlike every other import here: a refused + // or unsupported read is simply a paste that never happens, which is + // what the core already tolerates from an empty clipboard. + host_read_clipboard: () => { + globalThis.navigator?.clipboard?.readText() + .then((clip) => host.wasm.pardes_paste(putInput(host.wasm, clip))) + .catch(() => {}); + }, + // A file a page writes is a download; the core already resolved the path + // and the bytes, and the Blob needs its own copy of them. + host_download: (pathPtr, pathLen, ptr, len) => { + const name = text(pathPtr, pathLen).split("/").pop() || "pardes.txt"; + download(view(ptr, len).slice(), name, "text/plain;charset=utf-8"); + }, + }, + }; + return host; +} + export async function instantiate(url = "./pardes.wasm") { + const host = browserHost(); const response = await fetch(url); if (!response.ok) throw new Error(`fetch ${url}: HTTP ${response.status}`); if (WebAssembly.instantiateStreaming) { try { - return (await WebAssembly.instantiateStreaming(response.clone(), {})).instance.exports; + const { instance } = await WebAssembly.instantiateStreaming(response.clone(), host.imports); + host.wasm = instance.exports; + return host.wasm; } catch (error) { if (!/mime|content-type/i.test(String(error))) throw error; } } - return (await WebAssembly.instantiate(await response.arrayBuffer(), {})).instance.exports; + const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), host.imports); + host.wasm = instance.exports; + return host.wasm; } export async function boot(root, url = "./pardes.wasm") { @@ -461,6 +460,13 @@ function clamp(value, low, high) { return Math.max(low, Math.min(high, value)); } +function putInput(wasm, text) { + const data = encoder.encode(text); + if (data.length > wasm.pardes_input_capacity()) throw new Error("browser input exceeds wasm transfer buffer"); + new Uint8Array(wasm.memory.buffer, wasm.pardes_input_ptr(), data.length).set(data); + return data.length; +} + function download(bytes, name, type) { if (typeof document === "undefined") return; const url = URL.createObjectURL(new Blob([bytes], { type })); -- cgit v1.3