aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/font/face/web_canvas.zig
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-04 19:22:04 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-05 01:23:06 +0300
commit41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch)
tree3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/font/face/web_canvas.zig
parent55c138263e190a1e79d4b997bfb9397306c0ec1f (diff)
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/font/face/web_canvas.zig')
-rw-r--r--vendor/libghostty-vt/src/font/face/web_canvas.zig561
1 files changed, 561 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/font/face/web_canvas.zig b/vendor/libghostty-vt/src/font/face/web_canvas.zig
new file mode 100644
index 00000000..b4f9f5d5
--- /dev/null
+++ b/vendor/libghostty-vt/src/font/face/web_canvas.zig
@@ -0,0 +1,561 @@
+const std = @import("std");
+const assert = @import("../../quirks.zig").inlineAssert;
+const testing = std.testing;
+const Allocator = std.mem.Allocator;
+const js = @import("zig-js");
+const font = @import("../main.zig");
+
+const log = std.log.scoped(.font_face);
+
+pub const Face = struct {
+ /// See graphemes field for more details.
+ const grapheme_start: u32 = 0x10FFFF + 1;
+ const grapheme_end: u32 = std.math.maxInt(u32);
+
+ /// The web canvas face makes use of an allocator when interacting
+ /// with the JS environment.
+ alloc: Allocator,
+
+ /// The CSS "font" attribute, excluding size.
+ font_str: []const u8,
+
+ /// The size we currently have set.
+ size: font.face.DesiredSize,
+
+ /// The presentation for this font.
+ presentation: font.Presentation,
+
+ /// Metrics for this font face. These are useful for renderers.
+ metrics: font.Metrics,
+
+ /// The canvas element that we will reuse to render glyphs
+ canvas: js.Object,
+
+ /// The map to store multi-codepoint grapheme clusters that are rendered.
+ /// We use 1 above the maximum unicode codepoint up to the max 32-bit
+ /// unsigned integer to store the "glyph index" for graphemes.
+ grapheme_to_glyph: std.StringHashMapUnmanaged(u32) = .{},
+ glyph_to_grapheme: std.AutoHashMapUnmanaged(u32, []u8) = .{},
+ grapheme_next: u32 = grapheme_start,
+
+ /// Initialize a web canvas font with a "raw" value. The "raw" value can
+ /// be any valid value for a CSS "font" property EXCLUDING the size. The
+ /// size is always added via the `size` parameter.
+ ///
+ /// The raw value is copied so the caller can free it after it is gone.
+ ///
+ /// The presentation is given here directly because the browser gives
+ /// us no easy way to determine the presentation we want for this font.
+ /// Callers should just tell us what to expect.
+ pub fn initNamed(
+ alloc: Allocator,
+ raw: []const u8,
+ size: font.face.DesiredSize,
+ presentation: font.Presentation,
+ ) !Face {
+ // Copy our font string because we're going to have to reuse it.
+ const font_str = try alloc.dupe(u8, raw);
+ errdefer alloc.free(font_str);
+
+ // Create our canvas that we're going to continue to reuse.
+ const doc = try js.global.get(js.Object, "document");
+ defer doc.deinit();
+ const canvas = try doc.call(js.Object, "createElement", .{js.string("canvas")});
+ errdefer canvas.deinit();
+
+ var result = Face{
+ .alloc = alloc,
+ .font_str = font_str,
+ .size = size,
+ .presentation = presentation,
+
+ .canvas = canvas,
+
+ // We're going to calculate these right after initialization.
+ .metrics = undefined,
+ };
+ try result.calcMetrics();
+
+ log.debug("face initialized: {s}", .{raw});
+ return result;
+ }
+
+ pub fn deinit(self: *Face) void {
+ self.alloc.free(self.font_str);
+ self.grapheme_to_glyph.deinit(self.alloc);
+ {
+ var it = self.glyph_to_grapheme.valueIterator();
+ while (it.next()) |value| self.alloc.free(value.*);
+ self.glyph_to_grapheme.deinit(self.alloc);
+ }
+ self.canvas.deinit();
+ self.* = undefined;
+ }
+
+ /// Resize the font in-place. If this succeeds, the caller is responsible
+ /// for clearing any glyph caches, font atlas data, etc.
+ pub fn setSize(self: *Face, size: font.face.DesiredSize) !void {
+ const old = self.size;
+ self.size = size;
+ errdefer self.size = old;
+ try self.calcMetrics();
+ }
+
+ /// Returns the glyph index for the given Unicode code point. For canvas,
+ /// we support every glyph and the ID is just the codepoint since we don't
+ /// have access to the underlying tables anyways. We let the browser deal
+ /// with bad codepoints.
+ pub fn glyphIndex(self: Face, cp: u32) ?u32 {
+ // If this is a multi-codepoint grapheme then we only check if
+ // we actually know about it.
+ if (cp >= grapheme_start) {
+ if (!self.glyph_to_grapheme.contains(cp)) return null;
+ }
+
+ // Render the glyph to determine if it is colored or not. We
+ // have to do this because the browser will always try to render
+ // whatever we give it and we have no API to determine color.
+ //
+ // We don't want to say yes to the wrong presentation because
+ // it will go into the wrong Atlas.
+ const p: font.Presentation = if (cp <= 255) .text else p: {
+ break :p self.glyphPresentation(cp) catch {
+ // In this case, we assume we are unable to render
+ // this glyph and therefore just say we don't support it.
+ return null;
+ };
+ };
+ if (p != self.presentation) return null;
+
+ return cp;
+ }
+
+ /// This determines the presentation of the glyph by literally
+ /// inspecting the image data to look for any color. This isn't
+ /// super performant but we don't have a better choice given the
+ /// canvas APIs.
+ fn glyphPresentation(
+ self: Face,
+ cp: u32,
+ ) !font.Presentation {
+ // Render the glyph
+ var render = try self.renderGlyphInternal(self.alloc, cp);
+ defer render.deinit();
+
+ // Inspect the image data for any non-zeros in the RGB value.
+ // NOTE(perf): this is an easy candidate for SIMD.
+ var i: usize = 0;
+ while (i < render.bitmap.len) : (i += 4) {
+ if (render.bitmap[i] > 0 or
+ render.bitmap[i + 1] > 0 or
+ render.bitmap[i + 2] > 0) return .emoji;
+ }
+
+ return .text;
+ }
+
+ /// Returns the glyph index for the given grapheme cluster. The same
+ /// cluster will always map to the same glyph index. This does not render
+ /// the grapheme at this time, only reserves the index.
+ pub fn graphemeGlyphIndex(self: *Face, cluster: []const u8) error{OutOfMemory}!u32 {
+ // If we already have this stored then return it
+ const gop = try self.grapheme_to_glyph.getOrPut(self.alloc, cluster);
+ if (gop.found_existing) return gop.value_ptr.*;
+ errdefer _ = self.grapheme_to_glyph.remove(cluster);
+
+ // We don't have it stored. Ensure we have space to store. The
+ // next will be "0" if we're out of space due to unsigned int wrapping.
+ if (self.grapheme_next == 0) return error.OutOfMemory;
+
+ // Copy the cluster for our reverse mapping
+ const copy = try self.alloc.dupe(u8, cluster);
+ errdefer self.alloc.free(copy);
+
+ // Grow space for the reverse mapping
+ try self.glyph_to_grapheme.ensureUnusedCapacity(self.alloc, 1);
+
+ // Store it
+ gop.value_ptr.* = self.grapheme_next;
+ self.glyph_to_grapheme.putAssumeCapacity(self.grapheme_next, copy);
+
+ self.grapheme_next +%= 1;
+ return gop.value_ptr.*;
+ }
+
+ /// Render a glyph using the glyph index. The rendered glyph is stored
+ /// in the given texture atlas.
+ pub fn renderGlyph(
+ self: Face,
+ alloc: Allocator,
+ atlas: *font.Atlas,
+ glyph_index: u32,
+ opts: font.face.RenderOptions,
+ ) !font.Glyph {
+ _ = opts;
+
+ var render = try self.renderGlyphInternal(alloc, glyph_index);
+ defer render.deinit();
+
+ // Convert the format of the bitmap if necessary
+ const bitmap_formatted: []u8 = switch (atlas.format) {
+ // Bitmap is already in RGBA
+ .rgba => render.bitmap,
+
+ // Convert down to A8
+ .grayscale => a8: {
+ assert(@mod(render.bitmap.len, 4) == 0);
+ var bitmap_a8 = try alloc.alloc(u8, render.bitmap.len / 4);
+ errdefer alloc.free(bitmap_a8);
+ var i: usize = 0;
+ while (i < bitmap_a8.len) : (i += 1) {
+ bitmap_a8[i] = render.bitmap[(i * 4) + 3];
+ }
+
+ break :a8 bitmap_a8;
+ },
+
+ else => return error.UnsupportedAtlasFormat,
+ };
+ defer if (bitmap_formatted.ptr != render.bitmap.ptr) {
+ alloc.free(bitmap_formatted);
+ };
+
+ // Put it in our atlas
+ const region = try atlas.reserve(alloc, render.width, render.height);
+ if (region.width > 0 and region.height > 0) {
+ atlas.set(region, bitmap_formatted);
+ }
+
+ return font.Glyph{
+ .width = render.width,
+ .height = render.height,
+ // TODO: this can't be right
+ .offset_x = 0,
+ .offset_y = 0,
+ .atlas_x = region.x,
+ .atlas_y = region.y,
+ };
+ }
+
+ /// Calculate the metrics associated with a given face.
+ fn calcMetrics(self: *Face) !void {
+ const ctx = try self.context();
+ defer ctx.deinit();
+
+ // Cell width is the width of our M text
+ const cell_width: f32 = cell_width: {
+ const metrics = try ctx.call(js.Object, "measureText", .{js.string("M")});
+ defer metrics.deinit();
+
+ // We prefer the bounding box since it is tighter but certain
+ // text such as emoji do not have a bounding box set so we use
+ // the full run width instead.
+ const bounding_right = try metrics.get(f32, "actualBoundingBoxRight");
+ if (bounding_right > 0) break :cell_width bounding_right;
+ break :cell_width try metrics.get(f32, "width");
+ };
+
+ // To get the cell height we render a high and low character and get
+ // the total of the ascent and descent. This should equal our
+ // pixel height but this is a more surefire way to get it.
+ const height_metrics = try ctx.call(js.Object, "measureText", .{js.string("M_")});
+ defer height_metrics.deinit();
+ const asc = try height_metrics.get(f32, "actualBoundingBoxAscent");
+ const desc = try height_metrics.get(f32, "actualBoundingBoxDescent");
+ const cell_height = asc + desc;
+ const cell_baseline = desc;
+
+ // There isn't a declared underline position for canvas measurements
+ // so we just go 1 under the cell height to match freetype logic
+ // at this time (our freetype logic).
+ const underline_position = cell_height - 1;
+ const underline_thickness: f32 = 1;
+
+ const result = font.Metrics{
+ .cell_width = @intFromFloat(cell_width),
+ .cell_height = @intFromFloat(cell_height),
+ .cell_baseline = @intFromFloat(cell_baseline),
+ .underline_position = @intFromFloat(underline_position),
+ .underline_thickness = @intFromFloat(underline_thickness),
+ .strikethrough_position = @intFromFloat(underline_position),
+ .strikethrough_thickness = @intFromFloat(underline_thickness),
+ };
+
+ self.metrics = result;
+ log.debug("metrics font={s} value={}", .{ self.font_str, self.metrics });
+ }
+
+ /// Returns the 2d context configured for drawing
+ fn context(self: Face) !js.Object {
+ // This will return the same context on subsequent calls so it
+ // is important to reset it.
+ const ctx = try self.canvas.call(js.Object, "getContext", .{js.string("2d")});
+ errdefer ctx.deinit();
+
+ // Clear the canvas
+ {
+ const width = try self.canvas.get(f64, "width");
+ const height = try self.canvas.get(f64, "height");
+ try ctx.call(void, "clearRect", .{ 0, 0, width, height });
+ }
+
+ // Set our context font
+ const font_val = try std.fmt.allocPrint(
+ self.alloc,
+ "{d}px {s}",
+ .{ self.size.points, self.font_str },
+ );
+ defer self.alloc.free(font_val);
+ try ctx.set("font", js.string(font_val));
+
+ // If the font property didn't change, then the font set didn't work.
+ // We do this check because it is very easy to put an invalid font
+ // in and this at least makes it show up in the logs.
+ const check = try ctx.getAlloc(js.String, self.alloc, "font");
+ defer self.alloc.free(check);
+ if (!std.mem.eql(u8, font_val, check)) {
+ log.warn("canvas font didn't set, fonts may be broken, expected={s} got={s}", .{
+ font_val,
+ check,
+ });
+ }
+
+ return ctx;
+ }
+
+ /// An internal (web-canvas-only) format for rendered glyphs
+ /// since we do render passes in multiple different situations.
+ const RenderedGlyph = struct {
+ alloc: Allocator,
+ metrics: js.Object,
+ width: u32,
+ height: u32,
+ bitmap: []u8,
+
+ pub fn deinit(self: *RenderedGlyph) void {
+ self.metrics.deinit();
+ self.alloc.free(self.bitmap);
+ self.* = undefined;
+ }
+ };
+
+ /// Shared logic for rendering a glyph.
+ fn renderGlyphInternal(
+ self: Face,
+ alloc: Allocator,
+ glyph_index: u32,
+ ) !RenderedGlyph {
+ // Encode our glyph to UTF-8 so we can build a JS string out of it.
+ var utf8: [4]u8 = undefined;
+ const glyph_str = glyph_str: {
+ // If we are a normal glyph then we are a single codepoint and
+ // we just UTF8 encode it as-is.
+ if (glyph_index < grapheme_start) {
+ const utf8_len = try std.unicode.utf8Encode(@intCast(glyph_index), &utf8);
+ break :glyph_str js.string(utf8[0..utf8_len]);
+ }
+
+ // We are a multi-codepoint glyph so we have to read the glyph
+ // from the map and it is already utf8 encoded.
+ const slice = self.glyph_to_grapheme.get(glyph_index) orelse
+ return error.UnknownGraphemeCluster;
+ break :glyph_str js.string(slice);
+ };
+
+ // Get our drawing context
+ const measure_ctx = try self.context();
+ defer measure_ctx.deinit();
+
+ // Get the width and height of the render
+ const metrics = try measure_ctx.call(js.Object, "measureText", .{glyph_str});
+ errdefer metrics.deinit();
+ const width: u32 = @as(u32, @intFromFloat(@ceil(width: {
+ // We prefer the bounding box since it is tighter but certain
+ // text such as emoji do not have a bounding box set so we use
+ // the full run width instead.
+ const bounding_right = try metrics.get(f32, "actualBoundingBoxRight");
+ if (bounding_right > 0) break :width bounding_right;
+ break :width try metrics.get(f32, "width");
+ }))) + 1;
+
+ const left = try metrics.get(f32, "actualBoundingBoxLeft");
+ const asc = try metrics.get(f32, "actualBoundingBoxAscent");
+ const desc = try metrics.get(f32, "actualBoundingBoxDescent");
+
+ // On Firefox on Linux, the bounding box is broken in some cases for
+ // ideographic glyphs (such as emoji). We detect this and behave
+ // differently.
+ const broken_bbox = asc + desc < 0.001;
+
+ // Height is our ascender + descender for this char
+ const height = if (!broken_bbox) @as(u32, @intFromFloat(@ceil(asc + desc))) + 1 else width;
+
+ // Note: width and height both get "+ 1" added to them above. This
+ // is important so that there is a 1px border around the glyph to avoid
+ // any clipping in the atlas.
+
+ // Resize canvas to match the glyph size exactly
+ {
+ try self.canvas.set("width", width);
+ try self.canvas.set("height", height);
+
+ const width_str = try std.fmt.allocPrint(alloc, "{d}px", .{width});
+ defer alloc.free(width_str);
+ const height_str = try std.fmt.allocPrint(alloc, "{d}px", .{height});
+ defer alloc.free(height_str);
+
+ const style = try self.canvas.get(js.Object, "style");
+ defer style.deinit();
+ try style.set("width", js.string(width_str));
+ try style.set("height", js.string(height_str));
+ }
+
+ // Reload our context since we resized the canvas
+ const ctx = try self.context();
+ defer ctx.deinit();
+
+ // For the broken bounding box case we render ideographic baselines
+ // so that we just render the glyph fully in the box with no offsets.
+ if (broken_bbox) {
+ try ctx.set("textBaseline", js.string("ideographic"));
+ }
+
+ // Draw background
+ try ctx.set("fillStyle", js.string("transparent"));
+ try ctx.call(void, "fillRect", .{
+ 0,
+ 0,
+ width,
+ height,
+ });
+
+ // Draw glyph
+ try ctx.set("fillStyle", js.string("black"));
+ try ctx.call(void, "fillText", .{
+ glyph_str,
+ left + 1,
+ if (!broken_bbox) asc + 1 else @as(f32, @floatFromInt(height)),
+ });
+
+ // Read the image data and get it into a []u8 on our side
+ const bitmap: []u8 = bitmap: {
+ // Read the raw bitmap data and get the "data" value which is a
+ // Uint8ClampedArray.
+ const data = try ctx.call(js.Object, "getImageData", .{ 0, 0, width, height });
+ defer data.deinit();
+ const src_array = try data.get(js.Object, "data");
+ defer src_array.deinit();
+
+ // Allocate our local memory to copy the data to.
+ const len = try src_array.get(u32, "length");
+ const bitmap = try alloc.alloc(u8, @intCast(len));
+ errdefer alloc.free(bitmap);
+
+ // Create our target Uint8Array that we can use to copy from src.
+ const mem_array = mem_array: {
+ // Get our runtime memory
+ const mem = try js.runtime.get(js.Object, "memory");
+ defer mem.deinit();
+ const buf = try mem.get(js.Object, "buffer");
+ defer buf.deinit();
+
+ // Construct our array to peer into our memory
+ const Uint8Array = try js.global.get(js.Object, "Uint8Array");
+ defer Uint8Array.deinit();
+ const mem_array = try Uint8Array.new(.{ buf, bitmap.ptr });
+ errdefer mem_array.deinit();
+
+ break :mem_array mem_array;
+ };
+ defer mem_array.deinit();
+
+ // Copy
+ try mem_array.call(void, "set", .{src_array});
+
+ break :bitmap bitmap;
+ };
+ errdefer alloc.free(bitmap);
+
+ return RenderedGlyph{
+ .alloc = alloc,
+ .metrics = metrics,
+ .width = width,
+ .height = height,
+ .bitmap = bitmap,
+ };
+ }
+};
+
+/// The wasm-compatible API.
+pub const Wasm = struct {
+ const wasm = @import("../../os/wasm.zig");
+ const alloc = wasm.alloc;
+
+ export fn face_new(ptr: [*]const u8, len: usize, pts: u16, p: u16) ?*Face {
+ return face_new_(ptr, len, pts, p) catch null;
+ }
+
+ fn face_new_(ptr: [*]const u8, len: usize, pts: f32, presentation: u16) !*Face {
+ var face = try Face.initNamed(
+ alloc,
+ ptr[0..len],
+ .{ .points = pts },
+ @enumFromInt(presentation),
+ );
+ errdefer face.deinit();
+
+ const result = try alloc.create(Face);
+ errdefer alloc.destroy(result);
+ result.* = face;
+ return result;
+ }
+
+ export fn face_free(ptr: ?*Face) void {
+ if (ptr) |v| {
+ v.deinit();
+ alloc.destroy(v);
+ }
+ }
+
+ /// Resulting pointer must be freed using the global "free".
+ export fn face_render_glyph(
+ face: *Face,
+ atlas: *font.Atlas,
+ codepoint: u32,
+ ) ?*font.Glyph {
+ return face_render_glyph_(face, atlas, codepoint) catch |err| {
+ log.warn("error rendering glyph err={}", .{err});
+ return null;
+ };
+ }
+
+ export fn face_debug_canvas(face: *Face) void {
+ face_debug_canvas_(face) catch |err| {
+ log.warn("error adding debug canvas err={}", .{err});
+ };
+ }
+
+ fn face_debug_canvas_(face: *Face) !void {
+ const doc = try js.global.get(js.Object, "document");
+ defer doc.deinit();
+
+ const elem = try doc.call(
+ ?js.Object,
+ "getElementById",
+ .{js.string("face-canvas")},
+ ) orelse return error.CanvasContainerNotFound;
+ defer elem.deinit();
+
+ try elem.call(void, "append", .{face.canvas});
+ }
+
+ fn face_render_glyph_(face: *Face, atlas: *font.Atlas, codepoint: u32) !*font.Glyph {
+ const glyph = try face.renderGlyph(alloc, atlas, codepoint, .{});
+
+ const result = try alloc.create(font.Glyph);
+ errdefer alloc.destroy(result);
+ _ = try wasm.toHostOwned(result);
+ result.* = glyph;
+ return result;
+ }
+};