diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/font/face/web_canvas.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (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.zig | 561 |
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; + } +}; |
