From 24cc01e0559d2e88153a6dc5c32d2d2c89aee10d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 29 Nov 2022 12:24:46 -0800 Subject: [PATCH 1/6] move wasm main to src/, export an Atlas API We don't need the Atlas API for wasm at all, but its a good way to test things work incrementally and its a pretty cool thing to have as a wasm library really. --- build.zig | 8 +++---- src/font/Atlas.zig | 60 +++++++++++++++++++++++++++++++++++++++++++++- src/font/main.zig | 5 ++++ src/main_wasm.zig | 4 ++++ src/wasm.zig | 5 ++++ 5 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 src/main_wasm.zig create mode 100644 src/wasm.zig diff --git a/build.zig b/build.zig index eba845825..d74933803 100644 --- a/build.zig +++ b/build.zig @@ -106,11 +106,11 @@ pub fn build(b: *std.build.Builder) !void { b.installFile("dist/macos/Ghostty.icns", "Ghostty.app/Contents/Resources/Ghostty.icns"); } - // term.wasm + // wasm { const wasm = b.addSharedLibrary( - "ghostty-term", - "src/terminal/main_wasm.zig", + "ghostty-wasm", + "src/main_wasm.zig", .{ .unversioned = {} }, ); wasm.setTarget(.{ .cpu_arch = .wasm32, .os_tag = .freestanding }); @@ -122,7 +122,7 @@ pub fn build(b: *std.build.Builder) !void { wasm.addPackage(utf8proc.pkg); _ = try utf8proc.link(b, wasm); - const step = b.step("term-wasm", "Build the terminal.wasm library"); + const step = b.step("wasm", "Build the wasm library"); step.dependOn(&wasm.step); } diff --git a/src/font/Atlas.zig b/src/font/Atlas.zig index 1bb8fb5f5..9bce5e7cb 100644 --- a/src/font/Atlas.zig +++ b/src/font/Atlas.zig @@ -76,7 +76,7 @@ pub const Error = error{ /// A region within the texture atlas. These can be acquired using the /// "reserve" function. A region reservation is required to write data. -pub const Region = struct { +pub const Region = extern struct { x: u32, y: u32, width: u32, @@ -298,6 +298,64 @@ pub fn clear(self: *Atlas) void { self.nodes.appendAssumeCapacity(.{ .x = 1, .y = 1, .width = self.size - 2 }); } +/// The wasm-compatible API. This lacks documentation unless the API differs +/// from the standard Zig API. To learn what a function does, just look one +/// level deeper to what Zig function is called and read the documentation there. +/// +/// To export this from Zig, use `usingnamespace Wasm` in some top-level +/// space and it will be exported. +pub const Wasm = struct { + // If you're copying this file (Atlas.zig) out to a separate project, + // just replace this with the allocator you want to use. + const wasm = @import("../wasm.zig"); + const alloc = wasm.alloc; + + const FormatInt = @typeInfo(Format).Enum.tag_type; + export const ATLAS_FORMAT_GREYSCALE: u8 = @enumToInt(Format.greyscale); + export const ATLAS_FORMAT_RGB: u8 = @enumToInt(Format.rgb); + export const ATLAS_FORMAT_RGBA: u8 = @enumToInt(Format.rgba); + + export fn atlas_new(size: u32, format: u8) ?*Atlas { + const atlas = init( + alloc, + size, + @intToEnum(Format, @intCast(FormatInt, format)), + ) catch return null; + const result = alloc.create(Atlas) catch return null; + result.* = atlas; + return result; + } + + export fn atlas_reserve(self: *Atlas, width: u32, height: u32) Region { + return self.reserve(alloc, width, height) catch .{ + .x = 0, + .y = 0, + .width = 0, + .height = 0, + }; + } + + export fn atlas_set(self: *Atlas, reg: Region, data: [*]const u8, len: usize) void { + self.set(reg, data[0..len]); + } + + export fn atlas_grow(self: *Atlas, size_new: u32) bool { + self.grow(alloc, size_new) catch return false; + return true; + } + + export fn atlas_clear(self: *Atlas) void { + self.clear(); + } + + export fn atlas_free(ptr: ?*Atlas) void { + if (ptr) |v| { + v.deinit(alloc); + alloc.destroy(v); + } + } +}; + test "exact fit" { const alloc = testing.allocator; var atlas = try init(alloc, 34, .greyscale); // +2 for 1px border diff --git a/src/font/main.zig b/src/font/main.zig index b6747faea..7f93c4299 100644 --- a/src/font/main.zig +++ b/src/font/main.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const build_options = @import("build_options"); pub const Atlas = @import("Atlas.zig"); @@ -16,6 +17,10 @@ pub const Sprite = sprite.Sprite; pub const Descriptor = discovery.Descriptor; pub const Discover = discovery.Discover; +pub usingnamespace if (builtin.target.isWasm()) struct { + pub usingnamespace Atlas.Wasm; +} else struct {}; + /// Build options pub const options: struct { backend: Backend, diff --git a/src/main_wasm.zig b/src/main_wasm.zig new file mode 100644 index 000000000..06fbe4456 --- /dev/null +++ b/src/main_wasm.zig @@ -0,0 +1,4 @@ +// This is the main file for the WASM module. The WASM module has to +// export a C ABI compatible API. + +pub usingnamespace @import("font/main.zig"); diff --git a/src/wasm.zig b/src/wasm.zig new file mode 100644 index 000000000..96b53e425 --- /dev/null +++ b/src/wasm.zig @@ -0,0 +1,5 @@ +//! This file contains helpers for wasm compilation. +const std = @import("std"); + +/// The allocator to use in wasm environments. +pub const alloc = std.heap.page_allocator; From 3a4d8814449c141e44c465345bbb328fcf33ebec Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 30 Nov 2022 15:00:39 -0800 Subject: [PATCH 2/6] wasm allocator, consider "host-owned" pointers --- src/wasm.zig | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/src/wasm.zig b/src/wasm.zig index 96b53e425..d496e404e 100644 --- a/src/wasm.zig +++ b/src/wasm.zig @@ -1,5 +1,114 @@ //! This file contains helpers for wasm compilation. const std = @import("std"); +const builtin = @import("builtin"); + +comptime { + if (!builtin.target.isWasm()) { + @compileError("wasm.zig should only be analyzed for wasm32 builds"); + } +} /// The allocator to use in wasm environments. -pub const alloc = std.heap.page_allocator; +/// +/// The return values of this should NOT be sent to the host environment +/// unless toHostOwned is called on them. In this case, the caller is expected +/// to call free. If a pointer is NOT host-owned, then the wasm module is +/// expected to call the normal alloc.free/destroy functions. +/// +/// TODO: we should NOT be using page_allocator because we're getting +/// full 64kb pages for every allocation. I plan on changing this to the +/// new stdlib wasm allocator once that is merged and available. +pub const alloc = if (builtin.is_test) + std.testing.allocator +else + std.heap.page_allocator; + +/// For host-owned allocations: +/// We need to keep track of our own pointer lengths because Zig +/// allocators usually don't do this and we need to be able to send +/// a direct pointer back to the host system. A more appropriate thing +/// to do would be to probably make a custom allocator that keeps track +/// of size. +var allocs: std.AutoHashMapUnmanaged([*]u8, usize) = .{}; + +/// Allocate len bytes and return a pointer to the memory in the host. +/// The data is not zeroed. +pub export fn malloc(len: usize) ?[*]u8 { + return alloc_(len) catch return null; +} + +/// Free an allocation from malloc. +pub export fn free(ptr: ?[*]u8) void { + if (ptr) |v| { + if (allocs.get(v)) |len| { + const slice = v[0..len]; + alloc.free(slice); + _ = allocs.remove(v); + } + } +} + +/// Convert an allocated pointer of any type to a host-owned pointer. +/// This pushes the responsibility to free it to the host. The returned +/// pointer will match the pointer but is typed correctly for returning +/// to the host. +pub fn toHostOwned(ptr: anytype) ![*]u8 { + // Convert our pointer to a byte array + const info = @typeInfo(@TypeOf(ptr)).Pointer; + const T = info.child; + const size = @sizeOf(T); + const casted = @intToPtr([*]u8, @ptrToInt(ptr)); + + // Store the information about it + try allocs.putNoClobber(alloc, casted, size); + errdefer _ = allocs.remove(casted); + + return casted; +} + +/// Returns true if the value is host owned. +pub fn isHostOwned(ptr: anytype) bool { + const casted = @intToPtr([*]u8, @ptrToInt(ptr)); + return allocs.contains(casted); +} + +/// Convert a pointer back to a module-owned value. The caller is expected +/// to cast or have the valid pointer for alloc calls. +pub fn toModuleOwned(ptr: anytype) void { + const casted = @intToPtr([*]u8, @ptrToInt(ptr)); + _ = allocs.remove(casted); +} + +fn alloc_(len: usize) ![*]u8 { + // Create the allocation + const slice = try alloc.alloc(u8, len); + errdefer alloc.free(slice); + + // Store the size so we can deallocate later + try allocs.putNoClobber(alloc, slice.ptr, slice.len); + errdefer _ = allocs.remove(slice.ptr); + + return slice.ptr; +} + +test "basics" { + const testing = std.testing; + var buf = malloc(32).?; + try testing.expect(allocs.size == 1); + free(buf); + try testing.expect(allocs.size == 0); +} + +test "toHostOwned" { + const testing = std.testing; + + const Point = struct { x: u32 = 0, y: u32 = 0 }; + var p = try alloc.create(Point); + errdefer alloc.destroy(p); + const ptr = try toHostOwned(p); + try testing.expect(allocs.size == 1); + try testing.expect(isHostOwned(p)); + try testing.expect(isHostOwned(ptr)); + free(ptr); + try testing.expect(allocs.size == 0); +} From 6b101c22930673b781f351936664cc1e7f59d392 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 30 Nov 2022 15:04:45 -0800 Subject: [PATCH 3/6] support unit testing wasm via wasmtime --- build.zig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/build.zig b/build.zig index d74933803..7232f1d88 100644 --- a/build.zig +++ b/build.zig @@ -73,6 +73,9 @@ pub fn build(b: *std.build.Builder) !void { "Build and install test executables with 'build'", ) orelse false; + // We can use wasmtime to test wasm + b.enable_wasmtime = true; + // Add our benchmarks try benchSteps(b, target, mode); @@ -124,6 +127,15 @@ pub fn build(b: *std.build.Builder) !void { const step = b.step("wasm", "Build the wasm library"); step.dependOn(&wasm.step); + + // We support tests via wasmtime. wasmtime uses WASI so this + // isn't an exact match to our freestanding target above but + // it lets us test some basic functionality. + const test_step = b.step("test-wasm", "Run all tests for wasm"); + const main_test = b.addTest("src/main_wasm.zig"); + main_test.setTarget(.{ .cpu_arch = .wasm32, .os_tag = .wasi }); + main_test.addOptions("build_options", exe_options); + test_step.dependOn(&main_test.step); } // Run From b858aea1241206fa2d2c510a0ef13017a31d7b6d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 30 Nov 2022 15:05:09 -0800 Subject: [PATCH 4/6] Start scaffolding web_canvas backend --- example/index.html | 19 +++++++------ src/font/Atlas.zig | 54 +++++++++++++++++++++++------------- src/font/DeferredFace.zig | 11 ++++++++ src/font/face.zig | 2 ++ src/font/face/web_canvas.zig | 22 +++++++++++++++ src/font/main.zig | 40 ++++++++++++++++++++------ src/main_wasm.zig | 4 +++ 7 files changed, 117 insertions(+), 35 deletions(-) create mode 100644 src/font/face/web_canvas.zig diff --git a/example/index.html b/example/index.html index f93da37d7..314a78cb1 100644 --- a/example/index.html +++ b/example/index.html @@ -13,21 +13,24 @@ } }; - fetch('ghostty-term.wasm').then(response => + fetch('ghostty-wasm.wasm').then(response => response.arrayBuffer() ).then(bytes => WebAssembly.instantiate(bytes, importObject) ).then(results => { const { - terminal_new, - terminal_free, - terminal_print, + atlas_new, + atlas_free, + atlas_reserve, + free, + memory, } = results.instance.exports; - const term = terminal_new(80, 80); - console.log(term); - terminal_free(term); - terminal_print('a'); + const atlas = atlas_new(512, 0); + const reg = atlas_reserve(atlas, 10, 10); + console.log(reg); + free(reg); + atlas_free(atlas); }); diff --git a/src/font/Atlas.zig b/src/font/Atlas.zig index 9bce5e7cb..4259d5dda 100644 --- a/src/font/Atlas.zig +++ b/src/font/Atlas.zig @@ -49,10 +49,10 @@ modified: bool = false, /// updated in-place. resized: bool = false, -pub const Format = enum { - greyscale, - rgb, - rgba, +pub const Format = enum(u8) { + greyscale = 0, + rgb = 1, + rgba = 2, pub fn depth(self: Format) u8 { return switch (self) { @@ -310,33 +310,33 @@ pub const Wasm = struct { const wasm = @import("../wasm.zig"); const alloc = wasm.alloc; - const FormatInt = @typeInfo(Format).Enum.tag_type; - export const ATLAS_FORMAT_GREYSCALE: u8 = @enumToInt(Format.greyscale); - export const ATLAS_FORMAT_RGB: u8 = @enumToInt(Format.rgb); - export const ATLAS_FORMAT_RGBA: u8 = @enumToInt(Format.rgba); - export fn atlas_new(size: u32, format: u8) ?*Atlas { const atlas = init( alloc, size, - @intToEnum(Format, @intCast(FormatInt, format)), + @intToEnum(Format, format), ) catch return null; const result = alloc.create(Atlas) catch return null; result.* = atlas; return result; } - export fn atlas_reserve(self: *Atlas, width: u32, height: u32) Region { - return self.reserve(alloc, width, height) catch .{ - .x = 0, - .y = 0, - .width = 0, - .height = 0, - }; + /// The return value for this should be freed by the caller with "free". + export fn atlas_reserve(self: *Atlas, width: u32, height: u32) ?*Region { + return atlas_reserve_(self, width, height) catch return null; } - export fn atlas_set(self: *Atlas, reg: Region, data: [*]const u8, len: usize) void { - self.set(reg, data[0..len]); + fn atlas_reserve_(self: *Atlas, width: u32, height: u32) !*Region { + const reg = try self.reserve(alloc, width, height); + const result = try alloc.create(Region); + errdefer alloc.destroy(result); + _ = try wasm.toHostOwned(result); + result.* = reg; + return result; + } + + export fn atlas_set(self: *Atlas, reg: *Region, data: [*]const u8, len: usize) void { + self.set(reg.*, data[0..len]); } export fn atlas_grow(self: *Atlas, size_new: u32) bool { @@ -354,6 +354,22 @@ pub const Wasm = struct { alloc.destroy(v); } } + + test "happy path" { + var atlas = atlas_new(512, @enumToInt(Format.greyscale)).?; + defer atlas_free(atlas); + + const reg = atlas_reserve(atlas, 2, 2).?; + defer alloc.destroy(reg); + try testing.expect(wasm.isHostOwned(reg)); + defer wasm.toModuleOwned(reg); + try testing.expect(reg.width > 0); + + const data = &[_]u8{ 1, 2, 3, 4 }; + try testing.expect(!atlas.modified); + atlas_set(atlas, reg, data, data.len); + try testing.expect(atlas.modified); + } }; test "exact fit" { diff --git a/src/font/DeferredFace.zig b/src/font/DeferredFace.zig index 64a9f6e94..9ae816fa2 100644 --- a/src/font/DeferredFace.zig +++ b/src/font/DeferredFace.zig @@ -68,6 +68,8 @@ pub fn deinit(self: *DeferredFace) void { .fontconfig_freetype => if (self.fc) |*fc| fc.deinit(), .coretext => if (self.ct) |*ct| ct.deinit(), .freetype => {}, + // TODO + .web_canvas => unreachable, } self.* = undefined; } @@ -90,6 +92,9 @@ pub fn name(self: DeferredFace) ![:0]const u8 { }, .freetype => {}, + + // TODO + .web_canvas => unreachable, } return "TODO: built-in font names"; @@ -122,6 +127,9 @@ pub fn load( return; }, + // TODO + .web_canvas => unreachable, + // Unreachable because we must be already loaded or have the // proper configuration for one of the other deferred mechanisms. .freetype => unreachable, @@ -245,6 +253,9 @@ pub fn hasCodepoint(self: DeferredFace, cp: u32, p: ?Presentation) bool { } }, + // TODO + .web_canvas => unreachable, + .freetype => {}, } diff --git a/src/font/face.zig b/src/font/face.zig index d4d4ba1d2..3986d7aa9 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -2,12 +2,14 @@ const builtin = @import("builtin"); const options = @import("main.zig").options; const freetype = @import("face/freetype.zig"); const coretext = @import("face/coretext.zig"); +const web_canvas = @import("face/web_canvas.zig"); /// Face implementation for the compile options. pub const Face = switch (options.backend) { .fontconfig_freetype => freetype.Face, .coretext => freetype.Face, //.coretext => coretext.Face, + .web_canvas => web_canvas.Face, else => unreachable, }; diff --git a/src/font/face/web_canvas.zig b/src/font/face/web_canvas.zig new file mode 100644 index 000000000..a09e2e105 --- /dev/null +++ b/src/font/face/web_canvas.zig @@ -0,0 +1,22 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const freetype = @import("freetype"); +const assert = std.debug.assert; +const testing = std.testing; +const Allocator = std.mem.Allocator; +const font = @import("../main.zig"); +const Glyph = font.Glyph; +const Library = font.Library; +const Presentation = font.Presentation; +const convert = @import("freetype_convert.zig"); + +const log = std.log.scoped(.font_face); + +pub const Face = struct { + /// The presentation for this font. This is a heuristic since fonts don't have + /// a way to declare this. We just assume a font with color is an emoji font. + presentation: Presentation, + + /// Metrics for this font face. These are useful for renderers. + metrics: font.face.Metrics, +}; diff --git a/src/font/main.zig b/src/font/main.zig index 7f93c4299..4226f732c 100644 --- a/src/font/main.zig +++ b/src/font/main.zig @@ -25,12 +25,7 @@ pub usingnamespace if (builtin.target.isWasm()) struct { pub const options: struct { backend: Backend, } = .{ - .backend = if (build_options.coretext) - .coretext - else if (build_options.fontconfig) - .fontconfig_freetype - else - .freetype, + .backend = Backend.default(), }; pub const Backend = enum { @@ -43,14 +38,37 @@ pub const Backend = enum { /// CoreText for both font discovery and rendering (macOS). coretext, + /// Use the browser font system and the Canvas API (wasm). This limits + /// the available fonts to browser fonts (anything Canvas natively + /// supports). + web_canvas, + + /// Returns the default backend for a build environment. This is + /// meant to be called at comptime. + pub fn default() Backend { + // Wasm only supports browser at the moment. + if (builtin.target.isWasm()) return .web_canvas; + + return if (build_options.coretext) + .coretext + else if (build_options.fontconfig) + .fontconfig_freetype + else + .freetype; + } + /// Helper that just returns true if we should be using freetype. This /// is used for tests. pub fn freetype(self: Backend) bool { return switch (self) { .freetype, .fontconfig_freetype => true, - .coretext => false, + .coretext, .web_canvas => false, }; } + + test "default can run at comptime" { + _ = comptime default(); + } }; /// The styles that a family can take. @@ -71,5 +89,11 @@ pub const Presentation = enum(u1) { pub const sprite_index = Group.FontIndex.initSpecial(.sprite); test { - @import("std").testing.refAllDecls(@This()); + // For non-wasm we want to test everything we can + if (!comptime builtin.target.isWasm()) { + @import("std").testing.refAllDecls(@This()); + return; + } + + _ = Atlas; } diff --git a/src/main_wasm.zig b/src/main_wasm.zig index 06fbe4456..f2bcc9c60 100644 --- a/src/main_wasm.zig +++ b/src/main_wasm.zig @@ -1,4 +1,8 @@ // This is the main file for the WASM module. The WASM module has to // export a C ABI compatible API. +pub usingnamespace @import("wasm.zig"); pub usingnamespace @import("font/main.zig"); + +// TODO: temporary while we dev this +pub usingnamespace @import("font/face/web_canvas.zig"); From c32219554a4578ba9bb1c84aff5343796558aaa2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 30 Nov 2022 17:47:32 -0800 Subject: [PATCH 5/6] blank out web canvas impl --- example/index.html | 1 - src/font/face/web_canvas.zig | 13 +------------ src/main_wasm.zig | 3 --- 3 files changed, 1 insertion(+), 16 deletions(-) diff --git a/example/index.html b/example/index.html index 314a78cb1..24acd9527 100644 --- a/example/index.html +++ b/example/index.html @@ -28,7 +28,6 @@ const atlas = atlas_new(512, 0); const reg = atlas_reserve(atlas, 10, 10); - console.log(reg); free(reg); atlas_free(atlas); }); diff --git a/src/font/face/web_canvas.zig b/src/font/face/web_canvas.zig index a09e2e105..a2969ef68 100644 --- a/src/font/face/web_canvas.zig +++ b/src/font/face/web_canvas.zig @@ -1,22 +1,11 @@ const std = @import("std"); const builtin = @import("builtin"); -const freetype = @import("freetype"); const assert = std.debug.assert; -const testing = std.testing; const Allocator = std.mem.Allocator; const font = @import("../main.zig"); -const Glyph = font.Glyph; -const Library = font.Library; -const Presentation = font.Presentation; -const convert = @import("freetype_convert.zig"); const log = std.log.scoped(.font_face); pub const Face = struct { - /// The presentation for this font. This is a heuristic since fonts don't have - /// a way to declare this. We just assume a font with color is an emoji font. - presentation: Presentation, - - /// Metrics for this font face. These are useful for renderers. - metrics: font.face.Metrics, + // TODO }; diff --git a/src/main_wasm.zig b/src/main_wasm.zig index f2bcc9c60..5c4bdbf45 100644 --- a/src/main_wasm.zig +++ b/src/main_wasm.zig @@ -3,6 +3,3 @@ pub usingnamespace @import("wasm.zig"); pub usingnamespace @import("font/main.zig"); - -// TODO: temporary while we dev this -pub usingnamespace @import("font/face/web_canvas.zig"); From 1840829e9e2828e86762d92fcdf093dbeb0a2e88 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 3 Dec 2022 21:34:14 -0800 Subject: [PATCH 6/6] add node for web stuff --- nix/devshell.nix | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nix/devshell.nix b/nix/devshell.nix index 3fe3ff966..3326f83d6 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -2,6 +2,7 @@ , gdb , glxinfo +, nodejs , parallel , pkg-config , python @@ -64,6 +65,9 @@ in mkShell rec { zig zip + # For web and wasm stuff + nodejs + # Testing gdb parallel