From d89691b0cea7e24979e7e2a4818d1c99afcab844 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 05:21:14 +0200 Subject: [PATCH] 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) --- crates/ghostty-config/src/config.rs | 22 +- crates/ghostty-config/src/theme.rs | 17 +- crates/ghostty-font/Cargo.toml | 14 +- crates/ghostty-font/src/backend.rs | 24 +- .../src/deferred_face_web_canvas.rs | 81 +++ crates/ghostty-font/src/face.rs | 7 + crates/ghostty-font/src/face/web_canvas.rs | 645 ++++++++++++++++++ crates/ghostty-font/src/lib.rs | 20 + crates/ghostty-font/src/metrics.rs | 5 +- crates/ghostty-font/src/shape.rs | 7 +- crates/ghostty-font/src/shaper/mod.rs | 9 +- crates/ghostty-font/src/shaper/web_canvas.rs | 254 +++++++ crates/ghostty-os/Cargo.toml | 43 +- crates/ghostty-os/src/lib.rs | 42 ++ crates/ghostty-os/src/unsupported.rs | 104 +++ crates/ghostty-os/src/wasm/js.rs | 307 +++++++++ crates/ghostty-os/src/wasm/log.rs | 76 +++ crates/ghostty-os/src/wasm/mod.rs | 22 + crates/ghostty-os/src/wasm/target.rs | 14 + crates/ghostty-terminal/Cargo.toml | 8 +- .../src/kitty/graphics_image.rs | 36 +- crates/ghostty-terminal/src/string_map.rs | 10 +- crates/ghostty-wasm/Cargo.toml | 56 ++ crates/ghostty-wasm/js/ghostty_host.js | 106 +++ crates/ghostty-wasm/src/app.rs | 2 + crates/ghostty-wasm/src/atlas.rs | 196 ++++++ crates/ghostty-wasm/src/config.rs | 68 ++ crates/ghostty-wasm/src/face.rs | 155 +++++ crates/ghostty-wasm/src/lib.rs | 86 +++ crates/ghostty-wasm/src/shaper.rs | 111 +++ crates/ghostty-wasm/tests/node.rs | 60 ++ crates/ghostty-wasm/tests/node_test.js | 203 ++++++ docs/rewrite/notes/wasm-W1.md | 86 +++ 33 files changed, 2866 insertions(+), 30 deletions(-) create mode 100644 crates/ghostty-font/src/deferred_face_web_canvas.rs create mode 100644 crates/ghostty-font/src/face/web_canvas.rs create mode 100644 crates/ghostty-font/src/shaper/web_canvas.rs create mode 100644 crates/ghostty-os/src/unsupported.rs create mode 100644 crates/ghostty-os/src/wasm/js.rs create mode 100644 crates/ghostty-os/src/wasm/log.rs create mode 100644 crates/ghostty-os/src/wasm/mod.rs create mode 100644 crates/ghostty-os/src/wasm/target.rs create mode 100644 crates/ghostty-wasm/Cargo.toml create mode 100644 crates/ghostty-wasm/js/ghostty_host.js create mode 100644 crates/ghostty-wasm/src/app.rs create mode 100644 crates/ghostty-wasm/src/atlas.rs create mode 100644 crates/ghostty-wasm/src/config.rs create mode 100644 crates/ghostty-wasm/src/face.rs create mode 100644 crates/ghostty-wasm/src/lib.rs create mode 100644 crates/ghostty-wasm/src/shaper.rs create mode 100644 crates/ghostty-wasm/tests/node.rs create mode 100644 crates/ghostty-wasm/tests/node_test.js create mode 100644 docs/rewrite/notes/wasm-W1.md diff --git a/crates/ghostty-config/src/config.rs b/crates/ghostty-config/src/config.rs index 995e9d6..ab4c6da 100644 --- a/crates/ghostty-config/src/config.rs +++ b/crates/ghostty-config/src/config.rs @@ -3976,7 +3976,18 @@ impl Config { // // See: https://github.com/Vladimir-csp/xdg-terminal-exec { + #[cfg(unix)] use std::os::unix::ffi::OsStrExt; + #[cfg(not(unix))] + trait OsStrExt { + fn as_bytes(&self) -> &[u8]; + } + #[cfg(not(unix))] + impl OsStrExt for std::ffi::OsString { + fn as_bytes(&self) -> &[u8] { + self.as_encoded_bytes() + } + } let cargs: Vec = argv .iter() .filter_map(|a| std::ffi::CString::new(a.as_bytes()).ok()) @@ -4619,22 +4630,13 @@ fn zig_io_error_name(err: &std::io::Error) -> String { } fn file_kind_name(meta: &std::fs::Metadata) -> &'static str { - use std::os::unix::fs::FileTypeExt; let ft = meta.file_type(); if ft.is_dir() { "directory" } else if ft.is_symlink() { "sym_link" - } else if ft.is_block_device() { - "block_device" - } else if ft.is_char_device() { - "character_device" - } else if ft.is_fifo() { - "named_pipe" - } else if ft.is_socket() { - "unix_domain_socket" } else { - "unknown" + crate::theme::special_kind_name(ft) } } diff --git a/crates/ghostty-config/src/theme.rs b/crates/ghostty-config/src/theme.rs index f8af430..e55db70 100644 --- a/crates/ghostty-config/src/theme.rs +++ b/crates/ghostty-config/src/theme.rs @@ -94,14 +94,21 @@ fn diag(diags: &mut DiagnosticList, message: String) { } fn kind_name(ft: std::fs::FileType) -> &'static str { - use std::os::unix::fs::FileTypeExt; if ft.is_file() { "file" } else if ft.is_dir() { "directory" } else if ft.is_symlink() { "sym_link" - } else if ft.is_block_device() { + } else { + special_kind_name(ft) + } +} + +#[cfg(unix)] +pub(crate) fn special_kind_name(ft: std::fs::FileType) -> &'static str { + use std::os::unix::fs::FileTypeExt; + if ft.is_block_device() { "block_device" } else if ft.is_char_device() { "character_device" @@ -114,6 +121,12 @@ fn kind_name(ft: std::fs::FileType) -> &'static str { } } +/// wasm32 has no special file types (`std::os::unix` is unavailable). +#[cfg(not(unix))] +pub(crate) fn special_kind_name(_: std::fs::FileType) -> &'static str { + "unknown" +} + /// Verify that the file is a regular file, adding a diagnostic otherwise. fn check_regular(file: &File, theme: &str, diags: &mut DiagnosticList) -> bool { match file.metadata() { diff --git a/crates/ghostty-font/Cargo.toml b/crates/ghostty-font/Cargo.toml index 16b06ad..1aa80df 100644 --- a/crates/ghostty-font/Cargo.toml +++ b/crates/ghostty-font/Cargo.toml @@ -9,15 +9,23 @@ tiny-skia = "0.12.0" png = "0.18.1" thiserror = "2.0.21" log = "0.4.34" -freetype-sys = "0.23.0" -harfbuzz-sys = "0.8.0" ghostty-unicode.workspace = true bitflags = "2.13.2" -yeslogic-fontconfig-sys = "6.0.1" ghostty-datastruct.workspace = true ghostty-config.workspace = true ghostty-terminal.workspace = true +# The C font libraries are unavailable on wasm32, where the web_canvas +# backend (browser Canvas API through JS imports) is used instead +# (Zig: `font/backend.zig` `Backend.default`). +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +freetype-sys = "0.23.0" +harfbuzz-sys = "0.8.0" +yeslogic-fontconfig-sys = "6.0.1" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +ghostty-os.workspace = true + # This crate wraps the freetype / harfbuzz / fontconfig C libraries through # their `-sys` crates; unsafe is denied by default and allowed on minimal, # `// SAFETY:`-commented items only. diff --git a/crates/ghostty-font/src/backend.rs b/crates/ghostty-font/src/backend.rs index dfbc495..0c527ad 100644 --- a/crates/ghostty-font/src/backend.rs +++ b/crates/ghostty-font/src/backend.rs @@ -1,6 +1,6 @@ //! Font backend selection. Port of `font/backend.zig`. //! -//! The Rust rewrite only targets Linux, so [`OPTIONS`] always selects +//! On Linux (and other non-wasm targets) [`OPTIONS`] selects //! [`Backend::FontconfigFreetype`] (fontconfig discovery, FreeType rendering, //! HarfBuzz shaping). The other variants are kept for fidelity with the Zig //! enum and its helper predicates. @@ -27,7 +27,11 @@ pub enum Backend { impl Backend { /// Returns the default backend for a build environment. pub fn default_for_target() -> Backend { - if cfg!(target_os = "macos") { + // wasm32 always uses the browser Canvas API (Zig: `Backend.default` + // with `wasm_target == .browser`). + if cfg!(target_arch = "wasm32") { + Backend::WebCanvas + } else if cfg!(target_os = "macos") { Backend::Coretext } else { Backend::FontconfigFreetype @@ -72,14 +76,30 @@ pub struct Options { pub backend: Backend, } +#[cfg(not(target_arch = "wasm32"))] pub const OPTIONS: Options = Options { backend: Backend::FontconfigFreetype, }; +#[cfg(target_arch = "wasm32")] +pub const OPTIONS: Options = Options { + backend: Backend::WebCanvas, +}; + #[cfg(test)] mod tests { use super::*; + #[test] + fn web_canvas_capabilities() { + let b = Backend::WebCanvas; + assert!(!b.has_freetype()); + assert!(!b.has_fontconfig()); + assert!(!b.has_harfbuzz()); + assert!(!b.has_coretext()); + } + + #[cfg(not(target_arch = "wasm32"))] #[test] fn linux_backend_capabilities() { let b = OPTIONS.backend; diff --git a/crates/ghostty-font/src/deferred_face_web_canvas.rs b/crates/ghostty-font/src/deferred_face_web_canvas.rs new file mode 100644 index 0000000..d765a65 --- /dev/null +++ b/crates/ghostty-font/src/deferred_face_web_canvas.rs @@ -0,0 +1,81 @@ +//! A deferred face represents a single font face with all the information +//! necessary to load it, but defers loading the full face until it is +//! needed. Port of `font/DeferredFace.zig` (web_canvas variant, wasm32). + +pub use crate::face::web_canvas::Face; +use crate::{ + Presentation, + face::{DesiredSize, web_canvas::Error}, +}; + +/// WebCanvas specific data. +#[derive(Debug, Clone)] +pub struct WebCanvas { + /// The CSS "font" attribute, excluding size. + pub font_str: String, + + /// The presentation for this font. + pub presentation: Presentation, +} + +#[derive(Debug, Default)] +pub struct DeferredFace { + pub wc: Option, +} + +impl DeferredFace { + pub fn from_web_canvas(wc: WebCanvas) -> DeferredFace { + DeferredFace { wc: Some(wc) } + } + + /// Returns the family name of the font. + pub fn family_name(&self) -> String { + self.wc + .as_ref() + .map(|wc| wc.font_str.clone()) + .unwrap_or_default() + } + + /// Returns the name of this face. + pub fn name(&self) -> String { + self.family_name() + } + + /// Load the deferred font face. + pub fn load(&mut self, size: DesiredSize) -> Result { + let wc = self + .wc + .as_ref() + .expect("deferred face has no web canvas data"); + Face::init_named(&wc.font_str, size, wc.presentation) + } + + /// Returns true if this face can satisfy the given codepoint and + /// presentation. Canvas always has the codepoint because we have no way + /// of really checking and we let the browser handle it. + pub fn has_codepoint(&self, cp: u32, p: Option) -> bool { + let Some(wc) = &self.wc else { + // Unreachable upstream: the face MUST be loaded. + unreachable!("deferred face without web canvas data"); + }; + + // Fast-path if we have a specific presentation and we don't match, + // then it is definitely not this face. + if p.is_some_and(|desired| wc.presentation != desired) { + return false; + } + + // Slow-path: we initialize the font, render it, and check if it + // works and the presentation matches. + match Face::init_named(&wc.font_str, DesiredSize::new(12.0), wc.presentation) { + Ok(face) => face.glyph_index(cp).is_some(), + Err(err) => { + log::warn!( + "failed to init face for codepoint check face={} err={err}", + wc.font_str + ); + false + } + } + } +} diff --git a/crates/ghostty-font/src/face.rs b/crates/ghostty-font/src/face.rs index dc1c1f0..c070eb5 100644 --- a/crates/ghostty-font/src/face.rs +++ b/crates/ghostty-font/src/face.rs @@ -9,10 +9,17 @@ use std::sync::Arc; use crate::metrics::Metrics; +#[cfg(not(target_arch = "wasm32"))] pub mod freetype; +// The pure (non-JS) parts of the web_canvas face compile everywhere so they +// can be unit tested natively; the JS-backed `Face` is wasm32-only. +pub mod web_canvas; +#[cfg(not(target_arch = "wasm32"))] pub use freetype::Face; pub use ghostty_config::types_b::FreetypeLoadFlags; +#[cfg(target_arch = "wasm32")] +pub use web_canvas::Face; /// If a DPI can't be calculated, this DPI is used. This is probably /// wrong on modern devices so it is highly recommended you get the DPI diff --git a/crates/ghostty-font/src/face/web_canvas.rs b/crates/ghostty-font/src/face/web_canvas.rs new file mode 100644 index 0000000..3ec5aa8 --- /dev/null +++ b/crates/ghostty-font/src/face/web_canvas.rs @@ -0,0 +1,645 @@ +//! The browser Canvas face. Port of `font/face/web_canvas.zig`. +//! +//! Glyphs are measured and rasterised with a 2D canvas context through the +//! JS interop layer (`ghostty_os::wasm::js`). The JS-backed [`Face`] only +//! exists on `wasm32`; everything that does not touch JS (metrics math, +//! presentation detection, grapheme-cluster glyph indices, bitmap +//! conversion) lives at module level so it is unit tested natively. +//! +//! Deviations from the Zig file (which is stale against the current Atlas): +//! the canvas bitmap is RGBA, Zig matches on a non-existent `atlas.format == +//! .rgba`; here a `Bgra` atlas receives the bitmap with R/B swapped (the atlas +//! is BGRA, like `Atlas.Wasm.atlas_debug_canvas` assumes) and a `Grayscale` +//! atlas receives the alpha channel. + +use std::collections::HashMap; + +use crate::{Presentation, atlas, metrics::Metrics}; + +/// Graphemes (multi-codepoint clusters) get "glyph indexes" one above the +/// maximum unicode codepoint up to the maximum 32-bit unsigned integer. +pub const GRAPHEME_START: u32 = 0x10FFFF + 1; +pub const GRAPHEME_END: u32 = u32::MAX; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("out of memory")] + OutOfMemory, + #[error("unknown grapheme cluster")] + UnknownGraphemeCluster, + #[error("unsupported atlas format")] + UnsupportedAtlasFormat, + #[error("invalid codepoint")] + InvalidCodepoint, + #[error(transparent)] + Atlas(#[from] atlas::Error), + #[cfg(target_arch = "wasm32")] + #[error("javascript error: {0}")] + Js(#[from] ghostty_os::wasm::js::Error), +} + +/// The map that stores multi-codepoint grapheme clusters that are rendered. +#[derive(Debug)] +pub struct GraphemeTable { + to_glyph: HashMap, u32>, + from_glyph: HashMap>, + next: u32, +} + +impl Default for GraphemeTable { + fn default() -> Self { + Self { + to_glyph: HashMap::new(), + from_glyph: HashMap::new(), + next: GRAPHEME_START, + } + } +} + +impl GraphemeTable { + /// Returns the glyph index for the given grapheme cluster (UTF-8). The + /// same cluster will always map to the same glyph index. This does not + /// render the grapheme, only reserves the index. + pub fn glyph_index(&mut self, cluster: &[u8]) -> Result { + // If we already have this stored then return it + if let Some(v) = self.to_glyph.get(cluster) { + return Ok(*v); + } + + // We don't have it stored. The next will be "0" if we're out of + // space due to unsigned int wrapping. + if self.next == 0 { + return Err(Error::OutOfMemory); + } + + let index = self.next; + self.to_glyph.insert(cluster.to_vec(), index); + self.from_glyph.insert(index, cluster.to_vec()); + self.next = self.next.wrapping_add(1); + Ok(index) + } + + /// The UTF-8 bytes of the cluster for a grapheme glyph index. + pub fn cluster(&self, glyph_index: u32) -> Option<&[u8]> { + self.from_glyph.get(&glyph_index).map(Vec::as_slice) + } + + pub fn contains(&self, glyph_index: u32) -> bool { + self.from_glyph.contains_key(&glyph_index) + } +} + +/// Measurements from `CanvasRenderingContext2D.measureText`. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct TextMetrics { + pub width: f32, + pub actual_bounding_box_left: f32, + pub actual_bounding_box_right: f32, + pub actual_bounding_box_ascent: f32, + pub actual_bounding_box_descent: f32, +} + +impl TextMetrics { + /// 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. + pub fn extent_width(&self) -> f32 { + if self.actual_bounding_box_right > 0.0 { + self.actual_bounding_box_right + } else { + self.width + } + } +} + +/// Calculate the face metrics from the measurement of "M" (cell width) and +/// of "M_" (high and low character: ascent + descent is the cell height). +pub fn calc_metrics(m: TextMetrics, m_underscore: TextMetrics) -> Metrics { + let cell_width = m.extent_width(); + let cell_height = + m_underscore.actual_bounding_box_ascent + m_underscore.actual_bounding_box_descent; + let cell_baseline = m_underscore.actual_bounding_box_descent; + + // There isn't a declared underline position for canvas measurements + // so we just go 1 under the cell height to match freetype logic. + let underline_position = cell_height - 1.0; + let underline_thickness = 1.0f32; + + Metrics { + cell_width: cell_width as u32, + cell_height: cell_height as u32, + cell_baseline: cell_baseline as u32, + underline_position: underline_position as u32, + underline_thickness: underline_thickness as u32, + strikethrough_position: underline_position as u32, + strikethrough_thickness: underline_thickness as u32, + // The canvas gives no face box; the Zig code leaves these unset (it + // predates the required fields), so use the cell size. + face_width: f64::from(cell_width), + face_height: f64::from(cell_height), + ..Metrics::init() + } +} + +/// Determines the presentation of a rendered RGBA bitmap by literally +/// inspecting the image data for any color. +pub fn bitmap_presentation(bitmap: &[u8]) -> Presentation { + let (pixels, _) = bitmap.as_chunks::<4>(); + if pixels.iter().any(|px| px[0] > 0 || px[1] > 0 || px[2] > 0) { + Presentation::Emoji + } else { + Presentation::Text + } +} + +/// The pixel size of a rendered glyph, from its measurement. Returns +/// `(width, height, broken_bbox)`. Width and height both get "+ 1" so that +/// there is a 1px border around the glyph to avoid clipping in the atlas. +pub fn glyph_size(m: TextMetrics) -> (u32, u32, bool) { + let width = m.extent_width().ceil() as u32 + 1; + + // On Firefox on Linux, the bounding box is broken in some cases for + // ideographic glyphs (such as emoji). We detect this and behave + // differently. + let asc = m.actual_bounding_box_ascent; + let desc = m.actual_bounding_box_descent; + let broken_bbox = asc + desc < 0.001; + let height = if broken_bbox { + width + } else { + (asc + desc).ceil() as u32 + 1 + }; + (width, height, broken_bbox) +} + +/// Converts an RGBA canvas bitmap to the byte layout of an atlas format. +pub fn bitmap_for_atlas(rgba: &[u8], format: atlas::Format) -> Result, Error> { + debug_assert_eq!(rgba.len() % 4, 0); + match format { + // Swap R and B (the atlas is BGRA). + atlas::Format::Bgra => { + let mut out = rgba.to_vec(); + for px in out.as_chunks_mut::<4>().0 { + px.swap(0, 2); + } + Ok(out) + } + // Convert down to A8 + atlas::Format::Grayscale => Ok(rgba.as_chunks::<4>().0.iter().map(|px| px[3]).collect()), + atlas::Format::Bgr => Err(Error::UnsupportedAtlasFormat), + } +} + +/// Reserve space in the atlas for a rendered bitmap and write it. +pub fn put_in_atlas( + atlas: &mut atlas::Atlas, + width: u32, + height: u32, + rgba: &[u8], +) -> Result { + let formatted = bitmap_for_atlas(rgba, atlas.format)?; + let region = atlas.reserve(width, height)?; + if region.width > 0 && region.height > 0 { + atlas.set(region, &formatted); + } + Ok(crate::Glyph { + width, + height, + // TODO: this can't be right (upstream comment) + offset_x: 0, + offset_y: 0, + atlas_x: region.x, + atlas_y: region.y, + }) +} + +#[cfg(target_arch = "wasm32")] +pub use face_js::Face; + +#[cfg(target_arch = "wasm32")] +mod face_js { + use ghostty_os::wasm::js::{self, Arg, Object}; + + use super::{ + Error, + GRAPHEME_START, + GraphemeTable, + TextMetrics, + bitmap_presentation, + calc_metrics, + glyph_size, + put_in_atlas, + }; + use crate::{Glyph, Presentation, atlas::Atlas, face::DesiredSize, metrics::Metrics}; + + pub struct Face { + /// The CSS "font" attribute, excluding size. + font_str: String, + + /// The size we currently have set. + pub size: DesiredSize, + + /// The presentation for this font. + pub presentation: Presentation, + + /// Metrics for this font face. These are useful for renderers. + pub metrics: Metrics, + + /// The canvas element that we will reuse to render glyphs + canvas: Object, + + graphemes: GraphemeTable, + } + + /// An internal (web-canvas-only) format for rendered glyphs since we do + /// render passes in multiple different situations. + struct RenderedGlyph { + width: u32, + height: u32, + bitmap: Vec, + } + + fn text_metrics(o: &Object) -> Result { + Ok(TextMetrics { + width: o.get_f32("width")?, + actual_bounding_box_left: o.get_f32("actualBoundingBoxLeft")?, + actual_bounding_box_right: o.get_f32("actualBoundingBoxRight")?, + actual_bounding_box_ascent: o.get_f32("actualBoundingBoxAscent")?, + actual_bounding_box_descent: o.get_f32("actualBoundingBoxDescent")?, + }) + } + + impl Face { + /// 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 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 init_named( + raw: &str, + size: DesiredSize, + presentation: Presentation, + ) -> Result { + // Create our canvas that we're going to continue to reuse. + let doc = js::global()?.get_object("document")?; + let canvas = doc.call("createElement", &[Arg::Str("canvas")])?; + + let mut result = Face { + font_str: raw.to_owned(), + size, + presentation, + // We're going to calculate these right after initialization. + metrics: Metrics::init(), + canvas, + graphemes: GraphemeTable::default(), + }; + result.calc_metrics()?; + + log::debug!("face initialized: {raw}"); + Ok(result) + } + + /// Resize the font in-place. If this succeeds, the caller is + /// responsible for clearing any glyph caches, font atlas data, etc. + pub fn set_size(&mut self, size: DesiredSize) -> Result<(), Error> { + let old = self.size; + self.size = size; + if let Err(e) = self.calc_metrics() { + self.size = old; + return Err(e); + } + Ok(()) + } + + /// 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 glyph_index(&self, cp: u32) -> Option { + // If this is a multi-codepoint grapheme then we only check if + // we actually know about it. + if cp >= GRAPHEME_START && !self.graphemes.contains(cp) { + return None; + } + + // 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. + let p = if cp <= 255 { + Presentation::Text + } else { + // If we are unable to render this glyph then we just say we + // don't support it. + self.glyph_presentation(cp).ok()? + }; + if p != self.presentation { + return None; + } + + Some(cp) + } + + /// This determines the presentation of the glyph by literally + /// inspecting the image data to look for any color. + fn glyph_presentation(&self, cp: u32) -> Result { + let render = self.render_glyph_internal(cp)?; + Ok(bitmap_presentation(&render.bitmap)) + } + + /// 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 grapheme_glyph_index(&mut self, cluster: &[u8]) -> Result { + self.graphemes.glyph_index(cluster) + } + + /// Render a glyph using the glyph index. The rendered glyph is + /// stored in the given texture atlas. + pub fn render_glyph(&self, atlas: &mut Atlas, glyph_index: u32) -> Result { + let render = self.render_glyph_internal(glyph_index)?; + put_in_atlas(atlas, render.width, render.height, &render.bitmap) + } + + /// Calculate the metrics associated with a given face. + fn calc_metrics(&mut self) -> Result<(), Error> { + let ctx = self.context()?; + + // Cell width is the width of our M text + let m = text_metrics(&ctx.call("measureText", &[Arg::Str("M")])?)?; + + // To get the cell height we render a high and low character and + // get the total of the ascent and descent. + let hm = text_metrics(&ctx.call("measureText", &[Arg::Str("M_")])?)?; + + self.metrics = calc_metrics(m, hm); + log::debug!("metrics font={} value={:?}", self.font_str, self.metrics); + Ok(()) + } + + /// Returns the 2d context configured for drawing. + fn context(&self) -> Result { + // This will return the same context on subsequent calls so it + // is important to reset it. + let ctx = self.canvas.call("getContext", &[Arg::Str("2d")])?; + + // Clear the canvas + { + let width = self.canvas.get_f64("width")?; + let height = self.canvas.get_f64("height")?; + ctx.call( + "clearRect", + &[ + Arg::Num(0.0), + Arg::Num(0.0), + Arg::Num(width), + Arg::Num(height), + ], + )?; + } + + // Set our context font + let font_val = format!("{}px {}", self.size.points, self.font_str); + ctx.set("font", Arg::Str(&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. + let check = ctx.get_string("font")?; + if font_val != check { + log::warn!( + "canvas font didn't set, fonts may be broken, expected={font_val} got={check}" + ); + } + + Ok(ctx) + } + + /// Shared logic for rendering a glyph. + fn render_glyph_internal(&self, glyph_index: u32) -> Result { + // Encode our glyph to UTF-8 so we can build a JS string out of it. + let glyph_str: String = if glyph_index < GRAPHEME_START { + char::from_u32(glyph_index) + .ok_or(Error::InvalidCodepoint)? + .to_string() + } else { + // We are a multi-codepoint glyph so we have to read the glyph + // from the map and it is already utf8 encoded. + let slice = self + .graphemes + .cluster(glyph_index) + .ok_or(Error::UnknownGraphemeCluster)?; + String::from_utf8_lossy(slice).into_owned() + }; + + // Get our drawing context and the size of the render + let measure_ctx = self.context()?; + let m = text_metrics(&measure_ctx.call("measureText", &[Arg::Str(&glyph_str)])?)?; + let (width, height, broken_bbox) = glyph_size(m); + + // Resize canvas to match the glyph size exactly + { + self.canvas.set("width", Arg::from(width))?; + self.canvas.set("height", Arg::from(height))?; + + let style = self.canvas.get_object("style")?; + style.set("width", Arg::Str(&format!("{width}px")))?; + style.set("height", Arg::Str(&format!("{height}px")))?; + } + + // Reload our context since we resized the canvas + let ctx = self.context()?; + + // 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 { + ctx.set("textBaseline", Arg::Str("ideographic"))?; + } + + // Draw background + ctx.set("fillStyle", Arg::Str("transparent"))?; + ctx.call( + "fillRect", + &[ + Arg::Num(0.0), + Arg::Num(0.0), + Arg::from(width), + Arg::from(height), + ], + )?; + + // Draw glyph + ctx.set("fillStyle", Arg::Str("black"))?; + ctx.call( + "fillText", + &[ + Arg::Str(&glyph_str), + Arg::from(m.actual_bounding_box_left + 1.0), + if broken_bbox { + Arg::from(height) + } else { + Arg::from(m.actual_bounding_box_ascent + 1.0) + }, + ], + )?; + + // Read the image data and get it into a Vec on our side + let data = ctx.call( + "getImageData", + &[ + Arg::Num(0.0), + Arg::Num(0.0), + Arg::from(width), + Arg::from(height), + ], + )?; + let src_array = data.get_object("data")?; + let len = src_array.get_f64("length")? as usize; + let mut bitmap = vec![0u8; len]; + src_array.copy_bytes_to(&mut bitmap)?; + + Ok(RenderedGlyph { + width, + height, + bitmap, + }) + } + + /// Appends our canvas to the element with id `face-canvas` (the + /// debugging hook behind the `face_debug_canvas` export). + pub fn debug_canvas(&self) -> Result<(), Error> { + let doc = js::global()?.get_object("document")?; + let elem = doc.call("getElementById", &[Arg::Str("face-canvas")])?; + if elem.is_nullish() { + return Err(js::Error::Nullish.into()); + } + elem.call("append", &[Arg::Object(&self.canvas)])?; + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atlas::{Atlas, Format}; + + #[test] + fn grapheme_indexes_are_stable_and_sequential() { + let mut t = GraphemeTable::default(); + let a = t.glyph_index("šŸ‘šŸ½".as_bytes()).unwrap(); + let b = t.glyph_index("e\u{301}".as_bytes()).unwrap(); + assert_eq!(a, GRAPHEME_START); + assert_eq!(b, GRAPHEME_START + 1); + assert_eq!(t.glyph_index("šŸ‘šŸ½".as_bytes()).unwrap(), a); + assert_eq!(t.cluster(b), Some("e\u{301}".as_bytes())); + assert!(t.contains(a)); + assert!(!t.contains(GRAPHEME_START + 2)); + } + + #[test] + fn grapheme_table_exhaustion_is_oom() { + let mut t = GraphemeTable { + next: 0, + ..GraphemeTable::default() + }; + assert!(matches!(t.glyph_index(b"ab"), Err(Error::OutOfMemory))); + } + + #[test] + fn metrics_prefer_bounding_box() { + let m = TextMetrics { + width: 10.0, + actual_bounding_box_right: 8.7, + ..TextMetrics::default() + }; + let hm = TextMetrics { + actual_bounding_box_ascent: 12.4, + actual_bounding_box_descent: 3.9, + ..TextMetrics::default() + }; + let r = calc_metrics(m, hm); + assert_eq!(r.cell_width, 8); + assert_eq!(r.cell_height, 16); + assert_eq!(r.cell_baseline, 3); + assert_eq!(r.underline_position, 15); + assert_eq!(r.underline_thickness, 1); + assert_eq!(r.strikethrough_position, 15); + assert_eq!(r.strikethrough_thickness, 1); + } + + #[test] + fn metrics_fall_back_to_run_width() { + let m = TextMetrics { + width: 9.5, + ..TextMetrics::default() + }; + assert_eq!(calc_metrics(m, TextMetrics::default()).cell_width, 9); + } + + #[test] + fn presentation_detects_color() { + assert_eq!( + bitmap_presentation(&[0, 0, 0, 255, 0, 0, 0, 10]), + Presentation::Text + ); + assert_eq!( + bitmap_presentation(&[0, 0, 0, 255, 0, 5, 0, 255]), + Presentation::Emoji + ); + assert_eq!(bitmap_presentation(&[]), Presentation::Text); + } + + #[test] + fn glyph_size_has_one_pixel_border() { + let m = TextMetrics { + width: 7.0, + actual_bounding_box_right: 6.2, + actual_bounding_box_ascent: 9.1, + actual_bounding_box_descent: 2.0, + ..TextMetrics::default() + }; + assert_eq!(glyph_size(m), (8, 13, false)); + } + + #[test] + fn glyph_size_broken_bbox_is_square() { + let m = TextMetrics { + width: 14.0, + ..TextMetrics::default() + }; + assert_eq!(glyph_size(m), (15, 15, true)); + } + + #[test] + fn bitmap_conversion() { + let rgba = [1, 2, 3, 4, 5, 6, 7, 8]; + assert_eq!( + bitmap_for_atlas(&rgba, Format::Bgra).unwrap(), + [3, 2, 1, 4, 7, 6, 5, 8] + ); + assert_eq!(bitmap_for_atlas(&rgba, Format::Grayscale).unwrap(), [4, 8]); + assert!(matches!( + bitmap_for_atlas(&rgba, Format::Bgr), + Err(Error::UnsupportedAtlasFormat) + )); + } + + #[test] + fn put_in_atlas_writes_alpha() { + let mut atlas = Atlas::new(32, Format::Grayscale); + let rgba = [0, 0, 0, 200, 0, 0, 0, 100, 0, 0, 0, 50, 0, 0, 0, 25]; + let g = put_in_atlas(&mut atlas, 2, 2, &rgba).unwrap(); + assert_eq!((g.width, g.height), (2, 2)); + let row = |y: u32| { + let o = ((g.atlas_y + y) * atlas.size + g.atlas_x) as usize; + atlas.data[o..o + 2].to_vec() + }; + assert_eq!(row(0), [200, 100]); + assert_eq!(row(1), [50, 25]); + } +} diff --git a/crates/ghostty-font/src/lib.rs b/crates/ghostty-font/src/lib.rs index a854a66..0fc7ac8 100644 --- a/crates/ghostty-font/src/lib.rs +++ b/crates/ghostty-font/src/lib.rs @@ -8,37 +8,55 @@ pub mod atlas; pub mod backend; pub mod codepoint_map; +#[cfg(not(target_arch = "wasm32"))] pub mod codepoint_resolver; +#[cfg(not(target_arch = "wasm32"))] pub mod collection; pub mod config_types; +#[cfg(not(target_arch = "wasm32"))] pub mod deferred_face; +#[cfg(target_arch = "wasm32")] +#[path = "deferred_face_web_canvas.rs"] +pub mod deferred_face; +#[cfg(not(target_arch = "wasm32"))] pub mod discovery; +#[cfg(not(target_arch = "wasm32"))] pub mod embedded; pub mod face; +#[cfg(not(target_arch = "wasm32"))] pub mod fontconfig; pub mod glyph; +#[cfg(not(target_arch = "wasm32"))] pub mod library; pub mod metrics; pub mod nerd_font_attributes; pub mod opentype; pub mod shape; pub mod shaper; +#[cfg(not(target_arch = "wasm32"))] pub mod shared_grid; +#[cfg(not(target_arch = "wasm32"))] pub mod shared_grid_set; pub mod sprite; pub use atlas::Atlas; pub use backend::{Backend, OPTIONS as options}; pub use codepoint_map::CodepointMap; +#[cfg(not(target_arch = "wasm32"))] pub use codepoint_resolver::CodepointResolver; +#[cfg(not(target_arch = "wasm32"))] pub use collection::Collection; pub use deferred_face::DeferredFace; +#[cfg(not(target_arch = "wasm32"))] pub use discovery::{Descriptor, Discover}; pub use face::Face; pub use glyph::Glyph; +#[cfg(not(target_arch = "wasm32"))] pub use library::Library; pub use metrics::Metrics; +#[cfg(not(target_arch = "wasm32"))] pub use shared_grid::SharedGrid; +#[cfg(not(target_arch = "wasm32"))] pub use shared_grid_set::SharedGridSet; pub use sprite::{Face as SpriteFace, Sprite}; @@ -72,11 +90,13 @@ pub enum Presentation { } /// A FontIndex that can be used to use the sprite font directly. +#[cfg(not(target_arch = "wasm32"))] pub const SPRITE_INDEX: collection::Index = collection::Index::init_special(collection::Special::Sprite); /// The default font size adjustment we use when loading fallback fonts. /// /// TODO: Add user configuration for this instead of hard-coding it. +#[cfg(not(target_arch = "wasm32"))] pub const DEFAULT_FALLBACK_ADJUSTMENT: collection::SizeAdjustment = collection::SizeAdjustment::IcWidth; diff --git a/crates/ghostty-font/src/metrics.rs b/crates/ghostty-font/src/metrics.rs index 47eeff8..61533e6 100644 --- a/crates/ghostty-font/src/metrics.rs +++ b/crates/ghostty-font/src/metrics.rs @@ -241,9 +241,8 @@ fn zround(v: f64) -> f64 { impl Metrics { /// All-zero metrics, except `cursor_thickness` which defaults to 1. - /// (Zig's private `init()`; used by tests.) - #[cfg(test)] - fn init() -> Metrics { + /// (Zig's private `init()`; used by tests and the web_canvas face.) + pub(crate) fn init() -> Metrics { Metrics { cell_width: 0, cell_height: 0, diff --git a/crates/ghostty-font/src/shape.rs b/crates/ghostty-font/src/shape.rs index 608cda3..996467d 100644 --- a/crates/ghostty-font/src/shape.rs +++ b/crates/ghostty-font/src/shape.rs @@ -3,12 +3,17 @@ //! The shaper implementation for the Linux backend is HarfBuzz //! ([`crate::shaper::harfbuzz::Shaper`]). +pub use crate::shaper::feature::{DEFAULT_FEATURES, Feature, FeatureList}; /// Shaper implementation for our compile options (backend /// `fontconfig_freetype` => HarfBuzz). +#[cfg(not(target_arch = "wasm32"))] pub use crate::shaper::harfbuzz::Shaper; +/// Shaper implementation for the `web_canvas` backend (wasm32). +#[cfg(target_arch = "wasm32")] +pub use crate::shaper::web_canvas::Shaper; +#[cfg(not(target_arch = "wasm32"))] pub use crate::shaper::{ cache::Cache, - feature::{DEFAULT_FEATURES, Feature, FeatureList}, harfbuzz, noop, run::{RunIterator, RunIteratorHook, RunOptions, TextRun}, diff --git a/crates/ghostty-font/src/shaper/mod.rs b/crates/ghostty-font/src/shaper/mod.rs index 353f143..165b15e 100644 --- a/crates/ghostty-font/src/shaper/mod.rs +++ b/crates/ghostty-font/src/shaper/mod.rs @@ -1,11 +1,18 @@ //! Text shapers. Port of `font/shaper/`. +#[cfg(not(target_arch = "wasm32"))] pub mod cache; pub mod feature; +#[cfg(not(target_arch = "wasm32"))] pub mod harfbuzz; +#[cfg(not(target_arch = "wasm32"))] mod hb; +#[cfg(not(target_arch = "wasm32"))] pub mod noop; +#[cfg(not(target_arch = "wasm32"))] pub mod run; -#[cfg(test)] +pub mod web_canvas; + +#[cfg(all(test, not(target_arch = "wasm32")))] pub(crate) mod test_util; diff --git a/crates/ghostty-font/src/shaper/web_canvas.rs b/crates/ghostty-font/src/shaper/web_canvas.rs new file mode 100644 index 0000000..41c07a3 --- /dev/null +++ b/crates/ghostty-font/src/shaper/web_canvas.rs @@ -0,0 +1,254 @@ +//! The web canvas shaper. Port of `font/shaper/web_canvas.zig`. +//! +//! Canvas cannot shape, so this shaper only performs grapheme clustering: +//! it can render things like skin tone emoji but not ligatures such as "=>". +//! `font_features` are not supported. +//! +//! The Zig shaper plugs into `RunIterator` through hooks; the Rust +//! `RunIterator` is tied to the FreeType `Collection`, which does not exist +//! on wasm32, so the hook methods ([`Shaper::prepare`], [`Shaper::add_codepoint`], +//! [`Shaper::finalize`]) are exposed directly. The face that reserves +//! grapheme glyph indexes is passed to [`Shaper::shape`] as a closure +//! (`face.graphemeGlyphIndex` in Zig, `run.group.faceFromIndex(run.font_index)`). + +use ghostty_unicode::grapheme::{BreakState, grapheme_break}; + +use crate::shape::Cell; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invalid codepoint")] + InvalidCodepoint, + #[error("cell buffer too small")] + CellBufferTooSmall, + #[error(transparent)] + Face(#[from] crate::face::web_canvas::Error), +} + +#[derive(Debug, Clone, Copy)] +struct RunItem { + /// The codepoint for this cell. This must be used in conjunction with + /// cluster to find the total set of codepoints for a given cell. + codepoint: u32, + + /// Cluster is set to the X value of the cell that this codepoint is part + /// of. Note that a cell can have multiple codepoints with zero-width + /// joiners (ZWJ) and such. Terminals do NOT handle full extended + /// grapheme clustering well so it is possible a single grapheme extends + /// multiple clusters. + cluster: u32, +} + +#[derive(Debug)] +pub struct Shaper { + /// The shared memory used for shaping results. + cell_buf: Vec, + + /// The shared memory used for storing information about a run. + run_buf: Vec, +} + +impl Shaper { + /// `cap` is the size of the buffer used for storing shaped results. + /// This should be at least the number of columns in the terminal. + pub fn new(cap: usize) -> Shaper { + Shaper { + cell_buf: vec![Cell::default(); cap], + run_buf: Vec::new(), + } + } + + pub fn cell_buf_len(&self) -> usize { + self.cell_buf.len() + } + + pub fn end_frame(&self) {} + + /// Hook: reset the buffer for our current run. + pub fn prepare(&mut self) { + self.run_buf.clear(); + } + + /// Hook: add a codepoint of the current run. + pub fn add_codepoint(&mut self, cp: u32, cluster: u32) { + self.run_buf.push(RunItem { + codepoint: cp, + cluster, + }); + } + + /// Hook: finalize the run (nothing to do). + pub fn finalize(&mut self) {} + + /// Shape the current text run. The return value is only valid until the + /// next shape call. `grapheme_index` maps a UTF-8 grapheme cluster to a + /// glyph index (see `Face::grapheme_glyph_index`). + pub fn shape( + &mut self, + mut grapheme_index: impl FnMut(&[u8]) -> Result, + ) -> Result<&[Cell], Error> + where + Error: From, + { + let n = self.run_buf.len(); + if n > self.cell_buf.len() { + return Err(Error::CellBufferTooSmall); + } + + match n { + // Special cases: if we have no codepoints (is this possible?) + // then our result is also an empty cell run. + 0 => return Ok(&self.cell_buf[..0]), + + // If we have only 1 codepoint, then we assume that it is a + // single grapheme and just let it through. At this point, we + // can't have any more information to do anything else. + 1 => { + self.cell_buf[0] = Cell { + x: self.run_buf[0].cluster as u16, + glyph_index: self.run_buf[0].codepoint, + ..Cell::default() + }; + return Ok(&self.cell_buf[..1]); + } + + _ => {} + } + + // We know we have at least two codepoints, so we now go through each + // and perform grapheme clustering. + let mut break_state = BreakState::Default; + let mut cp1 = self.run_buf[0].codepoint; + + let mut start = 0usize; + let mut cur = 0usize; + let mut i = 1usize; + while i <= n { + // We loop to n so that we can handle the end case. In the end + // case, we always assume it is a grapheme break. This isn't + // strictly true but its how terminals work today. + let is_break = i == n || { + let cp2 = self.run_buf[i].codepoint; + let r = grapheme_break(cp1, cp2, &mut break_state); + cp1 = cp2; + r + }; + + // If this is NOT a grapheme break, cp2 is part of a single + // grapheme cluster and we expect there could be more. + if !is_break { + i += 1; + continue; + } + + // This IS a grapheme break, meaning that cp2 is NOT part of cp1. + // So we need to render the prior grapheme. + let len = i - start; + debug_assert!(len > 0); + let glyph_index = if len == 1 { + // If we have only a single codepoint then just render it as-is. + self.run_buf[start].codepoint + } else { + // We must have multiple codepoints. In this case we UTF-8 + // encode the codepoints and send them to the face to reserve + // a private glyph index. + let mut cluster = String::new(); + for item in &self.run_buf[start..i] { + cluster.push(char::from_u32(item.codepoint).ok_or(Error::InvalidCodepoint)?); + } + grapheme_index(cluster.as_bytes()).map_err(Error::from)? + }; + + self.cell_buf[cur] = Cell { + x: self.run_buf[start].cluster as u16, + glyph_index, + ..Cell::default() + }; + + start = i; + cur += 1; + i += 1; + } + + Ok(&self.cell_buf[..cur]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::face::web_canvas::{Error as FaceError, GraphemeTable}; + + fn run(codepoints: &[(u32, u32)]) -> (Vec, GraphemeTable) { + let mut shaper = Shaper::new(16); + let mut table = GraphemeTable::default(); + shaper.prepare(); + for (cp, cl) in codepoints { + shaper.add_codepoint(*cp, *cl); + } + shaper.finalize(); + let cells = shaper + .shape(|c| table.glyph_index(c).map_err(Error::from)) + .unwrap() + .to_vec(); + (cells, table) + } + + #[test] + fn empty_run() { + assert_eq!(run(&[]).0.len(), 0); + } + + #[test] + fn single_codepoint_passes_through() { + let (cells, _) = run(&[(0x41, 3)]); + assert_eq!(cells.len(), 1); + assert_eq!((cells[0].x, cells[0].glyph_index), (3, 0x41)); + } + + #[test] + fn plain_text_is_one_cell_per_codepoint() { + let (cells, table) = run(&[(0x61, 0), (0x62, 1), (0x63, 2)]); + assert_eq!( + cells + .iter() + .map(|c| (c.x, c.glyph_index)) + .collect::>(), + [(0, 0x61), (1, 0x62), (2, 0x63)] + ); + assert!(!table.contains(crate::face::web_canvas::GRAPHEME_START)); + } + + #[test] + fn combining_mark_reserves_grapheme_glyph() { + // e + COMBINING ACUTE ACCENT, then x + let (cells, table) = run(&[(0x65, 0), (0x301, 0), (0x78, 1)]); + assert_eq!(cells.len(), 2); + assert_eq!(cells[0].x, 0); + assert_eq!( + cells[0].glyph_index, + crate::face::web_canvas::GRAPHEME_START + ); + assert_eq!( + table.cluster(cells[0].glyph_index), + Some("e\u{301}".as_bytes()) + ); + assert_eq!((cells[1].x, cells[1].glyph_index), (1, 0x78)); + } + + #[test] + fn skin_tone_emoji_is_one_cluster() { + let (cells, table) = run(&[(0x1F44D, 0), (0x1F3FD, 0)]); + assert_eq!(cells.len(), 1); + assert_eq!(table.cluster(cells[0].glyph_index), Some("šŸ‘šŸ½".as_bytes())); + } + + #[test] + fn buffer_too_small() { + let mut shaper = Shaper::new(1); + shaper.add_codepoint(0x61, 0); + shaper.add_codepoint(0x62, 1); + let r = shaper.shape(|_| Ok::(0)); + assert!(matches!(r, Err(Error::CellBufferTooSmall))); + } +} diff --git a/crates/ghostty-os/Cargo.toml b/crates/ghostty-os/Cargo.toml index 541f6c8..a14d939 100644 --- a/crates/ghostty-os/Cargo.toml +++ b/crates/ghostty-os/Cargo.toml @@ -6,16 +6,51 @@ license.workspace = true [dependencies] ghostty-vt.workspace = true -gettext-rs = { version = "0.8.0", features = ["gettext-system"] } log = "0.4.34" +thiserror = "2.0.21" + +# Zig's wasm build only compiles `os/wasm.zig`; the POSIX / D-Bus / gettext +# dependencies are unavailable on wasm32. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +gettext-rs = { version = "0.8.0", features = ["gettext-system"] } nix = { version = "0.31.3", features = ["user"] } rustix = { version = "1.1.5", features = ["fs", "process", "system", "pipe", "net", "time", "rand", "pty"] } -thiserror = "2.0.21" zbus = "5.19.0" +[features] +# Mirrors Zig's `wasm_shared` build option (SharedArrayBuffer-backed memory). +wasm-shared = [] -[lints] -workspace = true +# Same as `[workspace.lints]` except `unsafe_code` is denied instead of +# forbidden: the wasm JS import blocks (`src/wasm`) need `unsafe`. +[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" diff --git a/crates/ghostty-os/src/lib.rs b/crates/ghostty-os/src/lib.rs index 2e8dc9f..f8c1aa1 100644 --- a/crates/ghostty-os/src/lib.rs +++ b/crates/ghostty-os/src/lib.rs @@ -15,38 +15,69 @@ )] pub mod args; +#[cfg(not(target_arch = "wasm32"))] pub mod cgroup; +#[cfg(not(target_arch = "wasm32"))] pub mod dbus; +#[cfg(not(target_arch = "wasm32"))] pub mod desktop; +#[cfg(not(target_arch = "wasm32"))] pub mod env; +#[cfg(not(target_arch = "wasm32"))] pub mod file; pub mod file_type; +#[cfg(not(target_arch = "wasm32"))] pub mod flatpak; +#[cfg(not(target_arch = "wasm32"))] pub mod homedir; +#[cfg(not(target_arch = "wasm32"))] pub mod hostname; +#[cfg(not(target_arch = "wasm32"))] pub mod i18n; pub mod i18n_locales; +#[cfg(not(target_arch = "wasm32"))] pub mod kernel_info; +#[cfg(not(target_arch = "wasm32"))] pub mod locale; pub mod mouse; +#[cfg(not(target_arch = "wasm32"))] pub mod open; +#[cfg(not(target_arch = "wasm32"))] pub mod passwd; +#[cfg(not(target_arch = "wasm32"))] pub mod path; +#[cfg(not(target_arch = "wasm32"))] pub mod pipe; +#[cfg(not(target_arch = "wasm32"))] pub mod resourcesdir; pub mod shell; pub mod string_encoding; +#[cfg(not(target_arch = "wasm32"))] pub mod systemd; +#[cfg(not(target_arch = "wasm32"))] pub mod temp_dir; pub mod uri; +#[cfg(target_arch = "wasm32")] +pub mod wasm; + +#[cfg(target_arch = "wasm32")] +mod unsupported; +#[cfg(target_arch = "wasm32")] +pub use unsupported::homedir::expand_home; +#[cfg(target_arch = "wasm32")] +pub use unsupported::{ResourcesDir, homedir, is_flatpak, passwd, resources_dir, xdg}; +#[cfg(not(target_arch = "wasm32"))] pub mod xdg; #[cfg(test)] pub(crate) mod testutil; // Functions and types (mirrors the flat exports of `os/main.zig`). +#[cfg(not(target_arch = "wasm32"))] pub use dbus::launched_by_dbus_activation; +#[cfg(not(target_arch = "wasm32"))] pub use desktop::{DesktopEnvironment, desktop_environment, launched_from_desktop}; +#[cfg(not(target_arch = "wasm32"))] pub use env::{ EnvMap, append_env, @@ -59,21 +90,32 @@ pub use env::{ prepend_env, prepend_env_os, }; +#[cfg(not(target_arch = "wasm32"))] pub use file::{Rlimit, alloc_tmp_dir, fix_max_files, restore_max_files}; +#[cfg(not(target_arch = "wasm32"))] pub use flatpak::{FlatpakHostCommand, is_flatpak}; +#[cfg(not(target_arch = "wasm32"))] pub use homedir::{expand_home, home}; +#[cfg(not(target_arch = "wasm32"))] pub use kernel_info::get_kernel_info; +#[cfg(not(target_arch = "wasm32"))] pub use locale::ensure_locale; pub use mouse::click_interval; +#[cfg(not(target_arch = "wasm32"))] pub use open::open; +#[cfg(not(target_arch = "wasm32"))] pub use pipe::pipe; +#[cfg(not(target_arch = "wasm32"))] pub use resourcesdir::{ResourcesDir, resources_dir}; pub use shell::ShellEscapeWriter; +#[cfg(not(target_arch = "wasm32"))] pub use systemd::launched_by_systemd; +#[cfg(not(target_arch = "wasm32"))] pub use temp_dir::TempDir; /// Join path components like `std.fs.path.join` (POSIX): empty components /// are skipped and exactly one separator is placed between components. +#[cfg(not(target_arch = "wasm32"))] pub(crate) fn path_join(parts: &[&str]) -> String { let mut out = String::new(); for p in parts { diff --git a/crates/ghostty-os/src/unsupported.rs b/crates/ghostty-os/src/unsupported.rs new file mode 100644 index 0000000..c22838a --- /dev/null +++ b/crates/ghostty-os/src/unsupported.rs @@ -0,0 +1,104 @@ +//! API-compatible stand-ins for OS facilities that do not exist on wasm32, +//! so crates that are also built for wasm (`ghostty-config`) compile +//! unchanged. In Zig these are simply never analyzed on wasm +//! (`os/passwd.zig` even `@compileError`s); here each reports "unavailable" +//! the way the Zig callers' error paths expect. + +use std::io; + +/// `homedir.zig` is unavailable: nothing can be expanded. +pub mod homedir { + #[derive(Debug, thiserror::Error, PartialEq, Eq)] + pub enum ExpandError { + #[error("home detection failed")] + HomeDetectionFailed, + #[error("buffer too small")] + BufferTooSmall, + } + + /// Paths without a leading `~/` pass through; others fail. + pub fn expand_home(path: &str) -> Result { + if path.starts_with("~/") { + Err(ExpandError::HomeDetectionFailed) + } else { + Ok(path.to_owned()) + } + } +} + +pub mod passwd { + #[derive(Debug, Clone, Default, PartialEq, Eq)] + pub struct Entry { + pub shell: Option, + pub home: Option, + pub name: Option, + } + + /// Zig: `@compileError("passwd is not available for wasm")`. + pub fn get() -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "passwd is not available for wasm", + )) + } +} + +pub mod xdg { + use std::ffi::CStr; + + #[derive(Debug, Clone, Copy, Default)] + pub struct Options<'a> { + pub subdir: Option<&'a str>, + pub home: Option<&'a str>, + } + + #[derive(Debug, thiserror::Error, PartialEq, Eq)] + pub enum Error { + #[error("xdg directories are not available on wasm")] + Unavailable, + } + + pub fn config(_: &Options<'_>) -> Result { + Err(Error::Unavailable) + } + + pub fn cache(_: &Options<'_>) -> Result { + Err(Error::Unavailable) + } + + pub fn state(_: &Options<'_>) -> Result { + Err(Error::Unavailable) + } + + /// No `xdg-terminal-exec` mode on wasm. + pub fn parse_terminal_exec<'a>(_: &'a [&'a CStr]) -> Option<&'a [&'a CStr]> { + None + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResourcesDir { + pub app_path: Option, + pub host_path: Option, +} + +impl ResourcesDir { + pub fn app(&self) -> Option<&str> { + self.app_path.as_deref() + } + + pub fn host(&self) -> Option<&str> { + self.host_path.as_deref() + } +} + +pub fn resources_dir() -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "resources dir is not available on wasm", + )) +} + +pub fn is_flatpak() -> bool { + false +} diff --git a/crates/ghostty-os/src/wasm/js.rs b/crates/ghostty-os/src/wasm/js.rs new file mode 100644 index 0000000..ec28c8a --- /dev/null +++ b/crates/ghostty-os/src/wasm/js.rs @@ -0,0 +1,307 @@ +//! JS interop for wasm. Stands in for the `zig-js` package +//! (`mitchellh/zig-js`) that Ghostty's Zig wasm code (`font/face/web_canvas`, +//! `font/Atlas`) imports as `js`. +//! +//! The `zig-js` source is not part of the Ghostty tree, so its exact import +//! names cannot be mirrored; this module defines an equivalent handle based +//! protocol in the import module `ghostty_js` and keeps the *Rust-side API* +//! shaped like `zig-js` (`Object`, `get`, `set`, `call`, `new`, string and +//! number arguments, global object lookup). The host (JS) keeps a table of +//! values; wasm only holds `u32` handles. Handle `0` is "invalid" and is +//! what every import returns when the JS operation threw. +//! +//! Imports (module `ghostty_js`): +//! +//! | import | signature | +//! |---|---| +//! | `global` | `() -> handle` of `globalThis` | +//! | `get` | `(obj, name_ptr, name_len) -> handle` | +//! | `set` | `(obj, name_ptr, name_len, value) -> i32` (1 = ok) | +//! | `call` | `(obj, name_ptr, name_len, args_ptr, args_len) -> handle` | +//! | `new` | `(ctor, args_ptr, args_len) -> handle` | +//! | `string` | `(ptr, len) -> handle` (UTF-8 -> JS string) | +//! | `number` | `(f64) -> handle` | +//! | `boolean` | `(i32) -> handle` | +//! | `to_f64` | `(handle) -> f64` | +//! | `string_len` | `(handle) -> usize` (UTF-8 byte length) | +//! | `string_copy` | `(handle, ptr, cap) -> usize` (writes UTF-8) | +//! | `is_nullish` | `(handle) -> i32` | +//! | `bytes` | `(ptr, len) -> handle` (a `Uint8ClampedArray` copy of wasm memory) | +//! | `copy_bytes` | `(array_handle, dst_ptr, len) -> i32` (copies a `Uint8Array`-like into wasm memory) | +//! | `release` | `(handle)` | +#![allow(unsafe_code)] + +use std::fmt; + +mod ffi { + #[link(wasm_import_module = "ghostty_js")] + unsafe extern "C" { + pub fn global() -> u32; + pub fn get(obj: u32, name: *const u8, len: usize) -> u32; + pub fn set(obj: u32, name: *const u8, len: usize, value: u32) -> i32; + pub fn call(obj: u32, name: *const u8, len: usize, args: *const u32, argc: usize) -> u32; + pub fn new(ctor: u32, args: *const u32, argc: usize) -> u32; + pub fn string(ptr: *const u8, len: usize) -> u32; + pub fn number(v: f64) -> u32; + pub fn boolean(v: i32) -> u32; + pub fn to_f64(h: u32) -> f64; + pub fn string_len(h: u32) -> usize; + pub fn string_copy(h: u32, ptr: *mut u8, cap: usize) -> usize; + pub fn is_nullish(h: u32) -> i32; + pub fn bytes(ptr: *const u8, len: usize) -> u32; + pub fn copy_bytes(h: u32, dst: *mut u8, len: usize) -> i32; + pub fn release(h: u32); + } +} + +/// Errors from JS interop (`zig-js` `error.JSError` and friends). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Error { + /// The JS operation threw (or the handle was invalid). + Js, + /// The value is `null`/`undefined` where an object was required. + Nullish, + /// A returned string was not valid UTF-8. + Utf8, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Js => f.write_str("javascript error"), + Error::Nullish => f.write_str("value is null or undefined"), + Error::Utf8 => f.write_str("invalid utf-8 from javascript"), + } + } +} + +impl std::error::Error for Error {} + +pub type Result = std::result::Result; + +/// A reference to a JS value. Released on drop (zig-js `Object.deinit`). +#[derive(Debug, PartialEq, Eq)] +pub struct Object(u32); + +/// An argument for [`Object::call`] / [`Object::new_instance`]. +#[derive(Debug, Clone, Copy)] +pub enum Arg<'a> { + Object(&'a Object), + Str(&'a str), + Num(f64), + Bool(bool), +} + +impl<'a> From<&'a Object> for Arg<'a> { + fn from(v: &'a Object) -> Self { + Arg::Object(v) + } +} + +impl<'a> From<&'a str> for Arg<'a> { + fn from(v: &'a str) -> Self { + Arg::Str(v) + } +} + +impl From for Arg<'_> { + fn from(v: f64) -> Self { + Arg::Num(v) + } +} + +impl From for Arg<'_> { + fn from(v: f32) -> Self { + Arg::Num(f64::from(v)) + } +} + +impl From for Arg<'_> { + fn from(v: u32) -> Self { + Arg::Num(f64::from(v)) + } +} + +impl From for Arg<'_> { + fn from(v: bool) -> Self { + Arg::Bool(v) + } +} + +fn check(h: u32) -> Result { + if h == 0 { + Err(Error::Js) + } else { + Ok(Object(h)) + } +} + +/// Converts arguments to handles. Temporaries are released by [`Args::drop`]. +struct Args { + handles: Vec, + owned: Vec, +} + +impl Args { + fn new(args: &[Arg<'_>]) -> Result { + let mut out = Args { + handles: Vec::with_capacity(args.len()), + owned: Vec::new(), + }; + for a in args { + let h = match a { + Arg::Object(o) => o.0, + // SAFETY: pointer/length of a live &str; host copies it. + Arg::Str(s) => unsafe { ffi::string(s.as_ptr(), s.len()) }, + // SAFETY: plain value call. + Arg::Num(n) => unsafe { ffi::number(*n) }, + // SAFETY: plain value call. + Arg::Bool(b) => unsafe { ffi::boolean(i32::from(*b)) }, + }; + if h == 0 { + return Err(Error::Js); + } + if !matches!(a, Arg::Object(_)) { + out.owned.push(h); + } + out.handles.push(h); + } + Ok(out) + } +} + +impl Drop for Args { + fn drop(&mut self) { + for h in &self.owned { + // SAFETY: handle was created by us and not yet released. + unsafe { ffi::release(*h) }; + } + } +} + +/// `js.global`: the `globalThis` object. +pub fn global() -> Result { + // SAFETY: plain call into the host. + check(unsafe { ffi::global() }) +} + +impl Object { + /// Creates a JS string value. + pub fn string(s: &str) -> Result { + // SAFETY: pointer/length of a live &str; host copies it. + check(unsafe { ffi::string(s.as_ptr(), s.len()) }) + } + + /// Creates a `Uint8ClampedArray` holding a copy of `data` (what zig-js + /// callers built by viewing wasm memory with `new Uint8ClampedArray( + /// memory.buffer, ptr, len)`; copying also works for shared memory). + pub fn clamped_array(data: &[u8]) -> Result { + // SAFETY: pointer/length of a live slice; host copies it. + check(unsafe { ffi::bytes(data.as_ptr(), data.len()) }) + } + + /// Gives up ownership of the handle without releasing it (the host is + /// expected to release it, like zig-js `deleteValue`). + pub fn into_raw(self) -> u32 { + let h = self.0; + std::mem::forget(self); + h + } + + /// Gets a property as an object reference (also used for primitives, + /// which are boxed on the host side). + pub fn get(&self, name: &str) -> Result { + // SAFETY: pointer/length of a live &str; host copies it. + check(unsafe { ffi::get(self.0, name.as_ptr(), name.len()) }) + } + + /// Gets a property that must be an object (not null/undefined). + pub fn get_object(&self, name: &str) -> Result { + let v = self.get(name)?; + if v.is_nullish() { + return Err(Error::Nullish); + } + Ok(v) + } + + /// Gets a numeric property. + pub fn get_f64(&self, name: &str) -> Result { + Ok(self.get(name)?.as_f64()) + } + + /// Gets a numeric property as `f32` (zig `get(f32, ...)`). + pub fn get_f32(&self, name: &str) -> Result { + Ok(self.get_f64(name)? as f32) + } + + /// Gets a string property. + pub fn get_string(&self, name: &str) -> Result { + self.get(name)?.to_string_value() + } + + /// Sets a property. + pub fn set(&self, name: &str, value: Arg<'_>) -> Result<()> { + let args = Args::new(&[value])?; + // SAFETY: pointer/length of a live &str; handle is valid. + let ok = unsafe { ffi::set(self.0, name.as_ptr(), name.len(), args.handles[0]) }; + if ok == 1 { Ok(()) } else { Err(Error::Js) } + } + + /// Calls a method and returns the result (which may be nullish). + pub fn call(&self, name: &str, args: &[Arg<'_>]) -> Result { + let args = Args::new(args)?; + // SAFETY: name and args pointers are live for the call. + check(unsafe { + ffi::call( + self.0, + name.as_ptr(), + name.len(), + args.handles.as_ptr(), + args.handles.len(), + ) + }) + } + + /// `new this(...args)` (zig-js `Object.new`). + pub fn new_instance(&self, args: &[Arg<'_>]) -> Result { + let args = Args::new(args)?; + // SAFETY: args pointer is live for the call. + check(unsafe { ffi::new(self.0, args.handles.as_ptr(), args.handles.len()) }) + } + + pub fn is_nullish(&self) -> bool { + // SAFETY: plain handle call. + unsafe { ffi::is_nullish(self.0) != 0 } + } + + /// Numeric value of this object (`Number(v)` on the host). + pub fn as_f64(&self) -> f64 { + // SAFETY: plain handle call. + unsafe { ffi::to_f64(self.0) } + } + + /// String value of this object (`String(v)` on the host). + pub fn to_string_value(&self) -> Result { + // SAFETY: plain handle call. + let len = unsafe { ffi::string_len(self.0) }; + let mut buf = vec![0u8; len]; + // SAFETY: `buf` has `len` writable bytes. + let n = unsafe { ffi::string_copy(self.0, buf.as_mut_ptr(), buf.len()) }; + buf.truncate(n); + String::from_utf8(buf).map_err(|_| Error::Utf8) + } + + /// Copies this (`Uint8Array`/`Uint8ClampedArray`) object into `dst`. + pub fn copy_bytes_to(&self, dst: &mut [u8]) -> Result<()> { + // SAFETY: `dst` is `dst.len()` writable bytes in wasm memory. + let ok = unsafe { ffi::copy_bytes(self.0, dst.as_mut_ptr(), dst.len()) }; + if ok == 1 { Ok(()) } else { Err(Error::Js) } + } +} + +impl Drop for Object { + fn drop(&mut self) { + // SAFETY: we own this handle. + unsafe { ffi::release(self.0) }; + } +} diff --git a/crates/ghostty-os/src/wasm/log.rs b/crates/ghostty-os/src/wasm/log.rs new file mode 100644 index 0000000..a05881d --- /dev/null +++ b/crates/ghostty-os/src/wasm/log.rs @@ -0,0 +1,76 @@ +//! Freestanding log sink. Port of `ZIG/src/os/wasm/log.zig`. +//! +//! Formats `"(): "` (or `": "` for +//! the default scope) and hands it to the host through the `env.log` import, +//! exactly like Ghostty's `Freestanding.log`. +#![allow(unsafe_code)] + +use ::log::{Level, LevelFilter, Log, Metadata, Record}; + +mod ffi { + #[link(wasm_import_module = "env")] + unsafe extern "C" { + /// `extern "env" fn log(ptr: [*]const u8, len: usize) void` + pub fn log(ptr: *const u8, len: usize); + } +} + +/// Zig `std.log.Level.asText()`. +fn level_text(level: Level) -> &'static str { + match level { + Level::Error => "error", + Level::Warn => "warning", + Level::Info => "info", + Level::Debug | Level::Trace => "debug", + } +} + +/// Formats a record like the Zig log function does. +pub fn format_record(level: Level, scope: &str, args: &std::fmt::Arguments<'_>) -> String { + let prefix = if scope.is_empty() || scope == "default" { + ": ".to_owned() + } else { + format!("({scope}): ") + }; + format!("{}{prefix}{args}", level_text(level)) +} + +/// Sends a string to the host. +pub fn host_log(s: &str) { + // SAFETY: the pointer/length pair describes a live UTF-8 buffer and the + // host only reads it for the duration of the call. + unsafe { ffi::log(s.as_ptr(), s.len()) } +} + +struct Logger; + +impl Log for Logger { + fn enabled(&self, _: &Metadata<'_>) -> bool { + true + } + + fn log(&self, record: &Record<'_>) { + host_log(&format_record( + record.level(), + record.target(), + record.args(), + )); + } + + fn flush(&self) {} +} + +static LOGGER: Logger = Logger; + +/// Installs the logger. Zig's level: debug in Debug, warn in ReleaseSmall, +/// info otherwise; here debug with `debug_assertions`, info otherwise. +pub fn init() { + let level = if cfg!(debug_assertions) { + LevelFilter::Debug + } else { + LevelFilter::Info + }; + if ::log::set_logger(&LOGGER).is_ok() { + ::log::set_max_level(level); + } +} diff --git a/crates/ghostty-os/src/wasm/mod.rs b/crates/ghostty-os/src/wasm/mod.rs new file mode 100644 index 0000000..528cbab --- /dev/null +++ b/crates/ghostty-os/src/wasm/mod.rs @@ -0,0 +1,22 @@ +//! Helpers for wasm compilation. Port of `ZIG/src/os/wasm.zig` and +//! `ZIG/src/os/wasm/*.zig`. +//! +//! Only compiled for `wasm32` (Zig: `@compileError` otherwise). The module is +//! organised like the Zig directory: +//! +//! - [`log`]: the freestanding log sink (`os/wasm/log.zig`). +//! - [`target`]: the wasm target platform (`os/wasm/target.zig`). +//! - [`js`]: the JS interop layer that stands in for the `zig-js` package +//! used by the Zig font code (see that module for the import ABI). + +pub mod js; +pub mod log; +pub mod target; + +/// True if we're in shared memory mode. If true, then the memory buffer +/// in JS will be backed by a SharedArrayBuffer and some behaviors change. +/// (Zig: `options.wasm_shared`, here the `wasm-shared` cargo feature.) +pub const SHARED_MEM: bool = cfg!(feature = "wasm-shared"); + +// The Zig `alloc` (`std.heap.wasm_allocator`) is the Rust global allocator on +// wasm32 (`std::alloc::System` is the wasm allocator); nothing to port. diff --git a/crates/ghostty-os/src/wasm/target.rs b/crates/ghostty-os/src/wasm/target.rs new file mode 100644 index 0000000..f91d831 --- /dev/null +++ b/crates/ghostty-os/src/wasm/target.rs @@ -0,0 +1,14 @@ +//! The wasm target platform. Port of `ZIG/src/os/wasm/target.zig`. +//! +//! This is used to toggle certain features on and off since the standard +//! triple target is often not specific enough (i.e. we can't tell +//! wasm32-freestanding is for browser or not). + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Target { + Browser, +} + +/// Our specific target platform. `None` on non-wasm targets (Zig); this +/// module only exists on wasm32 so it is always the browser. +pub const TARGET: Option = Some(Target::Browser); diff --git a/crates/ghostty-terminal/Cargo.toml b/crates/ghostty-terminal/Cargo.toml index 940eacd..90f73cf 100644 --- a/crates/ghostty-terminal/Cargo.toml +++ b/crates/ghostty-terminal/Cargo.toml @@ -12,11 +12,15 @@ ghostty-datastruct.workspace = true ghostty-unicode.workspace = true ghostty-vt.workspace = true log = "0.4.34" +png = "0.18.1" +thiserror = "2.0.21" + +# C / POSIX-only dependencies are unavailable on wasm32 (Zig builds the wasm +# module without oniguruma, and kitty shared-memory images need POSIX shm). +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] onig = "6.5.3" ghostty-onig.workspace = true -png = "0.18.1" rustix = { version = "1.1.5", features = ["shm", "fs"] } -thiserror = "2.0.21" [dev-dependencies] ghostty-config.workspace = true diff --git a/crates/ghostty-terminal/src/kitty/graphics_image.rs b/crates/ghostty-terminal/src/kitty/graphics_image.rs index 17a6c27..b87a226 100644 --- a/crates/ghostty-terminal/src/kitty/graphics_image.rs +++ b/crates/ghostty-terminal/src/kitty/graphics_image.rs @@ -3,13 +3,32 @@ //! [`LoadingImage`] is an image that is still being transmitted (possibly in //! chunks). [`Image`] is a fully loaded and validated image. +#[cfg(unix)] +use std::os::unix::{ffi::OsStrExt, fs::FileExt}; use std::{ io::{Cursor, Read, Seek, SeekFrom}, - os::unix::{ffi::OsStrExt, fs::FileExt}, path::Path, time::Instant, }; +/// wasm32 has no `std::os::unix`; paths are treated as UTF-8 there. +#[cfg(not(unix))] +trait OsStrExt { + fn from_bytes(bytes: &[u8]) -> &std::ffi::OsStr; + fn as_bytes(&self) -> &[u8]; +} + +#[cfg(not(unix))] +impl OsStrExt for std::ffi::OsStr { + fn from_bytes(bytes: &[u8]) -> &std::ffi::OsStr { + std::ffi::OsStr::new(std::str::from_utf8(bytes).unwrap_or("")) + } + + fn as_bytes(&self) -> &[u8] { + self.as_encoded_bytes() + } +} + use ghostty_vt::kitty::graphics_command::{ Command, Display, @@ -143,6 +162,19 @@ impl LoadingImage { } /// Reads the data from a shared memory segment. + #[cfg(target_arch = "wasm32")] + #[allow(clippy::unused_self)] + fn read_shared_memory(&mut self, _offset: u32, _size: u32, path: &[u8]) -> Result<(), Error> { + // POSIX shared memory does not exist on wasm32. + log::warn!( + "shared memory not supported on wasm: {}", + String::from_utf8_lossy(path) + ); + Err(Error::InvalidData) + } + + /// Reads the data from a shared memory segment. + #[cfg(not(target_arch = "wasm32"))] fn read_shared_memory(&mut self, offset: u32, size: u32, path: &[u8]) -> Result<(), Error> { use rustix::{fs::Mode, shm}; @@ -160,6 +192,7 @@ impl LoadingImage { result } + #[cfg(not(target_arch = "wasm32"))] fn read_shm_fd( &mut self, fd: rustix::fd::OwnedFd, @@ -898,6 +931,7 @@ mod tests { assert!(loading.complete().is_ok()); } + #[cfg(not(target_arch = "wasm32"))] #[test] fn image_load_shared_memory() { use std::io::Write; diff --git a/crates/ghostty-terminal/src/string_map.rs b/crates/ghostty-terminal/src/string_map.rs index f37785e..7f2dd0f 100644 --- a/crates/ghostty-terminal/src/string_map.rs +++ b/crates/ghostty-terminal/src/string_map.rs @@ -3,6 +3,7 @@ //! //! Port of `ZIG/src/terminal/StringMap.zig`. Regex matching uses Oniguruma //! (the `onig` crate), like Ghostty. +#[cfg(not(target_arch = "wasm32"))] use onig::{MatchParam, Regex, Region, SearchOptions}; use crate::{page_list::Pin, selection::Selection}; @@ -16,6 +17,7 @@ use crate::{page_list::Pin, selection::Selection}; /// the `ghostty-onig` wrapper since the `onig` crate does not expose it). /// Exceeding it aborts matching with an error which is treated as "no /// further matches". +#[cfg(not(target_arch = "wasm32"))] const ONI_SEARCH_RETRY_LIMIT: u32 = 100_000; /// A string and a map from each byte of the string to the pin it came from. @@ -25,6 +27,9 @@ pub struct StringMap { pub map: Vec, } +// Like Zig (`build_options.oniguruma == false`), regex search is not +// available on wasm32 (Oniguruma is a C library). +#[cfg(not(target_arch = "wasm32"))] impl StringMap { /// Returns an iterator that yields the next match of the given regex. pub fn search_iterator<'a>(&'a self, regex: &'a Regex) -> SearchIterator<'a> { @@ -36,6 +41,7 @@ impl StringMap { } } +#[cfg(not(target_arch = "wasm32"))] /// Iterates over the regular expression matches of the string. pub struct SearchIterator<'a> { map: &'a StringMap, @@ -43,6 +49,7 @@ pub struct SearchIterator<'a> { offset: usize, } +#[cfg(not(target_arch = "wasm32"))] impl<'a> SearchIterator<'a> { /// Returns the next regular expression match or None if there are /// no more matches. @@ -88,6 +95,7 @@ impl<'a> SearchIterator<'a> { } } +#[cfg(not(target_arch = "wasm32"))] impl<'a> Iterator for SearchIterator<'a> { type Item = Match<'a>; @@ -115,7 +123,7 @@ impl Match<'_> { } } -#[cfg(test)] +#[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; use crate::{ diff --git a/crates/ghostty-wasm/Cargo.toml b/crates/ghostty-wasm/Cargo.toml new file mode 100644 index 0000000..27c1fbd --- /dev/null +++ b/crates/ghostty-wasm/Cargo.toml @@ -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" diff --git a/crates/ghostty-wasm/js/ghostty_host.js b/crates/ghostty-wasm/js/ghostty_host.js new file mode 100644 index 0000000..3dc577b --- /dev/null +++ b/crates/ghostty-wasm/js/ghostty_host.js @@ -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 }; diff --git a/crates/ghostty-wasm/src/app.rs b/crates/ghostty-wasm/src/app.rs new file mode 100644 index 0000000..445ea0d --- /dev/null +++ b/crates/ghostty-wasm/src/app.rs @@ -0,0 +1,2 @@ +//! `App.zig` `Wasm`: the upstream exports (`app_new`, `app_free`) are +//! commented out, so nothing is exported here either. diff --git a/crates/ghostty-wasm/src/atlas.rs b/crates/ghostty-wasm/src/atlas.rs new file mode 100644 index 0000000..5ffa569 --- /dev/null +++ b/crates/ghostty-wasm/src/atlas.rs @@ -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 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 { + 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 { + // 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 = 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) +} diff --git a/crates/ghostty-wasm/src/config.rs b/crates/ghostty-wasm/src/config.rs new file mode 100644 index 0000000..82f05d1 --- /dev/null +++ b/crates/ghostty-wasm/src/config.rs @@ -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() +} diff --git a/crates/ghostty-wasm/src/face.rs b/crates/ghostty-wasm/src/face.rs new file mode 100644 index 0000000..bb770ca --- /dev/null +++ b/crates/ghostty-wasm/src/face.rs @@ -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 { + 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 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}"); + } +} diff --git a/crates/ghostty-wasm/src/lib.rs b/crates/ghostty-wasm/src/lib.rs new file mode 100644 index 0000000..45e60c7 --- /dev/null +++ b/crates/ghostty-wasm/src/lib.rs @@ -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) } +} diff --git a/crates/ghostty-wasm/src/shaper.rs b/crates/ghostty-wasm/src/shaper.rs new file mode 100644 index 0000000..34f7027 --- /dev/null +++ b/crates/ghostty-wasm/src/shaper.rs @@ -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 + } + } +} diff --git a/crates/ghostty-wasm/tests/node.rs b/crates/ghostty-wasm/tests/node.rs new file mode 100644 index 0000000..5db0a92 --- /dev/null +++ b/crates/ghostty-wasm/tests/node.rs @@ -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")); +} diff --git a/crates/ghostty-wasm/tests/node_test.js b/crates/ghostty-wasm/tests/node_test.js new file mode 100644 index 0000000..af8f278 --- /dev/null +++ b/crates/ghostty-wasm/tests/node_test.js @@ -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 `. +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); +}); diff --git a/docs/rewrite/notes/wasm-W1.md b/docs/rewrite/notes/wasm-W1.md new file mode 100644 index 0000000..91a2e03 --- /dev/null +++ b/docs/rewrite/notes/wasm-W1.md @@ -0,0 +1,86 @@ +# WASM port (W1) + +Port of Ghostty's wasm-specific Zig. Everything is behind +`cfg(target_arch = "wasm32")` (or target-gated Cargo deps); native behaviour is +unchanged. + +## File map +| Zig | Rust | +|---|---| +| `main_wasm.zig` | `crates/ghostty-wasm` (new, `cdylib`; empty on native) | +| `config/Wasm.zig` | `ghostty-wasm/src/config.rs` | +| `os/wasm.zig`, `os/wasm/target.zig`, `os/wasm/log.zig` | `ghostty-os/src/wasm/{mod,target,log}.rs` | +| zig-js (`js.*` used by Atlas/web_canvas) | `ghostty-os/src/wasm/js.rs` + host `ghostty-wasm/js/ghostty_host.js` | +| `font/face/web_canvas.zig` | `ghostty-font/src/face/web_canvas.rs` (+ exports in `ghostty-wasm/src/face.rs`) | +| `font/shaper/web_canvas.zig` | `ghostty-font/src/shaper/web_canvas.rs` (+ `ghostty-wasm/src/shaper.rs`) | +| `font/DeferredFace.zig` (`wc` variant, `Wasm`) | `ghostty-font/src/deferred_face_web_canvas.rs` | +| `font/Atlas.zig` `Wasm` | `ghostty-wasm/src/atlas.rs` | +| `font/backend.zig` | `ghostty-font/src/backend.rs` (`OPTIONS`/`default_for_target` = `WebCanvas` on wasm32) | +| `renderer/WebGL.zig` (empty stub), `App.zig` `Wasm` (commented out upstream) | nothing to export; `ghostty-wasm/src/app.rs` documents it | +| `os/passwd.zig` (`@compileError` on wasm) | `ghostty-os/src/unsupported.rs` shims | + +## Build gating +- `ghostty-os`: nix/rustix/zbus/gettext target-gated; POSIX modules (`env`, + `file`, `flatpak`, ...) are not compiled on wasm32. `unsupported.rs` gives + API-compatible "unavailable" stand-ins (`xdg`, `passwd`, `expand_home`, + `resources_dir`, `is_flatpak`) so `ghostty-config` compiles unchanged + (Zig never analyzes them on wasm). `unsafe` is `deny` (not `forbid`) for the + wasm import blocks only. +- `ghostty-config`: only `std::os::unix` bits changed (file-type names, + `OsStrExt`). +- `ghostty-terminal`: `onig`, `ghostty-onig`, `rustix` target-gated. Like Zig + with `oniguruma = false`, `StringMap::search_iterator`/`SearchIterator` do + not exist on wasm (match types stay). Kitty shared-memory transmission + returns `InvalidData` on wasm; `OsStrExt` shim for the file medium. +- `ghostty-font`: freetype/harfbuzz/fontconfig `-sys` target-gated; on wasm the + `collection`, `codepoint_resolver`, `shared_grid(_set)`, `library`, + `discovery`, `fontconfig`, `embedded`, `face::freetype`, harfbuzz shaper + and `shaper::{run,cache,noop}` modules are compiled out. +- Builds: `cargo build --target wasm32-unknown-unknown -p ghostty-font -p ghostty-config -p ghostty-terminal -p ghostty-vt -p ghostty-wasm` passes (debug + release); clippy `-D warnings` clean on both targets. + +## JS protocol (deviation) +`zig-js` (mitchellh/zig-js) is **not vendored** in the Ghostty tree, so its +import names could not be mirrored. `js.rs` documents a small handle-based +ABI in import module `ghostty_js` (`global/get/set/call/new/string/number/ +boolean/to_f64/string_len/string_copy/is_nullish/bytes/copy_bytes/release`) +plus `env.log(ptr,len)` (same as Zig's log). The Rust-side API mirrors +`js.Object`. `ghostty-wasm/js/ghostty_host.js` is a reference host. +Allocation helpers `wasm_alloc/wasm_free` (host-owned buffers) replace the +stale `toHostOwned` (does not exist in current `os/wasm.zig`). + +## Exports +Same names as Zig: `atlas_{new,free,reserve,set,grow,clear,debug_canvas}`, +`face_{new,free,render_glyph,debug_canvas}`, `deferred_face_{new,free,load}`, +`shaper_{new,free,test}`, `config_{new,free,load_string,finalize}`. +Differences/extras: `region_free`/`glyph_free` (Zig: "global free"), +`atlas_data/atlas_size`, `face_metrics`, `face_glyph_index`, +`shaper_{prepare,add_codepoint,shape}`, `config_format`, +`config_diagnostics_count`, `ghostty_wasm_init`, `wasm_alloc/free`. +`shaper_test` takes a `Face` (Zig takes a stale `GroupCache`) and prints into a +`ghostty-terminal` Terminal. + +## Deviations from the (stale) Zig web_canvas code +- Zig refers to `Atlas.Format.rgba` and a 7-field `Metrics` that no longer + exist; here Bgra atlases get the canvas RGBA bitmap with R/B swapped, + grayscale gets alpha, and `Metrics::init()` + face size = cell size. +- The `RunIterator` hook API is tied to the FreeType `Collection` (not built on + wasm), so `Shaper` exposes `prepare/add_codepoint/finalize/shape` directly and + takes the grapheme-index callback (`Face::grapheme_glyph_index`). +- `Collection`/`SharedGrid` are not generic over the face type in this port, so + they are unavailable on wasm (Zig's wasm exports never use them). +- `render_glyph` drops the unused `RenderOptions` parameter. + +## Tests +- Native: pure parts of web_canvas (grapheme table, metrics math, presentation + detection, glyph sizing, bitmap/atlas conversion; 9 tests) and the shaper + clustering (6 tests) run natively; `backend::web_canvas_capabilities`. +- `crates/ghostty-wasm/tests/node.rs` builds the module (separate target dir + `target/wasm-test`) and runs `tests/node_test.js` under node with a fake + canvas: config parse/finalize/format, atlas, face metrics/glyph_index/ + render, emoji presentation, shaper clustering, deferred face, debug canvas. + Skipped if node or the wasm32 target is missing. +- Not ported: Zig tests in Atlas.Wasm (`toHostOwned`, stale). + +## Known gaps +- `ghostty-os/tests/common/flatpak_mock.rs` (another agent's, untracked) fails + `clippy --all-targets -D warnings` (needless_pass_by_value); unrelated.