summaryrefslogtreecommitdiff
path: root/src/web.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/web.zig')
-rw-r--r--src/web.zig381
1 files changed, 117 insertions, 264 deletions
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);
+}
+
+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_aux0() u32 {
- return effect_aux0;
+fn setClipboard(_: ?*anyopaque, text: []const u8) void {
+ host_set_clipboard(text.ptr, text.len);
}
-export fn pardes_effect_aux1() u32 {
- return effect_aux1;
+/// 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);
-}