diff options
Diffstat (limited to 'src/web.zig')
| -rw-r--r-- | src/web.zig | 481 |
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; +} |
