summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-24 10:56:05 -0300
committerGabriel Schneider <[email protected]>2026-08-25 09:42:07 -0300
commitc3d0b84b7961ae26d2d654e7120821cc2d83d20d (patch)
tree0b9a060a8ff0ee7d83c8df5ca797a99e0d66c53e /src
parent70bde600793ea70bd68832018a154671c6bf1512 (diff)
downloadpardes-c3d0b84b7961ae26d2d654e7120821cc2d83d20d.tar.gz
pardes-c3d0b84b7961ae26d2d654e7120821cc2d83d20d.zip
host: the core owns the event loop; every platform becomes a vtable of optional methods
Diffstat (limited to 'src')
-rw-r--r--src/CHANGELOG.md34
-rw-r--r--src/builtins.zig60
-rw-r--r--src/config.zig41
-rw-r--r--src/file_pane.zig5
-rw-r--r--src/grammar_manifest.zig1
-rw-r--r--src/gui/gui.zig903
-rw-r--r--src/host.zig289
-rw-r--r--src/look.zig14
-rw-r--r--src/macos.zig521
-rw-r--r--src/output_pane.zig12
-rw-r--r--src/pardes.zig1103
-rw-r--r--src/source_manifest.zig44
-rw-r--r--src/syntax.zig191
-rw-r--r--src/term_pane.zig99
-rw-r--r--src/tty/tty.zig1086
-rw-r--r--src/web.zig381
-rw-r--r--src/web/app.mjs106
17 files changed, 3242 insertions, 1648 deletions
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<Tab>`
/// 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
+// ---------------------------------------------------------------- host seam
-/// Perform the IO the core queued. `threads_ok` is false for the one drain
-/// inside pardes_init, which runs before any reader task exists.
+/// 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];
+ // 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];
- 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 => {},
- }
+ 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"));
+ try std.testing.expect(p.executeBuiltinLine(source, "New"));
+ // 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
+}
+
+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()) |_| {}
- // 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;
+ 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) {
- .watch => |w| if (w.pane == id and !w.on) {
- unwatched = true;
- },
+ .save_text => asked = true,
else => {},
};
- try std.testing.expect(unwatched);
+ try std.testing.expect(asked);
}
-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 "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()) |_| {}
- 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;
- while (p.nextEffect()) |effect| switch (effect) {
- .new_file => if (n < requests.len) {
- requests[n] = effect;
- n += 1;
- },
- else => {},
+ // 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);
};
- try std.testing.expectEqual(requests.len, n);
+ p.perform(.{ .save_file = .{ .pane = doc_id } });
+ try std.testing.expectEqualStrings("body\n", p.fallback.files.get(doc.file.?.path).?);
- 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));
- while (p.nextEffect()) |_| {}
+ // a language backend nobody supplied answers nothing rather than hanging
+ p.perform(.{ .lsp = .{ .id = 7, .kind = .hover, .pane = doc_id, .offset = 0, .arg = .{} } });
+
+ 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,
- // 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, ""));
+ const vt: Host.VTable = .{ .push_pty_write = ptyWrite, .pull_read_clipboard = readClipboard };
- // 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);
+ 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 <path>`
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/<pid>/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 <path>` 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();
+ }
+
+ // ---- pseudo-terminals ---------------------------------------------------
- // 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;
+ 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 <elsewhere>` 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);
+}
+
+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_aux0() u32 {
- return effect_aux0;
+fn setClipboard(_: ?*anyopaque, text: []const u8) void {
+ host_set_clipboard(text.ptr, text.len);
}
-export fn pardes_effect_aux1() u32 {
- return effect_aux1;
+/// 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 }));