summaryrefslogtreecommitdiff
path: root/src/host.zig
blob: b6ccff34493c537bfbced14b95c77adf26b7cbef (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
//! 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,
        /// 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"),
        };
    }
};