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:
ddidderr committed 2026-10-12 05:21:14 +02:00
1 parent 0915f7b935
commit d89691b0ce
33 files changed
+2866 -30

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
//! `App.zig` `Wasm`: the upstream exports (`app_new`, `app_free`) are
//! commented out, so nothing is exported here either.
+196
View File
@@ -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)
}
+68
View File
@@ -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()
}
+155
View File
@@ -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}");
}
}
+86
View File
@@ -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) }
}
+111
View File
@@ -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
}
}
}