summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig225
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