diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-25 02:07:23 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-25 09:42:07 -0300 |
| commit | 6f48508aa08396bcf9dd4da2cab1d221bcc53f78 (patch) | |
| tree | 83daec3db5ac27ea3172651df2b3e9cb62eddb4a /src/pardes.zig | |
| parent | 28c70cabb6ceb7e5fecfd74f6984f5a995269f01 (diff) | |
| download | pardes-6f48508aa08396bcf9dd4da2cab1d221bcc53f78.tar.gz pardes-6f48508aa08396bcf9dd4da2cab1d221bcc53f78.zip | |
acmefs: pardes --fs serves acme's control filesystem over raw Linux FUSE
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 225 |
1 files changed, 217 insertions, 8 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index b4a0b974..c5eda6aa 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -39,6 +39,10 @@ const output_pane = @import("output_pane.zig"); const builtins = @import("builtins.zig"); const runtime_cfg = @import("runtime_config.zig"); const selection_pipe = @import("selection_pipe.zig"); +/// acme's control filesystem, as a pure transaction over this core: the FILES +/// a script opens (`body`, `ctl`, `event`, ...) and what they mean. The +/// transport that carries requests in is a host's business (src/fuse.zig). +pub const acmefs = @import("acmefs.zig"); pub const config = @import("config.zig"); pub const pdf_enabled = pdf_pane.enabled; pub const pdf = pdf_pane.pdf; @@ -3428,6 +3432,14 @@ pub const Event = union(enum) { /// this must not clamp onto and preview the final grid cell. pointer_leave, tick, + /// ONE FILESYSTEM REQUEST from a process that opened a file under the + /// acme-style control mount (src/acmefs.zig, served by src/fuse.zig). + /// `data` is borrowed for this call exactly like `output` bytes, which is + /// why this — like them — never goes through `postEvent`. The answer + /// leaves as an `Effect.fs_reply` in the same update, so the transport + /// that asked is the one that writes it back: no thread, no waiting and + /// no filesystem knowledge anywhere in here. + fs_req: acmefs.Req, }; /// IO the core wants done. Payloads are inline (fixed buffers): effects are @@ -3483,6 +3495,14 @@ pub const Effect = union(enum) { /// Write the build-time theme ring below the per-user config directory. /// The pane receives the completion/error message from the native host. dump_themes: struct { pane: u8 }, + /// The answer to an `Event.fs_req`. The bytes are NOT in here: `payload` + /// says where they live (a staging buffer in the core, or a range of a + /// pane's live text) and `fsPayload` resolves it during the drain, so a + /// megabyte read costs one `writev` and no copy. `.again` means the core + /// has nothing yet and the transport must ask again later — acme's + /// blocking `event` read, with the waiting left where the kernel's + /// request already is. + fs_reply: acmefs.Reply, quit, fn Buf(comptime n: usize) type { @@ -5188,6 +5208,18 @@ pub const Options = struct { /// deterministic; when present, each line is dispatched as a builtin /// before init returns and therefore before any frontend can render. startup_config: ?[]const u8 = null, + /// SERVE ACME'S CONTROL FILESYSTEM for this session (`--fs`), and where. + /// `null` is off; `""` means "derive the mount point" (a per-session + /// directory under `$XDG_RUNTIME_DIR`); anything else is the directory + /// `--fs=<dir>` named, which scripts and the snapshot harness need because + /// they have to predict it. + /// + /// One field rather than a flag plus a path: two of those encode a state + /// ("no filesystem, mounted here") that means nothing. The core never + /// mounts anything — a mount is a host's business, and one host (the + /// browser) has no filesystem at all — but the option rides here because + /// argv already reaches the shells this way, like `nested`. + fs: ?[]const u8 = null, /// Native launcher's resolved per-user `pardes` directory. Relative /// ThemeFile operands and DumpThemes are rooted here. Null for web and /// direct core callers which did not opt into per-user configuration. @@ -5512,6 +5544,11 @@ pub const Pardes = struct { pipe_seq: u32 = 0, pipe_wait: ?PendingPipe = null, + /// acme's control filesystem, when a host serves one (`pardes --fs`). + /// Zero-initialised and inert: a core nobody scripts pays for one branch + /// per edit and nothing else. See src/acmefs.zig. + fs: acmefs.State = .{}, + /// Pending effects, drained by the shell after each update. The bounded /// ring preserves byte order; once full, later effects are refused so no /// already-queued write can be reordered or silently evicted. @@ -5652,6 +5689,7 @@ pub const Pardes = struct { 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.fs.deinit(gpa); p.shell_rows.reset(gpa); p.scratch.deinit(); p.frame_arena.deinit(); @@ -5693,8 +5731,13 @@ pub const Pardes = struct { // 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 } }); + for (p.panes, 0..) |slot, id| if (slot == pane) { + if (watched) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); + // A pane's filesystem state dies WITH the pane, here, while the + // slot still names it: the alternative is a script that held its + // `event` file open leaving the editor suppressing button actions + // for whatever pane lands in this slot next. + p.fs.forget(p.gpa, id); }; if (p.lookHoverPane()) |h| if (h < p.panes.len and p.panes[h] == pane) p.cancelLookHover(); p.pane_alloc.doom(pane); @@ -6200,7 +6243,7 @@ pub const Pardes = struct { 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, + .output, .paste, .lsp_resp, .pipe_resp, .file_changed, .command, .fs_req => unreachable, else => {}, } if (p.in_len == p.in_q.len) return; @@ -6238,6 +6281,27 @@ pub const Pardes = struct { return pane.pdfPath(); } + /// THE BYTES BEHIND AN `.fs_reply`, resolved in the drain. A filesystem + /// read answers with either something the handler formatted (staged in the + /// core, valid until the next request) or a window onto a pane's live text, + /// which is handed over WITHOUT A COPY — the same trick, and the same + /// serial check, `.save_text` uses to write a megabyte it never duplicated. + /// A slot reused between the answer and this call resolves to nothing + /// rather than to another pane's text. + pub fn fsPayload(p: *const Pardes, r: acmefs.Reply) []const u8 { + return switch (r.payload) { + .none => &.{}, + .staged => |n| p.fs.out.items[0..@min(n, p.fs.out.items.len)], + .region => |g| region: { + const pane = p.panes[g.pane] orelse break :region &.{}; + if (pane.serial != g.serial) break :region &.{}; + const text = if (pane.file) |*f| f.content else break :region &.{}; + const lo = @min(g.off, text.len); + break :region text[lo..@min(lo + g.len, text.len)]; + }, + }; + } + /// 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. @@ -6306,6 +6370,11 @@ pub const Pardes = struct { }, .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 bytes are read off the core HERE, in the drain, exactly as + // save_file reads a file pane: the reply named where they live and + // this is the borrow window. A host with no filesystem serving + // cannot have asked, so a null method is not a dropped answer. + .fs_reply => |r| if (v.push_fs_reply) |f| f(p.host.ctx, &r, p.fsPayload(r)), // the loop's own condition; a host tears down after its own loop .quit => p.quit = true, } @@ -6399,6 +6468,15 @@ pub const Pardes = struct { }, else => {}, } + // Which input this update IS, in acme's origin alphabet, so every + // event record the handlers below produce is attributed without any + // of them being told: `K` for the keyboard, `M` for the mouse. A + // filesystem write says `E`/`F` for itself (see acmefs). + if (p.fs.listeners != 0) p.fs.origin = switch (ev) { + .key => 'K', + .mouse => 'M', + else => p.fs.origin, + }; switch (ev) { .resize => |sz| { p.snap_panel_layout_once = true; @@ -6463,6 +6541,10 @@ pub const Pardes = struct { }, .paste => |bytes| p.applyPaste(bytes), .command => |line| _ = p.executeBuiltinLine(p.active, line), + // One filesystem request in, one answer out, in this update. The + // whole of the concurrency is that the transport asked from the + // loop thread; see acmefs.zig's header. + .fs_req => |r| p.emit(.{ .fs_reply = acmefs.handle(p, r) }), .pinch => |scale| p.ov_pinch_scale = scale, .touch_scroll => |delta| p.ov_touch_scroll_delta = delta, .pointer_leave => p.pointer_inside = false, @@ -6474,6 +6556,34 @@ pub const Pardes = struct { } _ = p.scratch.reset(.retain_capacity); p.sync(); + p.fsReport(); + } + + /// TAG EDITS, which no single call site owns: a tag is assembled from a + /// live prefix and an editable tail by half a dozen paths (typing, a prompt + /// arming, a Save clearing the dirty marker, a shell reporting a new cwd), + /// so it is diffed at the END of an update, where it is finally settled. + /// acme can hook `textinsert` on the tag itself because its tag IS a text + /// buffer; pardes's is a rendering, so the diff is the honest equivalent. + /// + /// Costs nothing when nobody is listening: one branch, and the snapshots + /// are only allocated for panes a script has opened. + fn fsReport(p: *Pardes) void { + if (p.fs.listeners == 0) return; + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + if (!p.fs.scripted(id)) continue; + const tag = p.tagText(p.scratch.allocator(), pane) catch continue; + const snap = &p.fs.panes[id].tag_snap; + if (std.mem.eql(u8, snap.items, tag)) continue; + // First sight of a tag is not an edit: the script just opened the + // file and can read `tag` for itself. `release` drops the snapshot + // with the last reader, so this stays true across re-opens. + if (snap.capacity != 0 or snap.items.len != 0) + acmefs.noteReplace(p, id, true, snap.items, tag); + snap.clearRetainingCapacity(); + snap.appendSlice(p.gpa, tag) catch {}; + } } /// THE DEFAULT REGISTER, and nothing else. helix: an ordinary `y`/`d`/`c` @@ -6629,7 +6739,7 @@ pub const Pardes = struct { /// retained through the frame and a cwd can be rewritten under us by the /// next shell report, so this owns its bytes rather than lending the /// pane's. - fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 { + pub fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 { const arena = p.scratch.allocator(); if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint( arena, @@ -6750,7 +6860,7 @@ pub const Pardes = struct { /// the tag exactly as it is rendered: prefix ++ gap ++ tail. THE text /// tag_col and tag_anchor index, so the renderer, the mouse, the motions /// and the chord all read the same bytes at the same columns. - fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { + pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { const prefix = try p.tagPrefix(pane); const tail = curTail(pane); const gap = p.tagGap(pane, file_pane.displayWidth(prefix) + file_pane.displayWidth(tail)); @@ -6764,7 +6874,7 @@ pub const Pardes = struct { /// Take the laid-out tail into the pane's own buffer, once, on first touch. /// The gap comes along as ordinary characters — that is what hands the /// padding to you to edit, and what freezes it against reflow from here on. - fn seedTail(p: *Pardes, pane: *Pane) void { + pub fn seedTail(p: *Pardes, pane: *Pane) void { if (pane.tag_init) return; const tail = curTail(pane); const prefix = (p.tagPrefix(pane) catch return); @@ -12240,16 +12350,109 @@ pub const Pardes = struct { } } + /// THE ACME INVERSION: while a script holds this pane's `event` file open, + /// buttons 2 and 3 in it belong to the script. The words in its tag are + /// ITS commands — `Step`, `Run`, `Clear` — and pardes has never heard of + /// them, so it reports the click and performs nothing. Returns true when + /// the caller must not run the builtin. + /// + /// Keyboard Enter/Tab are deliberately NOT suppressed, unlike acme (which + /// has no keyboard equivalent to suppress): a scripted pane stays + /// editable, and a script that dies mid-run cannot leave you unable to + /// execute anything in it. + fn reportGesture( + p: *Pardes, + id: usize, + cmd: Builtin, + text: []const u8, + on_tag: bool, + operand: PointerOperand, + chorded: bool, + ) bool { + if (!p.fs.scripted(id)) return false; + const is_look = cmd == config.look_cmd; + const action: acmefs.Action = if (is_look) + (if (on_tag) .tag_look else .body_look) + else + (if (on_tag) .tag_exec else .body_exec); + const named = std.meta.stringToEnum(Builtin, commandText(text)) != null; + const range = p.gestureRange(id, text, on_tag, operand); + // acme(4)'s two flag vocabularies at the same bit positions. For an + // exec, bit 1 is "this is a builtin"; for a look, it is "pardes can + // act on this without loading a file", which is the same fact plus a + // word that is not a path. Bit 2 is acme's "the text indicated is a + // null string that has a non-null expansion", which is exactly what an + // empty range plus text means here. + var flag: u32 = if (named) acmefs.flag_builtin else 0; + if (range.q0 == range.q1 and text.len > 0) flag |= acmefs.flag_expansion; + if (is_look) { + if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= acmefs.flag_filename; + } else if (chorded) flag |= acmefs.flag_chorded; + return acmefs.noteAction(p, id, action, range.q0, range.q1, flag, text); + } + + /// THE BYTE RANGE A GESTURE NAMES, in the coordinates `addr` and `data` + /// speak — and it must name the TEXT REPORTED WITH IT, because a script + /// that does not recognise a record writes it back and pardes then + /// re-derives the text from these two numbers. A range that started at the + /// pointer instead of at the operand would execute `l D` for a click on + /// the `l` of `Del`. + /// + /// So the start comes from whatever the operand actually resolved: an + /// expanded word's own column, or the leading corner of the selection it + /// reused. When neither is available — a modal `v`/`x` selection, a + /// terminal's projected screen, a PDF — the answer is acme's null range at + /// the click, which its flag bit 2 already has a meaning for: the text + /// travels, the range does not claim to be it. + fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: PointerOperand) acmefs.PaneFs.Range { + const pane = p.panes[id] orelse return .{}; + if (on_tag) { + const tag = p.tagText(p.scratch.allocator(), pane) catch return .{}; + const sel = operand.expanded orelse operand.preview orelse return .{}; + const lead = @min(sel.c0, sel.c1); + const at = file_pane.rawAtDisplay(tag, @intCast(@max(0, lead))); + const q0: u32 = @intCast(@min(at, tag.len)); + return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, tag.len)) }; + } + const f = if (pane.file) |*file| file else return .{}; + const start: ?modal.Cursor = if (operand.file_word) |w| + .{ .row = @intCast(@max(0, w.row)), .col = @intCast(@max(0, w.lo)) } + else if (operand.expanded orelse operand.preview) |sel| lead: { + // A selection's leading corner in reading order, converted the way + // `pointerOperand` converts the click itself. + const top = @min(sel.r0, sel.r1) - @as(i32, BOX_H); + if (top < 0) break :lead null; + const w = pane.wrapAt(top); + const col = p.paneByteAtDisplay(pane, w.line, w.at, @min(sel.c0, sel.c1) - config.PREFIX_W); + break :lead .{ + .row = @intCast(@max(0, w.line)), + .col = @intCast(@max(0, col)), + }; + } else null; + const cursor = start orelse return .{}; + const q0: u32 = @intCast(@min(modal.hxOff(f.content, cursor), f.content.len)); + return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, f.content.len)) }; + } + fn dispatchPointerBuiltin( p: *Pardes, id: usize, cmd: Builtin, text: ?[]const u8, + gesture: ?struct { on_tag: bool, operand: PointerOperand }, ) void { const arg = p.chord_arg; p.chord_arg = null; defer if (arg) |a| p.gpa.free(a); - const operand = text orelse return; + const operand = text orelse { + // Nothing expanded — a click on `#`, `*`, `|`, a blank cell. A + // WATCHED pane still owns it: the script decides what a character + // pardes has no word for means, and life.py's grid is made of + // exactly those characters. + if (gesture) |g| _ = p.reportGesture(id, cmd, "", g.on_tag, g.operand, arg != null); + return; + }; + if (gesture) |g| if (p.reportGesture(id, cmd, operand, g.on_tag, g.operand, arg != null)) return; p.runBuiltin(cmd, id, "", p.withArg(operand, arg)); } @@ -12307,10 +12510,13 @@ pub const Pardes = struct { s.chorded, ); defer release.deinit(p.pdf_gpa); + // A native PDF page has no byte offsets of its own to + // report, so a scripted pane cannot intercept this one. if (release.action) |action| p.dispatchPointerBuiltin( s.id, if (action == .look) config.look_cmd else config.exec_cmd, release.text, + null, ); return; } @@ -12383,7 +12589,10 @@ pub const Pardes = struct { } const txt = operand.text; const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd; - p.dispatchPointerBuiltin(s.id, cmd, txt); + p.dispatchPointerBuiltin(s.id, cmd, txt, .{ + .on_tag = clk.r0 < BOX_H, + .operand = operand, + }); } }, .tag => {}, // dragUpdate already left the tag cursor + selection set |
