//! 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, // ---- this frontend's own membership ---- /// `Detach` — leave the session, which carries on for everybody else. /// Only a DETACHED core's host implements it, and the null case is the /// point rather than an oversight: a local tty or SDL shell has no /// session to leave, so the core reports that on the pane's row (see /// `perform`) instead of quietly quitting something. Argumentless like /// the two frame pushes above, because the host serving it already /// knows whose keystroke arrived — it is the one that delivered it. push_detach: ?*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, /// Deliver a signal to whatever is on this pane's tty — `pty/ctl`'s /// `sig INT`. A PUSH because there is no answer to have: `kill(2)` /// either reaches a process that is already gone or reaches one whose /// disposition the sender cannot see, and a script that wants to know /// whether the program died reads the pane. NULL means this host owns /// no pane shells and therefore has no child to signal — the browser /// and the board, where the same null already makes `push_spawn` and /// `push_pty_write` silent — and the effect is dropped exactly as a /// write to a pane with no pty is. push_pty_signal: ?*const fn (ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) 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 board's own pads ---- /// Flip one GPIO and report the level it held and the level it now holds. False means the /// host would not do it: a pin number outside the part, or no pads at all. /// /// A pull, because there is one answer. The HOST answers it rather than the core reaching /// for the registers itself - which `Peek` and `Poke` do two functions away - because /// driving a pad correctly is not one register. It is the IO MUX function select, the GPIO /// matrix output route, the pad's drive and input-buffer bits, and the output enable, keyed /// by a per-pin table. The firmware already owns that code and checks it against ESP-IDF's /// own headers on the die; a second copy in here would be a second copy nobody tests. pull_gpio_toggle: ?*const fn (ctx: ?*anyopaque, pin: u16, was: *u8, now: *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, /// Hand one filesystem answer back to whoever asked for it (a FUSE /// `write(2)` to /dev/fuse). `bytes` is the payload the core resolved /// for this reply and is borrowed for the length of this call — it may /// point straight into a pane's text, so a host that needs it later /// copies it. A push and not a pull: the answer is already computed, /// and a second host serving the same mount is not a thing that /// happens (the transport that asked is the one holding the request). push_fs_reply: ?*const fn (ctx: ?*anyopaque, reply: *const pardes.acmefs.Reply, bytes: []const u8) 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"), }; } };