//! 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. 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; 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, 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); errdefer pardes.allocators.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, .image_allocator = allocs.image, .pdf_allocator = allocs.pdf, .tree_sitter_allocator = allocs.tree_sitter, }, embedded_dump); return .{ .core = core, .arena = .init(allocs.frame), }; } 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(); pardes.syntax.stop(); pardes.allocators.deinit(); } }; 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(); 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_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] }); } 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 { 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, else => return, }; const kind: pardes.Mouse.Kind = switch (kind_arg) { 0 => .press, 1 => .release, 2 => .motion, 3 => .drag, else => return, }; s.core.update(.{ .mouse = .{ .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_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_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); } 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_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; } // 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; }, .quit => 10, }; } 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)); } export fn pardes_effect_len() u32 { const s = &(state orelse return 0); return @intCast(s.effect_len); } export fn pardes_effect_aux0() u32 { return effect_aux0; } export fn pardes_effect_aux1() u32 { return effect_aux1; } 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, @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); }