//! Freestanding WebAssembly boundary for the DOM backend. //! //! 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"); const gpa = std.heap.wasm_allocator; const embedded_dump = @embedFile("embedded.dump.zon"); const input_capacity = 64 * 1024; const c_alloc_header_size = 16; const attr_tagline: u16 = 1 << 7; comptime { if (c_alloc_header_size < @sizeOf(usize)) @compileError("C allocation header is too small"); } /// Tree-sitter's C runtime is linked into this module. Its conventional /// allocation symbols share the same wasm allocator as the Zig core and keep /// the original allocation size in a 16-byte, max-aligned prefix. export fn malloc(size_arg: usize) callconv(.c) ?*anyopaque { const size = @max(size_arg, 1); const total = std.math.add(usize, c_alloc_header_size, size) catch return null; const bytes = gpa.alignedAlloc(u8, .@"16", total) catch return null; const header: *align(16) usize = @ptrCast(bytes.ptr); header.* = total; return @ptrCast(bytes.ptr + c_alloc_header_size); } export fn calloc(count: usize, size: usize) callconv(.c) ?*anyopaque { const len = std.math.mul(usize, count, size) catch return null; const ptr = malloc(len) orelse return null; if (len != 0) { const bytes: [*]u8 = @ptrCast(ptr); @memset(bytes[0..len], 0); } return ptr; } export fn free(ptr: ?*anyopaque) callconv(.c) void { const data = ptr orelse return; const data_bytes: [*]u8 = @ptrCast(data); const base: [*]align(16) u8 = @ptrCast(@alignCast(data_bytes - c_alloc_header_size)); const header: *align(16) usize = @ptrCast(base); gpa.free(base[0..header.*]); } export fn realloc(ptr: ?*anyopaque, size: usize) callconv(.c) ?*anyopaque { if (ptr == null) return malloc(size); if (size == 0) { free(ptr); return null; } const data_bytes: [*]u8 = @ptrCast(ptr.?); const base: [*]align(16) u8 = @ptrCast(@alignCast(data_bytes - c_alloc_header_size)); const header: *align(16) usize = @ptrCast(base); const old_size = header.* - c_alloc_header_size; const replacement = malloc(size) orelse return null; const replacement_bytes: [*]u8 = @ptrCast(replacement); @memcpy(replacement_bytes[0..@min(old_size, size)], data_bytes[0..@min(old_size, size)]); free(ptr); return replacement; } pub const std_options: std.Options = .{ .logFn = logFn }; pub const panic = std.debug.FullPanic(panicImpl); fn logFn( comptime _: std.log.Level, comptime _: @EnumLiteral(), comptime _: []const u8, _: anytype, ) void {} fn panicImpl(_: []const u8, _: ?usize) noreturn { @trap(); } const WebCell = extern struct { // UTF-8 graphemes in the core are at most seven bytes. The eighth byte is // always zero, which also makes a debugger's memory view pleasant. text: [8]u8, fg: u32, bg: u32, attrs: u16, len: u8, flags: u8, }; comptime { if (@sizeOf(WebCell) != 20) @compileError("the JS frame ABI changed"); } const State = struct { core: *pardes.Pardes, tag_cells: [pardes.MAX_TAG_LAYERS][]WebCell = @splat(&.{}), layer_cells: [pardes.MAX_PANES][]WebCell = @splat(&.{}), cells: []WebCell = &.{}, frame_len: usize = 0, frame_cols: u16 = 0, frame_rows: u16 = 0, fn init(cols: u16, rows: u16) !State { const allocs = pardes.memory.init(gpa); errdefer pardes.memory.deinit(); pardes.image.start(std.Io.failing, allocs.image); errdefer pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); errdefer if (comptime pardes.pdf_enabled) pardes.pdf.stop(); pardes.syntax.start(allocs.tree_sitter); errdefer pardes.syntax.stop(); 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 }; } fn deinit(s: *State) void { if (s.cells.len > 0) gpa.free(s.cells); for (s.layer_cells) |cells| gpa.free(cells); for (s.tag_cells) |cells| gpa.free(cells); s.core.deinit(); pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.stop(); pardes.syntax.stop(); pardes.memory.deinit(); } }; var state: ?State = null; var input: [input_capacity]u8 = undefined; var last_error: u32 = 0; export fn pardes_init(cols_arg: u32, rows_arg: u32) u32 { if (state) |*old| old.deinit(); state = null; last_error = 0; const cols: u16 = @intCast(std.math.clamp(cols_arg, 1, std.math.maxInt(u16))); const rows: u16 = @intCast(std.math.clamp(rows_arg, 1, std.math.maxInt(u16))); state = State.init(cols, rows) catch |err| { last_error = @intFromError(err); return last_error; }; state.?.core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); return 0; } export fn pardes_deinit() void { if (state) |*s| s.deinit(); state = null; } export fn pardes_last_error() u32 { return last_error; } export fn pardes_input_ptr() u32 { return @intCast(@intFromPtr(&input)); } export fn pardes_input_capacity() u32 { return input_capacity; } export fn pardes_gui_tagline_font_percent() u32 { return pardes.config.gui_tagline_font_percent; } export fn pardes_resize(cols_arg: u32, rows_arg: u32) void { const s = &(state orelse return); const cols: u16 = @intCast(std.math.clamp(cols_arg, 1, std.math.maxInt(u16))); const rows: u16 = @intCast(std.math.clamp(rows_arg, 1, std.math.maxInt(u16))); if (s.core.screen_w == cols and s.core.screen_h == rows) return; s.core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); } /// `mods`: bit 0 ctrl, bit 1 alt, bit 2 shift. `len` bytes at input are the /// browser's composed text for printable keys and zero for functional keys. export fn pardes_key(cp_arg: u32, len_arg: u32, mods: u32) void { const s = &(state orelse return); if (cp_arg > std.math.maxInt(u21)) return; const len: usize = @min(len_arg, input.len); s.core.update(.{ .key = .{ .cp = @intCast(cp_arg), .text = input[0..len], .ctrl = mods & 1 != 0, .alt = mods & 2 != 0, .shift = mods & 4 != 0, } }); } export fn pardes_paste(len_arg: u32) void { const s = &(state orelse return); const len: usize = @min(len_arg, input.len); s.core.update(.{ .paste = input[0..len] }); } /// 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 { mouseWithHit(button_arg, kind_arg, col_arg, row_arg, mods, null, null); } export fn pardes_mouse_pixel(button_arg: u32, kind_arg: u32, col_arg: u32, row_arg: u32, mods: u32, x: f32, y: f32, bw: f32, bh: f32, tw: f32, th: f32) void { const s = &(state orelse return); const source = s.core.presentation.pointerFractional(s.core.screen_w, s.core.screen_h, x / @max(1, bw), y / @max(1, bh)); var hit: ?pardes.Mouse.BodyHit = null; for (&s.core.surface.body_layers) |*layer| { const point = source orelse break; hit = layer.hitAt(point.x * bw, point.y * bh, bw, bh, tw, th); if (hit != null) break; } var tag_hit: ?pardes.TagHit = null; for (&s.core.surface.tag_layers) |*layer| { const point = source orelse break; tag_hit = layer.hitAt(point.x * bw, point.y * bh, bw, bh, tw, th); if (tag_hit != null) break; } mouseWithHit(button_arg, kind_arg, col_arg, row_arg, mods, hit, tag_hit); } fn mouseWithHit(button_arg: u32, kind_arg: u32, col_arg: u32, row_arg: u32, mods: u32, hit: ?pardes.Mouse.BodyHit, tag_hit: ?pardes.TagHit) void { const s = &(state orelse return); const button: pardes.Mouse.Button = switch (button_arg) { 0 => .left, 1 => .middle, 2 => .right, 3 => .wheel_up, 4 => .wheel_down, 5 => .wheel_left, 6 => .wheel_right, 7 => .none, 8 => .back, 9 => .forward, else => return, }; const kind: pardes.Mouse.Kind = switch (kind_arg) { 0 => .press, 1 => .release, 2 => .motion, 3 => .drag, else => return, }; s.core.update(.{ .mouse = .{ .body_hit = hit, .tag_hit = tag_hit, .button = button, .kind = kind, .col = @intCast(@min(col_arg, std.math.maxInt(u16))), .row = @intCast(@min(row_arg, std.math.maxInt(u16))), .ctrl = mods & 1 != 0, } }); } export fn pardes_touch_scroll(delta: f32) void { const s = &(state orelse return); s.core.update(.{ .touch_scroll = delta }); } export fn pardes_pointer_leave() void { const s = &(state orelse return); 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); } 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); return @intCast(s.frame_len); } export fn pardes_frame_ptr() u32 { const s = &(state orelse return 0); return if (s.frame_len == 0) 0 else @intCast(@intFromPtr(s.cells.ptr)); } export fn pardes_frame_cols() u32 { const s = &(state orelse return 0); return s.frame_cols; } export fn pardes_frame_rows() u32 { const s = &(state orelse return 0); return s.frame_rows; } export fn pardes_cursor_x() i32 { const s = &(state orelse return -1); return if (s.core.surface.cursor) |cursor| cursor.x else -1; } export fn pardes_tag_text_inset() u32 { return pardes.TAG_TEXT_INSET; } export fn pardes_grip_columns() u32 { return @import("config.zig").GUTTER; } export fn pardes_tag_layer_limit() u32 { return pardes.MAX_TAG_LAYERS; } export fn pardes_tag_layer_value(index: u32, field: u32) u32 { const s = &(state orelse return 0); if (index >= pardes.MAX_TAG_LAYERS) return 0; const layer = &s.core.surface.tag_layers[index]; return switch (field) { 0 => layer.cols, 1 => @intFromEnum(layer.kind), 2 => layer.id, 3 => layer.serial, 4 => layer.viewport.x, 5 => layer.viewport.y, 6 => layer.viewport.w, 7 => layer.viewport.h, 8 => if (layer.cursor) |c| c.col else 65535, 9 => if (layer.cursor) |c| @intFromBool(c.bar) else 0, 10 => @as(u32, layer.bg[0]) << 16 | @as(u32, layer.bg[1]) << 8 | layer.bg[2], else => 0, }; } export fn pardes_tag_layer_cells(index: u32) ?[*]const WebCell { const s = &(state orelse return null); if (index >= pardes.MAX_TAG_LAYERS) return null; const layer = &s.core.surface.tag_layers[index]; if (layer.cols == 0) return null; const cells = &s.tag_cells[index]; if (cells.len != layer.cells.len) cells.* = gpa.realloc(cells.*, layer.cells.len) catch return null; for (layer.cells, cells.*) |source, *out| out.* = encodeWebCell(source); return cells.ptr; } export fn pardes_tagline_band_offset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { return pardes.taglineBandOffset(row, canvas_h, cell_h, tagline_h); } export fn pardes_body_layer_value(index: u32, field: u32) u32 { const s = &(state orelse return 0); if (index >= pardes.MAX_PANES) return 0; const layer = &s.core.surface.body_layers[index]; return switch (field) { 0 => layer.rows, 1 => layer.cols, 2 => layer.context_rows, 3 => layer.viewport.x, 4 => layer.viewport.y, 5 => layer.viewport.w, 6 => layer.viewport.h, 7 => layer.serial, 8 => if (layer.cursor) |c| c.x else 65535, 9 => if (layer.cursor) |c| c.y else 65535, 10 => if (layer.cursor) |c| @intFromBool(c.bar) else 0, else => @intFromBool(layer.hasContextBorderAfter(field - 11)), }; } export fn pardes_body_layer_cells(index: u32) ?[*]const WebCell { const s = &(state orelse return null); if (index >= pardes.MAX_PANES) return null; const layer = &s.core.surface.body_layers[index]; if (layer.rows == 0) return null; const cells = &s.layer_cells[index]; if (cells.len != layer.cells.len) cells.* = gpa.realloc(cells.*, layer.cells.len) catch return null; for (layer.cells, cells.*) |cell, *out| out.* = encodeWebCell(cell); return cells.ptr; } export fn pardes_body_layer_limit() u32 { return pardes.MAX_PANES; } export fn pardes_row_metrics(body_w: u16, body_h: u16, tagline_w: u16, tagline_h: u16) void { const s = &(state orelse return); s.core.update(.{ .resize = .{ .cols = s.core.screen_w, .rows = s.core.screen_h, .cell_pixels = s.core.cell_pixels, .row_metrics = .{ .body_w = @max(1, body_w), .body_h = @max(1, body_h), .tagline_w = @max(1, tagline_w), .tagline_h = @max(1, tagline_h) } } }); } export fn pardes_pointer_shape() u32 { const s = &(state orelse return 0); return @intFromEnum(s.core.surface.pointer_shape); } export fn pardes_cursor_y() i32 { const s = &(state orelse return -1); return if (s.core.surface.cursor) |cursor| cursor.y else -1; } export fn pardes_cursor_bar() u32 { const s = &(state orelse return 0); return @intFromBool(if (s.core.surface.cursor) |cursor| cursor.bar else false); } /// The browser's touch policy asks whether a gesture began on layout chrome. /// A hit returns row:col packed into u32, including snapping near a separator; /// 0xffffffff means body text and therefore one-finger scroll/tap-as-LOOK. export fn pardes_chrome_target(col_arg: u32, row_arg: u32) u32 { const s = &(state orelse return std.math.maxInt(u32)); const col: u16 = @intCast(@min(col_arg, std.math.maxInt(u16))); const row: u16 = @intCast(@min(row_arg, std.math.maxInt(u16))); const target = s.core.chromeTarget(col, row) orelse return std.math.maxInt(u32); return (@as(u32, target.row) << 16) | target.col; } // ---- 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 = .{ .present = present, .write_file = writeFile, .write_dump = writeDump, .set_clipboard = setClipboard, .read_clipboard = readClipboard, .open_link = openLink, }; fn host(s: *State) pardes.Host { return .{ .ctx = s, .vtable = &vtable }; } /// 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.* = encodeWebCell(cell); } fn encodeWebCell(cell: pardes.Cell) WebCell { var out: WebCell = .{ .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 out; } fn writeFile(_: ?*anyopaque, _: u8, path: []const u8, bytes: []const u8) void { // A download the browser has been handed IS the save here: there is no // filesystem to fail against, so there is no `saveFailed` to report. 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); } 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 { return switch (color) { .default => 0x01000000, .index => |index| 0x02000000 | @as(u32, index), .rgb => |rgb| (@as(u32, rgb[0]) << 16) | (@as(u32, rgb[1]) << 8) | rgb[2], }; } fn encodeAttrs(style: pardes.CellStyle) u16 { var attrs: u16 = 0; attrs |= @as(u16, @intFromBool(style.bold)) << 0; attrs |= @as(u16, @intFromBool(style.dim)) << 1; attrs |= @as(u16, @intFromBool(style.italic)) << 2; attrs |= @as(u16, @intFromBool(style.blink)) << 3; attrs |= @as(u16, @intFromBool(style.reverse)) << 4; attrs |= @as(u16, @intFromBool(style.invisible)) << 5; attrs |= @as(u16, @intFromBool(style.strikethrough)) << 6; attrs |= @as(u16, @intFromBool(style.font_role == .tagline)) * attr_tagline; attrs |= @as(u16, @intFromEnum(style.ul)) << 8; return attrs; }