feat(wasm): port Ghostty's wasm32 build
Ports main_wasm.zig as crates/ghostty-wasm (cdylib exporting the Zig names: atlas_*, face_*, deferred_face_*, shaper_*, config_*), the web_canvas font face and shaper (backend selected on wasm32), os/wasm (logging, target, JS interop) and config/Wasm.zig. C-library dependencies (freetype, harfbuzz, fontconfig, onig, gettext, D-Bus, rustix) are target-gated. Like Zig's wasm build, link regex search (StringMap::search_iterator) is unavailable without Oniguruma; Collection/SharedGrid/harfbuzz are compiled out on wasm32. ghostty-os switches from forbid to deny unsafe_code for the JS import blocks. The JS import ABI is currently a documented handle-based interface with a reference host (js/ghostty_host.js); aligning it with zig-js's exact import names follows separately. Test Plan: - cargo build --target wasm32-unknown-unknown -p ghostty-font \ -p ghostty-config -p ghostty-terminal -p ghostty-vt -p ghostty-wasm - cargo test -p ghostty-wasm (node runs the module against a fake canvas)
This commit is contained in:
1 parent
0915f7b935
commit
d89691b0ce
33 files changed
+2866
-30
No files matched your search
@@ -0,0 +1,56 @@
|
||||
[package]
|
||||
name = "ghostty-wasm"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "The Ghostty WASM module (browser): C ABI exports for atlas, font faces, shaper and config. Port of main_wasm.zig"
|
||||
|
||||
# `cdylib` is the `.wasm` module; `rlib` keeps the crate usable from native
|
||||
# workspace builds (it is empty there).
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
ghostty-config.workspace = true
|
||||
ghostty-font.workspace = true
|
||||
ghostty-os.workspace = true
|
||||
ghostty-terminal.workspace = true
|
||||
log = "0.4.34"
|
||||
|
||||
[features]
|
||||
# Zig's `wasm_shared` build option.
|
||||
wasm-shared = ["ghostty-os/wasm-shared"]
|
||||
|
||||
# `[workspace.lints]` with `unsafe_code` denied (the `extern "C"` exports need
|
||||
# raw pointers; every unsafe block carries a SAFETY comment).
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
cast_possible_truncation = "allow"
|
||||
cast_possible_wrap = "allow"
|
||||
cast_sign_loss = "allow"
|
||||
cast_precision_loss = "allow"
|
||||
cast_lossless = "allow"
|
||||
module_name_repetitions = "allow"
|
||||
too_many_lines = "allow"
|
||||
missing_errors_doc = "allow"
|
||||
missing_panics_doc = "allow"
|
||||
similar_names = "allow"
|
||||
struct_excessive_bools = "allow"
|
||||
fn_params_excessive_bools = "allow"
|
||||
match_same_arms = "allow"
|
||||
unreadable_literal = "allow"
|
||||
many_single_char_names = "allow"
|
||||
doc_markdown = "allow"
|
||||
must_use_candidate = "allow"
|
||||
return_self_not_must_use = "allow"
|
||||
used_underscore_binding = "allow"
|
||||
large_stack_arrays = "allow"
|
||||
inline_always = "allow"
|
||||
|
||||
[dev-dependencies]
|
||||
temp-env = "0.3.6"
|
||||
@@ -0,0 +1,106 @@
|
||||
// Host-side glue for the `ghostty_js` imports (see
|
||||
// crates/ghostty-os/src/wasm/js.rs for the protocol) and `env.log`.
|
||||
// Usable from a browser (ES module) or node (CommonJS).
|
||||
//
|
||||
// const host = createHost({ globalThis, log });
|
||||
// const { instance } = await WebAssembly.instantiate(bytes, host.imports);
|
||||
// host.setInstance(instance);
|
||||
|
||||
function createHost({ global = globalThis, log = console.log } = {}) {
|
||||
const dec = new TextDecoder();
|
||||
const enc = new TextEncoder();
|
||||
// Handle table. Handle 0 is reserved for "invalid / threw".
|
||||
const table = [undefined];
|
||||
const free = [];
|
||||
let instance = null;
|
||||
|
||||
const put = (v) => {
|
||||
if (free.length > 0) {
|
||||
const h = free.pop();
|
||||
table[h] = v;
|
||||
return h;
|
||||
}
|
||||
table.push(v);
|
||||
return table.length - 1;
|
||||
};
|
||||
const mem = () => new Uint8Array(instance.exports.memory.buffer);
|
||||
const str = (ptr, len) => dec.decode(mem().subarray(ptr, ptr + len));
|
||||
const guard = (f) => {
|
||||
try {
|
||||
return f();
|
||||
} catch (e) {
|
||||
host.lastError = e;
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const ghostty_js = {
|
||||
global: () => put(global),
|
||||
get: (obj, ptr, len) => guard(() => put(table[obj][str(ptr, len)])),
|
||||
set: (obj, ptr, len, val) =>
|
||||
guard(() => {
|
||||
table[obj][str(ptr, len)] = table[val];
|
||||
return 1;
|
||||
}),
|
||||
call: (obj, ptr, len, argsPtr, argc) =>
|
||||
guard(() => {
|
||||
const args = Array.from(new Uint32Array(instance.exports.memory.buffer, argsPtr, argc), (h) => table[h]);
|
||||
const o = table[obj];
|
||||
return put(o[str(ptr, len)](...args));
|
||||
}),
|
||||
new: (ctor, argsPtr, argc) =>
|
||||
guard(() => {
|
||||
const args = Array.from(new Uint32Array(instance.exports.memory.buffer, argsPtr, argc), (h) => table[h]);
|
||||
return put(new table[ctor](...args));
|
||||
}),
|
||||
string: (ptr, len) => put(str(ptr, len)),
|
||||
number: (v) => put(v),
|
||||
boolean: (v) => put(v !== 0),
|
||||
to_f64: (h) => Number(table[h]),
|
||||
string_len: (h) => enc.encode(String(table[h])).length,
|
||||
string_copy: (h, ptr, cap) => {
|
||||
const bytes = enc.encode(String(table[h]));
|
||||
const n = Math.min(bytes.length, cap);
|
||||
mem().set(bytes.subarray(0, n), ptr);
|
||||
return n;
|
||||
},
|
||||
is_nullish: (h) => (table[h] === undefined || table[h] === null ? 1 : 0),
|
||||
bytes: (ptr, len) => put(new Uint8ClampedArray(mem().slice(ptr, ptr + len).buffer)),
|
||||
copy_bytes: (h, dst, len) =>
|
||||
guard(() => {
|
||||
const src = table[h];
|
||||
if (src.length !== len) throw new Error(`length mismatch ${src.length} != ${len}`);
|
||||
mem().set(new Uint8Array(src.buffer, src.byteOffset, len), dst);
|
||||
return 1;
|
||||
}),
|
||||
release: (h) => {
|
||||
if (h > 0) {
|
||||
table[h] = undefined;
|
||||
free.push(h);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const host = {
|
||||
lastError: null,
|
||||
imports: { ghostty_js, env: { log: (ptr, len) => log(str(ptr, len)) } },
|
||||
setInstance(i) {
|
||||
instance = i;
|
||||
},
|
||||
// Helpers for callers of the exports.
|
||||
writeString(s) {
|
||||
const bytes = enc.encode(s);
|
||||
const ptr = instance.exports.wasm_alloc(bytes.length);
|
||||
mem().set(bytes, ptr);
|
||||
return { ptr, len: bytes.length, free: () => instance.exports.wasm_free(ptr, bytes.length) };
|
||||
},
|
||||
readString(ptr, len) {
|
||||
return str(ptr, len);
|
||||
},
|
||||
value: (h) => table[h],
|
||||
handleCount: () => table.length - 1 - free.length,
|
||||
};
|
||||
return host;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = { createHost };
|
||||
@@ -0,0 +1,2 @@
|
||||
//! `App.zig` `Wasm`: the upstream exports (`app_new`, `app_free`) are
|
||||
//! commented out, so nothing is exported here either.
|
||||
@@ -0,0 +1,196 @@
|
||||
//! `font/Atlas.zig` `Wasm`: the wasm-compatible atlas API.
|
||||
|
||||
use ghostty_font::atlas::{Atlas, Format, Region};
|
||||
use ghostty_os::wasm::js::{self, Arg, Object};
|
||||
|
||||
use crate::{ensure_init, host_bytes};
|
||||
|
||||
/// C layout of `Atlas.Region`.
|
||||
#[repr(C)]
|
||||
pub struct RegionC {
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl From<RegionC> for Region {
|
||||
fn from(r: RegionC) -> Region {
|
||||
Region {
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_from(v: u8) -> Option<Format> {
|
||||
match v {
|
||||
0 => Some(Format::Grayscale),
|
||||
1 => Some(Format::Bgr),
|
||||
2 => Some(Format::Bgra),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn atlas_new(size: u32, format: u8) -> *mut Atlas {
|
||||
ensure_init();
|
||||
let Some(format) = format_from(format) else {
|
||||
return std::ptr::null_mut();
|
||||
};
|
||||
Box::into_raw(Box::new(Atlas::new(size, format)))
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn atlas_free(ptr: *mut Atlas) {
|
||||
if !ptr.is_null() {
|
||||
// SAFETY: pointer came from `atlas_new` and is freed once.
|
||||
drop(unsafe { Box::from_raw(ptr) });
|
||||
}
|
||||
}
|
||||
|
||||
/// The return value must be freed by the caller with [`region_free`].
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn atlas_reserve(atlas: *mut Atlas, width: u32, height: u32) -> *mut RegionC {
|
||||
// SAFETY: the host passes a live pointer from `atlas_new`.
|
||||
let atlas = unsafe { &mut *atlas };
|
||||
match atlas.reserve(width, height) {
|
||||
Ok(r) => Box::into_raw(Box::new(RegionC {
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
})),
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn region_free(ptr: *mut RegionC) {
|
||||
if !ptr.is_null() {
|
||||
// SAFETY: pointer came from `atlas_reserve` and is freed once.
|
||||
drop(unsafe { Box::from_raw(ptr) });
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn atlas_set(atlas: *mut Atlas, reg: *const RegionC, data: *const u8, len: usize) {
|
||||
// SAFETY: live pointers from the host; `data` is valid for `len` bytes.
|
||||
let (atlas, reg, data) = unsafe { (&mut *atlas, &*reg, host_bytes(data, len)) };
|
||||
atlas.set(
|
||||
Region {
|
||||
x: reg.x,
|
||||
y: reg.y,
|
||||
width: reg.width,
|
||||
height: reg.height,
|
||||
},
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn atlas_grow(atlas: *mut Atlas, size_new: u32) -> bool {
|
||||
// SAFETY: live pointer from `atlas_new`.
|
||||
unsafe { &mut *atlas }.grow(size_new);
|
||||
true
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn atlas_clear(atlas: *mut Atlas) {
|
||||
// SAFETY: live pointer from `atlas_new`.
|
||||
unsafe { &mut *atlas }.clear();
|
||||
}
|
||||
|
||||
/// Returns a pointer to the atlas pixel data and its length through `len`
|
||||
/// (host convenience; Zig hosts read `atlas.data` through the struct layout).
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn atlas_data(atlas: *const Atlas, len: *mut usize) -> *const u8 {
|
||||
// SAFETY: live pointers from the host.
|
||||
let (atlas, len) = unsafe { (&*atlas, &mut *len) };
|
||||
*len = atlas.data.len();
|
||||
atlas.data.as_ptr()
|
||||
}
|
||||
|
||||
/// The atlas edge length in pixels.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn atlas_size(atlas: *const Atlas) -> u32 {
|
||||
// SAFETY: live pointer from `atlas_new`.
|
||||
unsafe { &*atlas }.size
|
||||
}
|
||||
|
||||
/// This creates a Canvas element identified by the handle returned that the
|
||||
/// caller can draw into the DOM to visualize the atlas. The returned handle
|
||||
/// must be released from the JS runtime (`ghostty_js` release / zigjs
|
||||
/// `deleteValue`). Returns 0 on error.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn atlas_debug_canvas(atlas: *const Atlas) -> u32 {
|
||||
// SAFETY: live pointer from `atlas_new`.
|
||||
let atlas = unsafe { &*atlas };
|
||||
match debug_canvas(atlas) {
|
||||
Ok(c) => c.into_raw(),
|
||||
Err(err) => {
|
||||
log::warn!("error dumping atlas canvas err={err}");
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn debug_canvas(atlas: &Atlas) -> Result<Object, js::Error> {
|
||||
// Create our canvas
|
||||
let doc = js::global()?.get_object("document")?;
|
||||
let canvas = doc.call("createElement", &[Arg::Str("canvas")])?;
|
||||
|
||||
// Setup our canvas size
|
||||
{
|
||||
canvas.set("width", Arg::from(atlas.size))?;
|
||||
canvas.set("height", Arg::from(atlas.size))?;
|
||||
let style = canvas.get_object("style")?;
|
||||
let px = format!("{}px", atlas.size);
|
||||
style.set("width", Arg::Str(&px))?;
|
||||
style.set("height", Arg::Str(&px))?;
|
||||
}
|
||||
|
||||
// This will return the same context on subsequent calls.
|
||||
let ctx = canvas.call("getContext", &[Arg::Str("2d")])?;
|
||||
|
||||
// We need to draw pixels so this is format dependent.
|
||||
let buf: Vec<u8> = match atlas.format {
|
||||
// Convert from BGRA to RGBA by swapping every R and B.
|
||||
Format::Bgra => {
|
||||
let mut buf = atlas.data.clone();
|
||||
for px in buf.as_chunks_mut::<4>().0 {
|
||||
px.swap(0, 2);
|
||||
}
|
||||
buf
|
||||
}
|
||||
// Convert from A8 to RGBA so every 4th byte is set to a value.
|
||||
Format::Grayscale => {
|
||||
let mut buf = vec![0u8; atlas.data.len() * 4];
|
||||
for (i, v) in atlas.data.iter().enumerate() {
|
||||
buf[i * 4 + 3] = *v;
|
||||
}
|
||||
buf
|
||||
}
|
||||
Format::Bgr => {
|
||||
log::warn!("error dumping atlas canvas err=UnsupportedAtlasFormat");
|
||||
return Err(js::Error::Js);
|
||||
}
|
||||
};
|
||||
|
||||
// Create an ImageData from our buffer and then write it to the canvas
|
||||
let arr = Object::clamped_array(&buf)?;
|
||||
let image_data_ctor = js::global()?.get_object("ImageData")?;
|
||||
let image_data = image_data_ctor.new_instance(&[
|
||||
Arg::Object(&arr),
|
||||
Arg::from(atlas.size),
|
||||
Arg::from(atlas.size),
|
||||
])?;
|
||||
ctx.call(
|
||||
"putImageData",
|
||||
&[Arg::Object(&image_data), Arg::Num(0.0), Arg::Num(0.0)],
|
||||
)?;
|
||||
|
||||
Ok(canvas)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! `config/Wasm.zig`: the wasm-compatible config API.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use ghostty_config::{
|
||||
cli_args::{self, LineIterator},
|
||||
config::Config,
|
||||
file_formatter::FileFormatter,
|
||||
};
|
||||
|
||||
use crate::{ensure_init, host_bytes};
|
||||
|
||||
/// Create a new configuration filled with the initial default values.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn config_new() -> *mut Config {
|
||||
ensure_init();
|
||||
Box::into_raw(Box::new(Config::new_default()))
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn config_free(ptr: *mut Config) {
|
||||
if !ptr.is_null() {
|
||||
// SAFETY: pointer came from `config_new` and is freed once.
|
||||
drop(unsafe { Box::from_raw(ptr) });
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the configuration from a string in the same format as the file-based
|
||||
/// syntax for the desktop version of the terminal.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn config_load_string(config: *mut Config, s: *const u8, len: usize) {
|
||||
// SAFETY: live pointer from `config_new`; `s` is valid for `len` bytes.
|
||||
let (config, s) = unsafe { (&mut *config, host_bytes(s, len)) };
|
||||
let mut iter = LineIterator::new(Cursor::new(s));
|
||||
if let Err(err) = cli_args::parse(config, &mut iter) {
|
||||
log::error!("error loading config err={err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn config_finalize(config: *mut Config) {
|
||||
// SAFETY: live pointer from `config_new`.
|
||||
if let Err(err) = unsafe { &mut *config }.finalize() {
|
||||
log::error!("error finalizing config err={err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra (not in Zig): writes the config in file format (like
|
||||
/// `+show-config`, only changed values) to `out` (capacity `cap`) and returns
|
||||
/// the full length; the output is truncated if it does not fit.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn config_format(config: *const Config, out: *mut u8, cap: usize) -> usize {
|
||||
// SAFETY: live pointer from `config_new`; `out` is valid for `cap` bytes.
|
||||
let (config, out) = unsafe { (&*config, std::slice::from_raw_parts_mut(out, cap)) };
|
||||
let mut fmt = FileFormatter::new(config);
|
||||
fmt.changed = true;
|
||||
let s = fmt.to_string();
|
||||
let n = s.len().min(cap);
|
||||
out[..n].copy_from_slice(&s.as_bytes()[..n]);
|
||||
s.len()
|
||||
}
|
||||
|
||||
/// Extra (not in Zig): the number of diagnostics (parse errors) recorded.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn config_diagnostics_count(config: *const Config) -> usize {
|
||||
// SAFETY: live pointer from `config_new`.
|
||||
unsafe { &*config }.diagnostics.items().len()
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! `font/face/web_canvas.zig` `Wasm` (`face_*`) and `font/DeferredFace.zig`
|
||||
//! `Wasm` (`deferred_face_*`).
|
||||
|
||||
use ghostty_font::{
|
||||
Glyph,
|
||||
Presentation,
|
||||
atlas::Atlas,
|
||||
deferred_face::{DeferredFace, WebCanvas},
|
||||
face::{DesiredSize, web_canvas::Face},
|
||||
};
|
||||
|
||||
use crate::{ensure_init, host_bytes};
|
||||
|
||||
fn presentation_from(v: u16) -> Option<Presentation> {
|
||||
match v {
|
||||
0 => Some(Presentation::Text),
|
||||
1 => Some(Presentation::Emoji),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// C layout of `font.Glyph`.
|
||||
#[repr(C)]
|
||||
pub struct GlyphC {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub offset_x: i32,
|
||||
pub offset_y: i32,
|
||||
pub atlas_x: u32,
|
||||
pub atlas_y: u32,
|
||||
}
|
||||
|
||||
impl From<Glyph> for GlyphC {
|
||||
fn from(g: Glyph) -> GlyphC {
|
||||
GlyphC {
|
||||
width: g.width,
|
||||
height: g.height,
|
||||
offset_x: g.offset_x,
|
||||
offset_y: g.offset_y,
|
||||
atlas_x: g.atlas_x,
|
||||
atlas_y: g.atlas_y,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn face_new(ptr: *const u8, len: usize, pts: u16, p: u16) -> *mut Face {
|
||||
ensure_init();
|
||||
// SAFETY: the host passes a buffer valid for `len` bytes.
|
||||
let raw = unsafe { host_bytes(ptr, len) };
|
||||
let (Ok(raw), Some(p)) = (std::str::from_utf8(raw), presentation_from(p)) else {
|
||||
return std::ptr::null_mut();
|
||||
};
|
||||
match Face::init_named(raw, DesiredSize::new(f32::from(pts)), p) {
|
||||
Ok(face) => Box::into_raw(Box::new(face)),
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn face_free(ptr: *mut Face) {
|
||||
if !ptr.is_null() {
|
||||
// SAFETY: pointer came from `face_new` and is freed once.
|
||||
drop(unsafe { Box::from_raw(ptr) });
|
||||
}
|
||||
}
|
||||
|
||||
/// Resulting pointer must be freed with [`glyph_free`].
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn face_render_glyph(
|
||||
face: *mut Face,
|
||||
atlas: *mut Atlas,
|
||||
codepoint: u32,
|
||||
) -> *mut GlyphC {
|
||||
// SAFETY: live pointers from the host.
|
||||
let (face, atlas) = unsafe { (&*face, &mut *atlas) };
|
||||
match face.render_glyph(atlas, codepoint) {
|
||||
Ok(g) => Box::into_raw(Box::new(g.into())),
|
||||
Err(err) => {
|
||||
log::warn!("error rendering glyph err={err}");
|
||||
std::ptr::null_mut()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn glyph_free(ptr: *mut GlyphC) {
|
||||
if !ptr.is_null() {
|
||||
// SAFETY: pointer came from `face_render_glyph` and is freed once.
|
||||
drop(unsafe { Box::from_raw(ptr) });
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn face_debug_canvas(face: *mut Face) {
|
||||
// SAFETY: live pointer from `face_new`.
|
||||
if let Err(err) = unsafe { &*face }.debug_canvas() {
|
||||
log::warn!("error adding debug canvas err={err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra (not in Zig): the cell metrics `cell_width, cell_height,
|
||||
/// cell_baseline` of a face, written to `out[0..3]`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn face_metrics(face: *const Face, out: *mut u32) {
|
||||
// SAFETY: live pointer from `face_new`; `out` has room for 3 u32.
|
||||
let (face, out) = unsafe { (&*face, std::slice::from_raw_parts_mut(out, 3)) };
|
||||
out[0] = face.metrics.cell_width;
|
||||
out[1] = face.metrics.cell_height;
|
||||
out[2] = face.metrics.cell_baseline;
|
||||
}
|
||||
|
||||
/// Extra (not in Zig): `Face.glyphIndex`; returns 0xFFFF_FFFF for none.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn face_glyph_index(face: *const Face, cp: u32) -> u32 {
|
||||
// SAFETY: live pointer from `face_new`.
|
||||
unsafe { &*face }.glyph_index(cp).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn deferred_face_new(
|
||||
ptr: *const u8,
|
||||
len: usize,
|
||||
presentation: u16,
|
||||
) -> *mut DeferredFace {
|
||||
ensure_init();
|
||||
// SAFETY: the host passes a buffer valid for `len` bytes.
|
||||
let raw = unsafe { host_bytes(ptr, len) };
|
||||
let (Ok(raw), Some(p)) = (std::str::from_utf8(raw), presentation_from(presentation)) else {
|
||||
log::warn!("error creating deferred face err=InvalidArgument");
|
||||
return std::ptr::null_mut();
|
||||
};
|
||||
Box::into_raw(Box::new(DeferredFace::from_web_canvas(WebCanvas {
|
||||
font_str: raw.to_owned(),
|
||||
presentation: p,
|
||||
})))
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn deferred_face_free(ptr: *mut DeferredFace) {
|
||||
if !ptr.is_null() {
|
||||
// SAFETY: pointer came from `deferred_face_new` and is freed once.
|
||||
drop(unsafe { Box::from_raw(ptr) });
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn deferred_face_load(face: *mut DeferredFace, pts: f32) {
|
||||
// SAFETY: live pointer from `deferred_face_new`.
|
||||
let face = unsafe { &mut *face };
|
||||
// Like Zig, the loaded face is dropped (this only checks that it loads).
|
||||
if let Err(err) = face.load(DesiredSize::new(pts)) {
|
||||
log::warn!("error loading deferred face err={err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! The Ghostty WASM module. Port of `ZIG/src/main_wasm.zig`.
|
||||
//!
|
||||
//! The module exports a C ABI. Like Zig's `main_wasm.zig` it pulls in
|
||||
//! `os/wasm` (logging, JS interop), `font` (atlas, deferred face, canvas face,
|
||||
//! shaper), `terminal` and the config/app wasm APIs:
|
||||
//!
|
||||
//! - module `atlas`, `font/Atlas.zig` `Wasm` (`atlas_*`)
|
||||
//! - module `face`, `font/face/web_canvas.zig` `Wasm` (`face_*`) and
|
||||
//! `font/DeferredFace.zig` `Wasm` (`deferred_face_*`)
|
||||
//! - module `shaper`, `font/shaper/web_canvas.zig` `Wasm` (`shaper_*`)
|
||||
//! - module `config`, `config/Wasm.zig` (`config_*`)
|
||||
//! - module `app`, `App.zig` `Wasm` (commented out upstream, nothing exported)
|
||||
//!
|
||||
//! The host provides the `ghostty_js` imports (see
|
||||
//! `ghostty_os::wasm::js`) and `env.log`. Strings are passed as
|
||||
//! pointer+length pairs into wasm memory allocated by the host with
|
||||
//! [`wasm_alloc`] / released with [`wasm_free`].
|
||||
//!
|
||||
//! The crate is empty on non-wasm targets so the native workspace builds.
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
// The C ABI needs raw pointers; every unsafe block has a SAFETY comment.
|
||||
#![allow(unsafe_code)]
|
||||
// Exports are `extern "C"` taking raw pointers by design.
|
||||
#![allow(clippy::not_unsafe_ptr_arg_deref, clippy::missing_safety_doc)]
|
||||
|
||||
pub mod app;
|
||||
pub mod atlas;
|
||||
pub mod config;
|
||||
pub mod face;
|
||||
pub mod shaper;
|
||||
|
||||
use std::sync::Once;
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
/// Installs the logger and panic hook (Zig: `std_options.logFn`). Called by
|
||||
/// every constructor export so the host does not need a separate start call;
|
||||
/// also exported for hosts that want logging before the first constructor.
|
||||
pub(crate) fn ensure_init() {
|
||||
INIT.call_once(|| {
|
||||
ghostty_os::wasm::log::init();
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
ghostty_os::wasm::log::host_log(&format!("error(panic): {info}"));
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn ghostty_wasm_init() {
|
||||
ensure_init();
|
||||
}
|
||||
|
||||
/// Allocates `len` bytes in wasm memory for the host to write into (strings,
|
||||
/// bitmaps). Release with [`wasm_free`]. Returns null on `len == 0`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn wasm_alloc(len: usize) -> *mut u8 {
|
||||
if len == 0 {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
let mut v = vec![0u8; len].into_boxed_slice();
|
||||
let p = v.as_mut_ptr();
|
||||
std::mem::forget(v);
|
||||
p
|
||||
}
|
||||
|
||||
/// Releases memory from [`wasm_alloc`].
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn wasm_free(ptr: *mut u8, len: usize) {
|
||||
if ptr.is_null() || len == 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `ptr`/`len` came from `wasm_alloc` (boxed slice of `len` bytes).
|
||||
drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len)) });
|
||||
}
|
||||
|
||||
/// Borrows a host string (`ptr`, `len`) as bytes.
|
||||
///
|
||||
/// # Safety
|
||||
/// `ptr` must be valid for `len` bytes (or `len == 0`).
|
||||
pub(crate) unsafe fn host_bytes<'a>(ptr: *const u8, len: usize) -> &'a [u8] {
|
||||
if len == 0 {
|
||||
return &[];
|
||||
}
|
||||
// SAFETY: guaranteed by the caller.
|
||||
unsafe { std::slice::from_raw_parts(ptr, len) }
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! `font/shaper/web_canvas.zig` `Wasm` (`shaper_*`).
|
||||
|
||||
use ghostty_font::{face::web_canvas::Face, shaper::web_canvas::Shaper};
|
||||
use ghostty_terminal::terminal::{Options, Terminal};
|
||||
|
||||
use crate::{ensure_init, host_bytes};
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn shaper_new(cap: usize) -> *mut Shaper {
|
||||
ensure_init();
|
||||
Box::into_raw(Box::new(Shaper::new(cap)))
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn shaper_free(ptr: *mut Shaper) {
|
||||
if !ptr.is_null() {
|
||||
// SAFETY: pointer came from `shaper_new` and is freed once.
|
||||
drop(unsafe { Box::from_raw(ptr) });
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a test to verify shaping works properly: prints the string into a
|
||||
/// terminal, then shapes and logs every row.
|
||||
///
|
||||
/// Zig takes a `GroupCache` (stale upstream); this takes the face that
|
||||
/// reserves grapheme glyph indexes.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn shaper_test(shaper: *mut Shaper, face: *mut Face, s: *const u8, len: usize) {
|
||||
// SAFETY: live pointers from the host; `s` is valid for `len` bytes.
|
||||
let (shaper, face, s) = unsafe { (&mut *shaper, &mut *face, host_bytes(s, len)) };
|
||||
if let Err(err) = shaper_test_(shaper, face, s) {
|
||||
log::warn!("error during shaper test err={err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn shaper_test_(shaper: &mut Shaper, face: &mut Face, s: &[u8]) -> Result<(), String> {
|
||||
let s = std::str::from_utf8(s).map_err(|e| e.to_string())?;
|
||||
|
||||
// Create a terminal and print all our characters into it.
|
||||
let cols = u16::try_from(shaper.cell_buf_len()).map_err(|e| e.to_string())?;
|
||||
let mut term = Terminal::new(Options::new(cols, 80)).map_err(|e| format!("{e:?}"))?;
|
||||
for c in s.chars() {
|
||||
term.print(u32::from(c)).map_err(|e| format!("{e:?}"))?;
|
||||
}
|
||||
|
||||
// Iterate over the rows and print out all the runs we get.
|
||||
let plain = term.plain_string().map_err(|e| format!("{e:?}"))?;
|
||||
for (y, row) in plain.lines().enumerate() {
|
||||
if row.is_empty() {
|
||||
continue;
|
||||
}
|
||||
shaper.prepare();
|
||||
for (x, c) in row.chars().enumerate() {
|
||||
shaper.add_codepoint(u32::from(c), x as u32);
|
||||
}
|
||||
shaper.finalize();
|
||||
let cells = shaper
|
||||
.shape(|cluster| face.grapheme_glyph_index(cluster))
|
||||
.map_err(|e| e.to_string())?;
|
||||
log::info!("y={y} shape={cells:?}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extra (not in Zig): prepares a run for the hook-style API.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn shaper_prepare(shaper: *mut Shaper) {
|
||||
// SAFETY: live pointer from `shaper_new`.
|
||||
unsafe { &mut *shaper }.prepare();
|
||||
}
|
||||
|
||||
/// Extra (not in Zig): `RunIteratorHook.addCodepoint`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn shaper_add_codepoint(shaper: *mut Shaper, cp: u32, cluster: u32) {
|
||||
// SAFETY: live pointer from `shaper_new`.
|
||||
unsafe { &mut *shaper }.add_codepoint(cp, cluster);
|
||||
}
|
||||
|
||||
/// Extra (not in Zig): shapes the current run. Writes `(x, glyph_index)` u32
|
||||
/// pairs to `out` (capacity `cap` pairs) and returns the number of cells, or
|
||||
/// `u32::MAX` on error.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn shaper_shape(
|
||||
shaper: *mut Shaper,
|
||||
face: *mut Face,
|
||||
out: *mut u32,
|
||||
cap: usize,
|
||||
) -> u32 {
|
||||
// SAFETY: live pointers from the host; `out` has room for `cap` pairs.
|
||||
let (shaper, face, out) = unsafe {
|
||||
(
|
||||
&mut *shaper,
|
||||
&mut *face,
|
||||
std::slice::from_raw_parts_mut(out, cap * 2),
|
||||
)
|
||||
};
|
||||
match shaper.shape(|cluster| face.grapheme_glyph_index(cluster)) {
|
||||
Ok(cells) if cells.len() <= cap => {
|
||||
for (i, c) in cells.iter().enumerate() {
|
||||
out[i * 2] = u32::from(c.x);
|
||||
out[i * 2 + 1] = c.glyph_index;
|
||||
}
|
||||
cells.len() as u32
|
||||
}
|
||||
Ok(_) => u32::MAX,
|
||||
Err(err) => {
|
||||
log::warn!("error shaping err={err}");
|
||||
u32::MAX
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Builds the wasm module and runs `tests/node_test.js` against it with
|
||||
//! node, using stub JS imports (a fake canvas measuring API). Skipped when
|
||||
//! node or the `wasm32-unknown-unknown` target is not available.
|
||||
|
||||
use std::{path::PathBuf, process::Command};
|
||||
|
||||
#[test]
|
||||
fn wasm_module_runs_under_node() {
|
||||
if Command::new("node").arg("--version").output().is_err() {
|
||||
eprintln!("skipping: node not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let target_dir = std::env::var_os("CARGO_TARGET_DIR")
|
||||
.map_or_else(|| manifest.join("../../target"), PathBuf::from);
|
||||
// A separate directory so this does not contend with the outer build.
|
||||
let target_dir = target_dir.join("wasm-test");
|
||||
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_owned());
|
||||
let out = Command::new(cargo)
|
||||
.args([
|
||||
"build",
|
||||
"--target",
|
||||
"wasm32-unknown-unknown",
|
||||
"-p",
|
||||
"ghostty-wasm",
|
||||
"--manifest-path",
|
||||
])
|
||||
.arg(manifest.join("Cargo.toml"))
|
||||
.env("CARGO_TARGET_DIR", &target_dir)
|
||||
.output()
|
||||
.expect("run cargo");
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
if stderr.contains("can't find crate for `std`")
|
||||
|| stderr.contains("target may not be installed")
|
||||
{
|
||||
eprintln!("skipping: wasm32-unknown-unknown target not installed");
|
||||
return;
|
||||
}
|
||||
panic!("wasm build failed:\n{stderr}");
|
||||
}
|
||||
|
||||
let wasm = target_dir.join("wasm32-unknown-unknown/debug/ghostty_wasm.wasm");
|
||||
assert!(wasm.exists(), "missing {}", wasm.display());
|
||||
|
||||
let out = Command::new("node")
|
||||
.arg(manifest.join("tests/node_test.js"))
|
||||
.arg(&wasm)
|
||||
.output()
|
||||
.expect("run node");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"node test failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("ok"));
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Instantiates ghostty_wasm.wasm with stub JS imports and exercises the
|
||||
// exported config / atlas / canvas face / shaper functions. Run by
|
||||
// tests/node.rs: `node node_test.js <path-to-wasm>`.
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const { createHost } = require(path.join(__dirname, '..', 'js', 'ghostty_host.js'));
|
||||
|
||||
// A fake canvas: every glyph is a 7px wide, 12px ascent, 4px descent box
|
||||
// (emoji are 14px wide and "colored"). measureText records the font in use.
|
||||
class FakeCanvas {
|
||||
constructor() {
|
||||
this.width = 300;
|
||||
this.height = 150;
|
||||
this.style = {};
|
||||
this.calls = [];
|
||||
const self = this;
|
||||
this.ctx = {
|
||||
font: '',
|
||||
textBaseline: 'alphabetic',
|
||||
fillStyle: '',
|
||||
clearRect: (...a) => self.calls.push(['clearRect', ...a]),
|
||||
fillRect: (...a) => self.calls.push(['fillRect', ...a]),
|
||||
fillText: (t, x, y) => {
|
||||
self.lastText = { t, x, y };
|
||||
self.calls.push(['fillText', t, x, y]);
|
||||
},
|
||||
measureText: (t) => {
|
||||
const n = [...t].length;
|
||||
const emoji = /\p{Extended_Pictographic}/u.test(t);
|
||||
const w = emoji ? 14 : 7 * n;
|
||||
return {
|
||||
width: w + 1,
|
||||
actualBoundingBoxLeft: 0,
|
||||
actualBoundingBoxRight: w,
|
||||
actualBoundingBoxAscent: 12,
|
||||
actualBoundingBoxDescent: t.includes('_') ? 4 : 0.5,
|
||||
_t: t,
|
||||
};
|
||||
},
|
||||
getImageData: (x, y, w, h) => {
|
||||
const data = new Uint8ClampedArray(w * h * 4);
|
||||
const emoji = /\p{Extended_Pictographic}/u.test(self.lastText ? self.lastText.t : '');
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
if (emoji) {
|
||||
data[i * 4] = 200; // red channel => color
|
||||
data[i * 4 + 3] = 255;
|
||||
} else {
|
||||
data[i * 4 + 3] = (i % 2) * 255; // alpha only (black text)
|
||||
}
|
||||
}
|
||||
return { data };
|
||||
},
|
||||
putImageData: (...a) => self.calls.push(['putImageData', ...a]),
|
||||
};
|
||||
}
|
||||
getContext(kind) {
|
||||
assert.strictEqual(kind, '2d');
|
||||
return this.ctx;
|
||||
}
|
||||
}
|
||||
class FakeImageData {
|
||||
constructor(data, w, h) {
|
||||
this.data = data;
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
}
|
||||
}
|
||||
const canvases = [];
|
||||
const fakeGlobal = {
|
||||
document: {
|
||||
createElement: (tag) => {
|
||||
assert.strictEqual(tag, 'canvas');
|
||||
const c = new FakeCanvas();
|
||||
canvases.push(c);
|
||||
return c;
|
||||
},
|
||||
getElementById: () => null,
|
||||
},
|
||||
ImageData: FakeImageData,
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const logs = [];
|
||||
const host = createHost({ global: fakeGlobal, log: (s) => logs.push(s) });
|
||||
const bytes = fs.readFileSync(process.argv[2]);
|
||||
const { instance } = await WebAssembly.instantiate(bytes, host.imports);
|
||||
host.setInstance(instance);
|
||||
const x = instance.exports;
|
||||
x.ghostty_wasm_init();
|
||||
|
||||
// ---- config ----
|
||||
{
|
||||
const cfg = x.config_new();
|
||||
assert.ok(cfg);
|
||||
const s = host.writeString('font-size = 18\nwindow-padding-x = 7\nnot-a-key = 1\n');
|
||||
x.config_load_string(cfg, s.ptr, s.len);
|
||||
s.free();
|
||||
assert.strictEqual(x.config_diagnostics_count(cfg), 1);
|
||||
x.config_finalize(cfg);
|
||||
const cap = 1 << 16;
|
||||
const out = x.wasm_alloc(cap);
|
||||
const n = x.config_format(cfg, out, cap);
|
||||
assert.ok(n > 0 && n <= cap);
|
||||
const text = host.readString(out, n);
|
||||
x.wasm_free(out, cap);
|
||||
assert.match(text, /font-size = 18/);
|
||||
assert.match(text, /window-padding-x = 7/);
|
||||
x.config_free(cfg);
|
||||
}
|
||||
|
||||
// ---- atlas ----
|
||||
const atlas = x.atlas_new(64, 0); // grayscale
|
||||
assert.ok(atlas);
|
||||
assert.strictEqual(x.atlas_size(atlas), 64);
|
||||
|
||||
// ---- face ----
|
||||
const f = host.writeString('monospace');
|
||||
const face = x.face_new(f.ptr, f.len, 12, 0);
|
||||
assert.ok(face, 'face_new');
|
||||
const m = x.wasm_alloc(12);
|
||||
x.face_metrics(face, m);
|
||||
const metrics = new Uint32Array(instance.exports.memory.buffer, m, 3).slice();
|
||||
assert.deepStrictEqual([...metrics], [7, 16, 4]);
|
||||
assert.ok(canvases[0].ctx.font === '12px monospace', canvases[0].ctx.font);
|
||||
|
||||
// Text presentation face supports ascii/latin text but not color emoji
|
||||
assert.strictEqual((x.face_glyph_index(face, 0x41) >>> 0), 0x41);
|
||||
assert.strictEqual((x.face_glyph_index(face, 0x4e2d) >>> 0), 0x4e2d);
|
||||
assert.strictEqual((x.face_glyph_index(face, 0x1f600) >>> 0), 0xffffffff);
|
||||
|
||||
// Render a glyph into the atlas
|
||||
const g = x.face_render_glyph(face, atlas, 0x41);
|
||||
assert.ok(g, 'face_render_glyph');
|
||||
const gv = new Uint32Array(instance.exports.memory.buffer, g, 6);
|
||||
assert.strictEqual(gv[0], 8); // width: 7 + 1 border
|
||||
assert.strictEqual(gv[1], 14); // height: ceil(12 + 0.5) + 1
|
||||
x.glyph_free(g);
|
||||
const sz = x.wasm_alloc(8);
|
||||
const dp = x.atlas_data(atlas, sz);
|
||||
const len = new Uint32Array(instance.exports.memory.buffer, sz, 1)[0];
|
||||
assert.strictEqual(len, 64 * 64);
|
||||
const atlasBytes = new Uint8Array(instance.exports.memory.buffer, dp, len);
|
||||
assert.ok(atlasBytes.some((b) => b === 255), 'atlas received glyph alpha');
|
||||
|
||||
// Emoji face
|
||||
const ef = host.writeString('sans-serif');
|
||||
const eface = x.face_new(ef.ptr, ef.len, 14, 1);
|
||||
assert.ok(eface);
|
||||
assert.strictEqual((x.face_glyph_index(eface, 0x1f600) >>> 0), 0x1f600);
|
||||
assert.strictEqual((x.face_glyph_index(eface, 0x4e2d) >>> 0), 0xffffffff);
|
||||
|
||||
// ---- shaper ----
|
||||
const sh = x.shaper_new(16);
|
||||
x.shaper_prepare(sh);
|
||||
// 'e' + U+0301, then 'x'
|
||||
x.shaper_add_codepoint(sh, 0x65, 0);
|
||||
x.shaper_add_codepoint(sh, 0x301, 0);
|
||||
x.shaper_add_codepoint(sh, 0x78, 1);
|
||||
const outp = x.wasm_alloc(16 * 8);
|
||||
const cells = x.shaper_shape(sh, face, outp, 16);
|
||||
assert.strictEqual(cells, 2);
|
||||
const ov = new Uint32Array(instance.exports.memory.buffer, outp, 4);
|
||||
assert.deepStrictEqual([ov[0], ov[1] >= 0x110000, ov[2], ov[3]], [0, true, 1, 0x78]);
|
||||
// Render the reserved grapheme glyph through the canvas
|
||||
const gg = x.face_render_glyph(face, atlas, ov[1]);
|
||||
assert.ok(gg);
|
||||
x.glyph_free(gg);
|
||||
assert.strictEqual(canvases[0].lastText.t, 'é');
|
||||
|
||||
// shaper_test prints through a terminal and logs the shaped runs
|
||||
const st = host.writeString('hello é');
|
||||
x.shaper_test(sh, face, st.ptr, st.len);
|
||||
assert.ok(logs.some((l) => l.startsWith('info(')), logs.join('\n'));
|
||||
|
||||
// ---- deferred face ----
|
||||
const d = x.deferred_face_new(f.ptr, f.len, 0);
|
||||
assert.ok(d);
|
||||
const before = canvases.length;
|
||||
x.deferred_face_load(d, 12);
|
||||
assert.strictEqual(canvases.length, before + 1);
|
||||
x.deferred_face_free(d);
|
||||
|
||||
// ---- atlas debug canvas ----
|
||||
const h = x.atlas_debug_canvas(atlas);
|
||||
assert.ok(h > 0);
|
||||
const dc = host.value(h);
|
||||
assert.strictEqual(dc.width, 64);
|
||||
assert.ok(dc.calls.some((c) => c[0] === 'putImageData'));
|
||||
|
||||
// ---- cleanup / handle leaks ----
|
||||
x.shaper_free(sh);
|
||||
x.face_free(face);
|
||||
x.face_free(eface);
|
||||
x.atlas_free(atlas);
|
||||
f.free();
|
||||
ef.free();
|
||||
st.free();
|
||||
console.log('ok');
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in new issue
Block a user