Files
ghostty-rs/crates/ghostty-wasm/src/atlas.rs
T
ddidderr a07b224869 feat(wasm): use zig-js's exact JS interop ABI
Ghostty's wasm code talks to JavaScript through the zig-js library
(mitchellh/zig-js, pinned in build.zig.zon). The first port invented its
own import ABI because the dependency wasn't available; now:
- ghostty-os/src/wasm/js.rs is a Rust port of zig-js's Zig side (Ref,
  Value, Object get/set/call/new, global, runtime, string) using its
  "zig-js" import module, function names and NaN-boxed value refs.
- web_canvas face and the atlas debug canvas make the same JS calls as
  Ghostty's Zig code.
- zig-js's unmodified JS host (+ LICENSE) is vendored under
  crates/ghostty-wasm/js/zig-js and drives the node test.

Test Plan:
- cargo test -p ghostty-wasm (node >= 24; asserts zig-js imports and no
  leaked host values)
2026-10-12 05:26:53 +02:00

226 lines
6.7 KiB
Rust

//! `font/Atlas.zig` `Wasm`: the wasm-compatible atlas API.
use ghostty_font::atlas::{Atlas, Format, Region};
use ghostty_os::wasm::js::{self, 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 id returned that the
/// caller can draw into the DOM to visualize the atlas. The returned ID must
/// be freed from the JS runtime by calling `zigjs.deleteValue`. Returns 0 on
/// error (that is also never a valid id: 0 is the predefined `NaN`).
#[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(id) => id,
Err(err) => {
log::warn!("error dumping atlas canvas err={err:?}");
0
}
}
}
fn debug_canvas(atlas: &Atlas) -> Result<u32, DebugCanvasError> {
// Create our canvas
let doc = js::global().get::<Object>("document")?;
let canvas = doc.call::<Object>("createElement", &[&js::string("canvas")])?;
// Setup our canvas size
{
canvas.set("width", atlas.size)?;
canvas.set("height", atlas.size)?;
let width_str = format!("{}px", atlas.size);
let style = canvas.get::<Object>("style")?;
style.set("width", js::string(&width_str))?;
style.set("height", js::string(&width_str))?;
}
// This will return the same context on subsequent calls so it is
// important to reset it.
let ctx = canvas.call::<Object>("getContext", &[&js::string("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 => return Err(DebugCanvasError::UnsupportedAtlasFormat),
};
// Create an ImageData from our buffer and then write it to the canvas
let image_data = {
// Get our runtime memory
let mem = js::runtime().get::<Object>("memory")?;
let mem_buf = mem.get::<Object>("buffer")?;
// Create an array that points to our buffer
let arr = {
let uint8_clamped_array = js::global().get::<Object>("Uint8ClampedArray")?;
let arr = uint8_clamped_array.new_instance(&[
&mem_buf,
&(buf.as_ptr() as usize),
&buf.len(),
])?;
if ghostty_os::wasm::SHARED_MEM {
// If we're sharing memory then we have to copy the data since
// we can't set ImageData directly using a SharedArrayBuffer.
arr.call::<Object>("slice", &[])?
} else {
arr
}
};
// Create the image data from our array
let image_data = js::global().get::<Object>("ImageData")?;
image_data.new_instance(&[&arr, &atlas.size, &atlas.size])?
};
// Draw it
ctx.call::<()>("putImageData", &[&image_data, &0, &0])?;
Ok(canvas.into_id())
}
#[derive(Debug)]
enum DebugCanvasError {
Js(js::Error),
UnsupportedAtlasFormat,
}
impl From<js::Error> for DebugCanvasError {
fn from(e: js::Error) -> Self {
DebugCanvasError::Js(e)
}
}