summaryrefslogtreecommitdiff
path: root/src/web.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-01 22:44:19 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commitd59397d4146ffa23adbbe6dc99c64ebb22a93b2d (patch)
treea06984bf172a6c894c1586d58aad02ec302b6c2d /src/web.zig
parentcbcdf01764e2e144a821643c3a8e9a5ad39eda2e (diff)
downloadpardes-d59397d4146ffa23adbbe6dc99c64ebb22a93b2d.tar.gz
pardes-d59397d4146ffa23adbbe6dc99c64ebb22a93b2d.zip
add vanilla DOM web backend
Diffstat (limited to 'src/web.zig')
-rw-r--r--src/web.zig481
1 files changed, 481 insertions, 0 deletions
diff --git a/src/web.zig b/src/web.zig
new file mode 100644
index 00000000..cebbeebf
--- /dev/null
+++ b/src/web.zig
@@ -0,0 +1,481 @@
+//! 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: std.ArrayList(WebCell) = .empty,
+ effect_data: std.ArrayList(u8) = .empty,
+
+ fn deinit(s: *State) void {
+ s.core.deinit();
+ s.arena.deinit();
+ s.cells.deinit(gpa);
+ s.effect_data.deinit(gpa);
+ }
+};
+
+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)));
+ const core = pardes.Pardes.initFromDump(gpa, .{ .cols = cols, .rows = rows }, embedded_dump) catch |err| {
+ last_error = @intFromError(err);
+ return last_error;
+ };
+ state = .{ .core = core, .arena = .init(gpa) };
+ 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] } });
+}
+
+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);
+ 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;
+ s.cells.resize(gpa, count) catch |err| {
+ last_error = @intFromError(err);
+ return 0;
+ };
+ for (surface.cells, s.cells.items) |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.cells.items.len == 0) 0 else @intCast(@intFromPtr(s.cells.items.ptr));
+}
+
+export fn pardes_frame_cols() u32 {
+ const s = &(state orelse return 0);
+ return s.core.screen_w;
+}
+
+export fn pardes_frame_rows() u32 {
+ const s = &(state orelse return 0);
+ return s.core.screen_h;
+}
+
+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 = chromeTarget(s.core, 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);
+ s.effect_data.clearRetainingCapacity();
+ 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| {
+ putU32(s, @intCast(file.path.len));
+ appendEffect(s, file.path);
+ appendEffect(s, 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;
+ },
+ .lsp => |e| blk: {
+ effect_aux0 = e.id;
+ effect_aux1 = e.pane;
+ putEffect(s, e.arg.slice());
+ break :blk 8;
+ },
+ .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;
+ },
+ .quit => 10,
+ };
+}
+
+export fn pardes_effect_ptr() u32 {
+ const s = &(state orelse return 0);
+ return if (s.effect_data.items.len == 0) 0 else @intCast(@intFromPtr(s.effect_data.items.ptr));
+}
+
+export fn pardes_effect_len() u32 {
+ const s = &(state orelse return 0);
+ return @intCast(s.effect_data.items.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 putEffect(s: *State, bytes: []const u8) void {
+ s.effect_data.clearRetainingCapacity();
+ appendEffect(s, bytes);
+}
+
+fn appendEffect(s: *State, bytes: []const u8) void {
+ s.effect_data.appendSlice(gpa, bytes) catch {};
+}
+
+fn putU32(s: *State, value: u32) void {
+ var bytes: [4]u8 = undefined;
+ std.mem.writeInt(u32, &bytes, value, .little);
+ appendEffect(s, &bytes);
+}
+
+const Target = struct { col: u16, row: u16 };
+
+fn chromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?Target {
+ if (row < pardes.TOPBAR_H) return .{ .col = col, .row = row };
+ for (core.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const rect = core.rects[id];
+ if (row == rect.y and col >= rect.x and col < rect.x + rect.w and col < rect.x + pardes.config.GUTTER)
+ return .{ .col = col, .row = rect.y };
+ }
+ for (0..core.ncol -| 1) |column| {
+ const handle = core.col_x[column] + core.col_w[column] -| 1;
+ if (col == handle) return .{ .col = handle, .row = row };
+ }
+ for (0..core.ncol) |column| {
+ if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
+ for (0..core.col_n[column] -| 1) |index| {
+ const rect = core.rects[core.col_terms[column][index]];
+ const handle = rect.y + rect.h -| 1;
+ if (row == handle) return .{ .col = col, .row = handle };
+ }
+ }
+ for (core.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const rect = core.rects[id];
+ if (row == rect.y and col >= rect.x and col < rect.x + rect.w)
+ return .{ .col = col, .row = rect.y };
+ }
+ for (0..core.ncol -| 1) |column| {
+ const handle = core.col_x[column] + core.col_w[column] -| 1;
+ if (distance(col, handle) == 1) return .{ .col = handle, .row = row };
+ }
+ for (0..core.ncol) |column| {
+ if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
+ for (0..core.col_n[column] -| 1) |index| {
+ const rect = core.rects[core.col_terms[column][index]];
+ const handle = rect.y + rect.h -| 1;
+ if (distance(row, handle) == 1) return .{ .col = col, .row = handle };
+ }
+ }
+ return null;
+}
+
+fn distance(a: u16, b: u16) u16 {
+ return if (a > b) a - b else b - a;
+}