From c3d0b84b7961ae26d2d654e7120821cc2d83d20d Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 24 Aug 2026 10:56:05 -0300 Subject: host: the core owns the event loop; every platform becomes a vtable of optional methods --- src/web.zig | 381 +++++++++++++++++++----------------------------------------- 1 file changed, 117 insertions(+), 264 deletions(-) (limited to 'src/web.zig') 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); } -export fn pardes_effect_aux0() u32 { - return effect_aux0; +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_aux1() u32 { - return effect_aux1; +fn setClipboard(_: ?*anyopaque, text: []const u8) void { + host_set_clipboard(text.ptr, text.len); +} + +/// 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); -} -- cgit v1.3