feat(wasm): port Ghostty's wasm32 build
Ports main_wasm.zig as crates/ghostty-wasm (cdylib exporting the Zig names: atlas_*, face_*, deferred_face_*, shaper_*, config_*), the web_canvas font face and shaper (backend selected on wasm32), os/wasm (logging, target, JS interop) and config/Wasm.zig. C-library dependencies (freetype, harfbuzz, fontconfig, onig, gettext, D-Bus, rustix) are target-gated. Like Zig's wasm build, link regex search (StringMap::search_iterator) is unavailable without Oniguruma; Collection/SharedGrid/harfbuzz are compiled out on wasm32. ghostty-os switches from forbid to deny unsafe_code for the JS import blocks. The JS import ABI is currently a documented handle-based interface with a reference host (js/ghostty_host.js); aligning it with zig-js's exact import names follows separately. Test Plan: - cargo build --target wasm32-unknown-unknown -p ghostty-font \ -p ghostty-config -p ghostty-terminal -p ghostty-vt -p ghostty-wasm - cargo test -p ghostty-wasm (node runs the module against a fake canvas)
This commit is contained in:
1 parent
75252a7651
commit
42f05c926d
33 files changed
+2866
-30
No files matched your search
@@ -3976,7 +3976,18 @@ impl Config {
|
|||||||
//
|
//
|
||||||
// See: https://github.com/Vladimir-csp/xdg-terminal-exec
|
// See: https://github.com/Vladimir-csp/xdg-terminal-exec
|
||||||
{
|
{
|
||||||
|
#[cfg(unix)]
|
||||||
use std::os::unix::ffi::OsStrExt;
|
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<std::ffi::CString> = argv
|
let cargs: Vec<std::ffi::CString> = argv
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|a| std::ffi::CString::new(a.as_bytes()).ok())
|
.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 {
|
fn file_kind_name(meta: &std::fs::Metadata) -> &'static str {
|
||||||
use std::os::unix::fs::FileTypeExt;
|
|
||||||
let ft = meta.file_type();
|
let ft = meta.file_type();
|
||||||
if ft.is_dir() {
|
if ft.is_dir() {
|
||||||
"directory"
|
"directory"
|
||||||
} else if ft.is_symlink() {
|
} else if ft.is_symlink() {
|
||||||
"sym_link"
|
"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 {
|
} else {
|
||||||
"unknown"
|
crate::theme::special_kind_name(ft)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,14 +94,21 @@ fn diag(diags: &mut DiagnosticList, message: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn kind_name(ft: std::fs::FileType) -> &'static str {
|
fn kind_name(ft: std::fs::FileType) -> &'static str {
|
||||||
use std::os::unix::fs::FileTypeExt;
|
|
||||||
if ft.is_file() {
|
if ft.is_file() {
|
||||||
"file"
|
"file"
|
||||||
} else if ft.is_dir() {
|
} else if ft.is_dir() {
|
||||||
"directory"
|
"directory"
|
||||||
} else if ft.is_symlink() {
|
} else if ft.is_symlink() {
|
||||||
"sym_link"
|
"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"
|
"block_device"
|
||||||
} else if ft.is_char_device() {
|
} else if ft.is_char_device() {
|
||||||
"character_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.
|
/// Verify that the file is a regular file, adding a diagnostic otherwise.
|
||||||
fn check_regular(file: &File, theme: &str, diags: &mut DiagnosticList) -> bool {
|
fn check_regular(file: &File, theme: &str, diags: &mut DiagnosticList) -> bool {
|
||||||
match file.metadata() {
|
match file.metadata() {
|
||||||
|
|||||||
@@ -9,15 +9,23 @@ tiny-skia = "0.12.0"
|
|||||||
png = "0.18.1"
|
png = "0.18.1"
|
||||||
thiserror = "2.0.21"
|
thiserror = "2.0.21"
|
||||||
log = "0.4.34"
|
log = "0.4.34"
|
||||||
freetype-sys = "0.23.0"
|
|
||||||
harfbuzz-sys = "0.8.0"
|
|
||||||
ghostty-unicode.workspace = true
|
ghostty-unicode.workspace = true
|
||||||
bitflags = "2.13.2"
|
bitflags = "2.13.2"
|
||||||
yeslogic-fontconfig-sys = "6.0.1"
|
|
||||||
ghostty-datastruct.workspace = true
|
ghostty-datastruct.workspace = true
|
||||||
ghostty-config.workspace = true
|
ghostty-config.workspace = true
|
||||||
ghostty-terminal.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
|
# This crate wraps the freetype / harfbuzz / fontconfig C libraries through
|
||||||
# their `-sys` crates; unsafe is denied by default and allowed on minimal,
|
# their `-sys` crates; unsafe is denied by default and allowed on minimal,
|
||||||
# `// SAFETY:`-commented items only.
|
# `// SAFETY:`-commented items only.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Font backend selection. Port of `font/backend.zig`.
|
//! 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,
|
//! [`Backend::FontconfigFreetype`] (fontconfig discovery, FreeType rendering,
|
||||||
//! HarfBuzz shaping). The other variants are kept for fidelity with the Zig
|
//! HarfBuzz shaping). The other variants are kept for fidelity with the Zig
|
||||||
//! enum and its helper predicates.
|
//! enum and its helper predicates.
|
||||||
@@ -27,7 +27,11 @@ pub enum Backend {
|
|||||||
impl Backend {
|
impl Backend {
|
||||||
/// Returns the default backend for a build environment.
|
/// Returns the default backend for a build environment.
|
||||||
pub fn default_for_target() -> Backend {
|
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
|
Backend::Coretext
|
||||||
} else {
|
} else {
|
||||||
Backend::FontconfigFreetype
|
Backend::FontconfigFreetype
|
||||||
@@ -72,14 +76,30 @@ pub struct Options {
|
|||||||
pub backend: Backend,
|
pub backend: Backend,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub const OPTIONS: Options = Options {
|
pub const OPTIONS: Options = Options {
|
||||||
backend: Backend::FontconfigFreetype,
|
backend: Backend::FontconfigFreetype,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub const OPTIONS: Options = Options {
|
||||||
|
backend: Backend::WebCanvas,
|
||||||
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn linux_backend_capabilities() {
|
fn linux_backend_capabilities() {
|
||||||
let b = OPTIONS.backend;
|
let b = OPTIONS.backend;
|
||||||
|
|||||||
@@ -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<WebCanvas>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Face, Error> {
|
||||||
|
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<Presentation>) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,17 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use crate::metrics::Metrics;
|
use crate::metrics::Metrics;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod freetype;
|
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 freetype::Face;
|
||||||
pub use ghostty_config::types_b::FreetypeLoadFlags;
|
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
|
/// 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
|
/// wrong on modern devices so it is highly recommended you get the DPI
|
||||||
|
|||||||
@@ -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<Vec<u8>, u32>,
|
||||||
|
from_glyph: HashMap<u32, Vec<u8>>,
|
||||||
|
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<u32, Error> {
|
||||||
|
// 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<Vec<u8>, 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<crate::Glyph, Error> {
|
||||||
|
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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_metrics(o: &Object) -> Result<TextMetrics, Error> {
|
||||||
|
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<Face, Error> {
|
||||||
|
// 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<u32> {
|
||||||
|
// 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<Presentation, Error> {
|
||||||
|
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<u32, Error> {
|
||||||
|
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<Glyph, Error> {
|
||||||
|
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<Object, Error> {
|
||||||
|
// 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<RenderedGlyph, Error> {
|
||||||
|
// 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<u8> 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,37 +8,55 @@
|
|||||||
pub mod atlas;
|
pub mod atlas;
|
||||||
pub mod backend;
|
pub mod backend;
|
||||||
pub mod codepoint_map;
|
pub mod codepoint_map;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod codepoint_resolver;
|
pub mod codepoint_resolver;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod collection;
|
pub mod collection;
|
||||||
pub mod config_types;
|
pub mod config_types;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod deferred_face;
|
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;
|
pub mod discovery;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod embedded;
|
pub mod embedded;
|
||||||
pub mod face;
|
pub mod face;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod fontconfig;
|
pub mod fontconfig;
|
||||||
pub mod glyph;
|
pub mod glyph;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod nerd_font_attributes;
|
pub mod nerd_font_attributes;
|
||||||
pub mod opentype;
|
pub mod opentype;
|
||||||
pub mod shape;
|
pub mod shape;
|
||||||
pub mod shaper;
|
pub mod shaper;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod shared_grid;
|
pub mod shared_grid;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod shared_grid_set;
|
pub mod shared_grid_set;
|
||||||
pub mod sprite;
|
pub mod sprite;
|
||||||
|
|
||||||
pub use atlas::Atlas;
|
pub use atlas::Atlas;
|
||||||
pub use backend::{Backend, OPTIONS as options};
|
pub use backend::{Backend, OPTIONS as options};
|
||||||
pub use codepoint_map::CodepointMap;
|
pub use codepoint_map::CodepointMap;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use codepoint_resolver::CodepointResolver;
|
pub use codepoint_resolver::CodepointResolver;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use collection::Collection;
|
pub use collection::Collection;
|
||||||
pub use deferred_face::DeferredFace;
|
pub use deferred_face::DeferredFace;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use discovery::{Descriptor, Discover};
|
pub use discovery::{Descriptor, Discover};
|
||||||
pub use face::Face;
|
pub use face::Face;
|
||||||
pub use glyph::Glyph;
|
pub use glyph::Glyph;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use library::Library;
|
pub use library::Library;
|
||||||
pub use metrics::Metrics;
|
pub use metrics::Metrics;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use shared_grid::SharedGrid;
|
pub use shared_grid::SharedGrid;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use shared_grid_set::SharedGridSet;
|
pub use shared_grid_set::SharedGridSet;
|
||||||
pub use sprite::{Face as SpriteFace, Sprite};
|
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.
|
/// A FontIndex that can be used to use the sprite font directly.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub const SPRITE_INDEX: collection::Index =
|
pub const SPRITE_INDEX: collection::Index =
|
||||||
collection::Index::init_special(collection::Special::Sprite);
|
collection::Index::init_special(collection::Special::Sprite);
|
||||||
|
|
||||||
/// The default font size adjustment we use when loading fallback fonts.
|
/// The default font size adjustment we use when loading fallback fonts.
|
||||||
///
|
///
|
||||||
/// TODO: Add user configuration for this instead of hard-coding it.
|
/// TODO: Add user configuration for this instead of hard-coding it.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub const DEFAULT_FALLBACK_ADJUSTMENT: collection::SizeAdjustment =
|
pub const DEFAULT_FALLBACK_ADJUSTMENT: collection::SizeAdjustment =
|
||||||
collection::SizeAdjustment::IcWidth;
|
collection::SizeAdjustment::IcWidth;
|
||||||
@@ -241,9 +241,8 @@ fn zround(v: f64) -> f64 {
|
|||||||
|
|
||||||
impl Metrics {
|
impl Metrics {
|
||||||
/// All-zero metrics, except `cursor_thickness` which defaults to 1.
|
/// All-zero metrics, except `cursor_thickness` which defaults to 1.
|
||||||
/// (Zig's private `init()`; used by tests.)
|
/// (Zig's private `init()`; used by tests and the web_canvas face.)
|
||||||
#[cfg(test)]
|
pub(crate) fn init() -> Metrics {
|
||||||
fn init() -> Metrics {
|
|
||||||
Metrics {
|
Metrics {
|
||||||
cell_width: 0,
|
cell_width: 0,
|
||||||
cell_height: 0,
|
cell_height: 0,
|
||||||
|
|||||||
@@ -3,12 +3,17 @@
|
|||||||
//! The shaper implementation for the Linux backend is HarfBuzz
|
//! The shaper implementation for the Linux backend is HarfBuzz
|
||||||
//! ([`crate::shaper::harfbuzz::Shaper`]).
|
//! ([`crate::shaper::harfbuzz::Shaper`]).
|
||||||
|
|
||||||
|
pub use crate::shaper::feature::{DEFAULT_FEATURES, Feature, FeatureList};
|
||||||
/// Shaper implementation for our compile options (backend
|
/// Shaper implementation for our compile options (backend
|
||||||
/// `fontconfig_freetype` => HarfBuzz).
|
/// `fontconfig_freetype` => HarfBuzz).
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use crate::shaper::harfbuzz::Shaper;
|
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::{
|
pub use crate::shaper::{
|
||||||
cache::Cache,
|
cache::Cache,
|
||||||
feature::{DEFAULT_FEATURES, Feature, FeatureList},
|
|
||||||
harfbuzz,
|
harfbuzz,
|
||||||
noop,
|
noop,
|
||||||
run::{RunIterator, RunIteratorHook, RunOptions, TextRun},
|
run::{RunIterator, RunIteratorHook, RunOptions, TextRun},
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
//! Text shapers. Port of `font/shaper/`.
|
//! Text shapers. Port of `font/shaper/`.
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod feature;
|
pub mod feature;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod harfbuzz;
|
pub mod harfbuzz;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
mod hb;
|
mod hb;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod noop;
|
pub mod noop;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod run;
|
pub mod run;
|
||||||
|
|
||||||
#[cfg(test)]
|
pub mod web_canvas;
|
||||||
|
|
||||||
|
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||||
pub(crate) mod test_util;
|
pub(crate) mod test_util;
|
||||||
@@ -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<Cell>,
|
||||||
|
|
||||||
|
/// The shared memory used for storing information about a run.
|
||||||
|
run_buf: Vec<RunItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<E>(
|
||||||
|
&mut self,
|
||||||
|
mut grapheme_index: impl FnMut(&[u8]) -> Result<u32, E>,
|
||||||
|
) -> Result<&[Cell], Error>
|
||||||
|
where
|
||||||
|
Error: From<E>,
|
||||||
|
{
|
||||||
|
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<Cell>, 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::<Vec<_>>(),
|
||||||
|
[(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::<u32, FaceError>(0));
|
||||||
|
assert!(matches!(r, Err(Error::CellBufferTooSmall)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,16 +6,51 @@ license.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ghostty-vt.workspace = true
|
ghostty-vt.workspace = true
|
||||||
gettext-rs = { version = "0.8.0", features = ["gettext-system"] }
|
|
||||||
log = "0.4.34"
|
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"] }
|
nix = { version = "0.31.3", features = ["user"] }
|
||||||
rustix = { version = "1.1.5", features = ["fs", "process", "system", "pipe", "net", "time", "rand", "pty"] }
|
rustix = { version = "1.1.5", features = ["fs", "process", "system", "pipe", "net", "time", "rand", "pty"] }
|
||||||
thiserror = "2.0.21"
|
|
||||||
zbus = "5.19.0"
|
zbus = "5.19.0"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Mirrors Zig's `wasm_shared` build option (SharedArrayBuffer-backed memory).
|
||||||
|
wasm-shared = []
|
||||||
|
|
||||||
[lints]
|
# Same as `[workspace.lints]` except `unsafe_code` is denied instead of
|
||||||
workspace = true
|
# 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]
|
[dev-dependencies]
|
||||||
temp-env = "0.3.6"
|
temp-env = "0.3.6"
|
||||||
@@ -15,38 +15,69 @@
|
|||||||
)]
|
)]
|
||||||
|
|
||||||
pub mod args;
|
pub mod args;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod cgroup;
|
pub mod cgroup;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod dbus;
|
pub mod dbus;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod desktop;
|
pub mod desktop;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod env;
|
pub mod env;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod file;
|
pub mod file;
|
||||||
pub mod file_type;
|
pub mod file_type;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod flatpak;
|
pub mod flatpak;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod homedir;
|
pub mod homedir;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod hostname;
|
pub mod hostname;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
pub mod i18n_locales;
|
pub mod i18n_locales;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod kernel_info;
|
pub mod kernel_info;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod locale;
|
pub mod locale;
|
||||||
pub mod mouse;
|
pub mod mouse;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod open;
|
pub mod open;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod passwd;
|
pub mod passwd;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod path;
|
pub mod path;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod pipe;
|
pub mod pipe;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod resourcesdir;
|
pub mod resourcesdir;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod string_encoding;
|
pub mod string_encoding;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod systemd;
|
pub mod systemd;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod temp_dir;
|
pub mod temp_dir;
|
||||||
pub mod uri;
|
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;
|
pub mod xdg;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) mod testutil;
|
pub(crate) mod testutil;
|
||||||
|
|
||||||
// Functions and types (mirrors the flat exports of `os/main.zig`).
|
// Functions and types (mirrors the flat exports of `os/main.zig`).
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use dbus::launched_by_dbus_activation;
|
pub use dbus::launched_by_dbus_activation;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use desktop::{DesktopEnvironment, desktop_environment, launched_from_desktop};
|
pub use desktop::{DesktopEnvironment, desktop_environment, launched_from_desktop};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use env::{
|
pub use env::{
|
||||||
EnvMap,
|
EnvMap,
|
||||||
append_env,
|
append_env,
|
||||||
@@ -59,21 +90,32 @@ pub use env::{
|
|||||||
prepend_env,
|
prepend_env,
|
||||||
prepend_env_os,
|
prepend_env_os,
|
||||||
};
|
};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use file::{Rlimit, alloc_tmp_dir, fix_max_files, restore_max_files};
|
pub use file::{Rlimit, alloc_tmp_dir, fix_max_files, restore_max_files};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use flatpak::{FlatpakHostCommand, is_flatpak};
|
pub use flatpak::{FlatpakHostCommand, is_flatpak};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use homedir::{expand_home, home};
|
pub use homedir::{expand_home, home};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use kernel_info::get_kernel_info;
|
pub use kernel_info::get_kernel_info;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use locale::ensure_locale;
|
pub use locale::ensure_locale;
|
||||||
pub use mouse::click_interval;
|
pub use mouse::click_interval;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use open::open;
|
pub use open::open;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use pipe::pipe;
|
pub use pipe::pipe;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use resourcesdir::{ResourcesDir, resources_dir};
|
pub use resourcesdir::{ResourcesDir, resources_dir};
|
||||||
pub use shell::ShellEscapeWriter;
|
pub use shell::ShellEscapeWriter;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use systemd::launched_by_systemd;
|
pub use systemd::launched_by_systemd;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use temp_dir::TempDir;
|
pub use temp_dir::TempDir;
|
||||||
|
|
||||||
/// Join path components like `std.fs.path.join` (POSIX): empty components
|
/// Join path components like `std.fs.path.join` (POSIX): empty components
|
||||||
/// are skipped and exactly one separator is placed between components.
|
/// are skipped and exactly one separator is placed between components.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub(crate) fn path_join(parts: &[&str]) -> String {
|
pub(crate) fn path_join(parts: &[&str]) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for p in parts {
|
for p in parts {
|
||||||
|
|||||||
@@ -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<String, ExpandError> {
|
||||||
|
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<String>,
|
||||||
|
pub home: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zig: `@compileError("passwd is not available for wasm")`.
|
||||||
|
pub fn get() -> std::io::Result<Entry> {
|
||||||
|
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<String, Error> {
|
||||||
|
Err(Error::Unavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cache(_: &Options<'_>) -> Result<String, Error> {
|
||||||
|
Err(Error::Unavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state(_: &Options<'_>) -> Result<String, Error> {
|
||||||
|
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<String>,
|
||||||
|
pub host_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ResourcesDir> {
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::Unsupported,
|
||||||
|
"resources dir is not available on wasm",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_flatpak() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
@@ -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<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
/// 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<f64> for Arg<'_> {
|
||||||
|
fn from(v: f64) -> Self {
|
||||||
|
Arg::Num(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<f32> for Arg<'_> {
|
||||||
|
fn from(v: f32) -> Self {
|
||||||
|
Arg::Num(f64::from(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<u32> for Arg<'_> {
|
||||||
|
fn from(v: u32) -> Self {
|
||||||
|
Arg::Num(f64::from(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<bool> for Arg<'_> {
|
||||||
|
fn from(v: bool) -> Self {
|
||||||
|
Arg::Bool(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(h: u32) -> Result<Object> {
|
||||||
|
if h == 0 {
|
||||||
|
Err(Error::Js)
|
||||||
|
} else {
|
||||||
|
Ok(Object(h))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts arguments to handles. Temporaries are released by [`Args::drop`].
|
||||||
|
struct Args {
|
||||||
|
handles: Vec<u32>,
|
||||||
|
owned: Vec<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Args {
|
||||||
|
fn new(args: &[Arg<'_>]) -> Result<Args> {
|
||||||
|
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<Object> {
|
||||||
|
// SAFETY: plain call into the host.
|
||||||
|
check(unsafe { ffi::global() })
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Object {
|
||||||
|
/// Creates a JS string value.
|
||||||
|
pub fn string(s: &str) -> Result<Object> {
|
||||||
|
// 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<Object> {
|
||||||
|
// 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<Object> {
|
||||||
|
// 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<Object> {
|
||||||
|
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<f64> {
|
||||||
|
Ok(self.get(name)?.as_f64())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets a numeric property as `f32` (zig `get(f32, ...)`).
|
||||||
|
pub fn get_f32(&self, name: &str) -> Result<f32> {
|
||||||
|
Ok(self.get_f64(name)? as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets a string property.
|
||||||
|
pub fn get_string(&self, name: &str) -> Result<String> {
|
||||||
|
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<Object> {
|
||||||
|
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<Object> {
|
||||||
|
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<String> {
|
||||||
|
// 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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
//! Freestanding log sink. Port of `ZIG/src/os/wasm/log.zig`.
|
||||||
|
//!
|
||||||
|
//! Formats `"<level>(<scope>): <message>"` (or `"<level>: <message>"` 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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<Target> = Some(Target::Browser);
|
||||||
@@ -12,11 +12,15 @@ ghostty-datastruct.workspace = true
|
|||||||
ghostty-unicode.workspace = true
|
ghostty-unicode.workspace = true
|
||||||
ghostty-vt.workspace = true
|
ghostty-vt.workspace = true
|
||||||
log = "0.4.34"
|
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"
|
onig = "6.5.3"
|
||||||
ghostty-onig.workspace = true
|
ghostty-onig.workspace = true
|
||||||
png = "0.18.1"
|
|
||||||
rustix = { version = "1.1.5", features = ["shm", "fs"] }
|
rustix = { version = "1.1.5", features = ["shm", "fs"] }
|
||||||
thiserror = "2.0.21"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
ghostty-config.workspace = true
|
ghostty-config.workspace = true
|
||||||
|
|||||||
@@ -3,13 +3,32 @@
|
|||||||
//! [`LoadingImage`] is an image that is still being transmitted (possibly in
|
//! [`LoadingImage`] is an image that is still being transmitted (possibly in
|
||||||
//! chunks). [`Image`] is a fully loaded and validated image.
|
//! chunks). [`Image`] is a fully loaded and validated image.
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::{ffi::OsStrExt, fs::FileExt};
|
||||||
use std::{
|
use std::{
|
||||||
io::{Cursor, Read, Seek, SeekFrom},
|
io::{Cursor, Read, Seek, SeekFrom},
|
||||||
os::unix::{ffi::OsStrExt, fs::FileExt},
|
|
||||||
path::Path,
|
path::Path,
|
||||||
time::Instant,
|
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::{
|
use ghostty_vt::kitty::graphics_command::{
|
||||||
Command,
|
Command,
|
||||||
Display,
|
Display,
|
||||||
@@ -143,6 +162,19 @@ impl LoadingImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Reads the data from a shared memory segment.
|
/// 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> {
|
fn read_shared_memory(&mut self, offset: u32, size: u32, path: &[u8]) -> Result<(), Error> {
|
||||||
use rustix::{fs::Mode, shm};
|
use rustix::{fs::Mode, shm};
|
||||||
|
|
||||||
@@ -160,6 +192,7 @@ impl LoadingImage {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn read_shm_fd(
|
fn read_shm_fd(
|
||||||
&mut self,
|
&mut self,
|
||||||
fd: rustix::fd::OwnedFd,
|
fd: rustix::fd::OwnedFd,
|
||||||
@@ -898,6 +931,7 @@ mod tests {
|
|||||||
assert!(loading.complete().is_ok());
|
assert!(loading.complete().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
#[test]
|
#[test]
|
||||||
fn image_load_shared_memory() {
|
fn image_load_shared_memory() {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Port of `ZIG/src/terminal/StringMap.zig`. Regex matching uses Oniguruma
|
//! Port of `ZIG/src/terminal/StringMap.zig`. Regex matching uses Oniguruma
|
||||||
//! (the `onig` crate), like Ghostty.
|
//! (the `onig` crate), like Ghostty.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use onig::{MatchParam, Regex, Region, SearchOptions};
|
use onig::{MatchParam, Regex, Region, SearchOptions};
|
||||||
|
|
||||||
use crate::{page_list::Pin, selection::Selection};
|
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).
|
/// the `ghostty-onig` wrapper since the `onig` crate does not expose it).
|
||||||
/// Exceeding it aborts matching with an error which is treated as "no
|
/// Exceeding it aborts matching with an error which is treated as "no
|
||||||
/// further matches".
|
/// further matches".
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
const ONI_SEARCH_RETRY_LIMIT: u32 = 100_000;
|
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.
|
/// 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<Pin>,
|
pub map: Vec<Pin>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
impl StringMap {
|
||||||
/// Returns an iterator that yields the next match of the given regex.
|
/// Returns an iterator that yields the next match of the given regex.
|
||||||
pub fn search_iterator<'a>(&'a self, regex: &'a Regex) -> SearchIterator<'a> {
|
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.
|
/// Iterates over the regular expression matches of the string.
|
||||||
pub struct SearchIterator<'a> {
|
pub struct SearchIterator<'a> {
|
||||||
map: &'a StringMap,
|
map: &'a StringMap,
|
||||||
@@ -43,6 +49,7 @@ pub struct SearchIterator<'a> {
|
|||||||
offset: usize,
|
offset: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
impl<'a> SearchIterator<'a> {
|
impl<'a> SearchIterator<'a> {
|
||||||
/// Returns the next regular expression match or None if there are
|
/// Returns the next regular expression match or None if there are
|
||||||
/// no more matches.
|
/// no more matches.
|
||||||
@@ -88,6 +95,7 @@ impl<'a> SearchIterator<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
impl<'a> Iterator for SearchIterator<'a> {
|
impl<'a> Iterator for SearchIterator<'a> {
|
||||||
type Item = Match<'a>;
|
type Item = Match<'a>;
|
||||||
|
|
||||||
@@ -115,7 +123,7 @@ impl Match<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{
|
use crate::{
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
[package]
|
||||||
|
name = "ghostty-wasm"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "The Ghostty WASM module (browser): C ABI exports for atlas, font faces, shaper and config. Port of main_wasm.zig"
|
||||||
|
|
||||||
|
# `cdylib` is the `.wasm` module; `rlib` keeps the crate usable from native
|
||||||
|
# workspace builds (it is empty there).
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ghostty-config.workspace = true
|
||||||
|
ghostty-font.workspace = true
|
||||||
|
ghostty-os.workspace = true
|
||||||
|
ghostty-terminal.workspace = true
|
||||||
|
log = "0.4.34"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Zig's `wasm_shared` build option.
|
||||||
|
wasm-shared = ["ghostty-os/wasm-shared"]
|
||||||
|
|
||||||
|
# `[workspace.lints]` with `unsafe_code` denied (the `extern "C"` exports need
|
||||||
|
# raw pointers; every unsafe block carries a SAFETY comment).
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "deny"
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
pedantic = { level = "warn", priority = -1 }
|
||||||
|
todo = "warn"
|
||||||
|
unwrap_used = "warn"
|
||||||
|
cast_possible_truncation = "allow"
|
||||||
|
cast_possible_wrap = "allow"
|
||||||
|
cast_sign_loss = "allow"
|
||||||
|
cast_precision_loss = "allow"
|
||||||
|
cast_lossless = "allow"
|
||||||
|
module_name_repetitions = "allow"
|
||||||
|
too_many_lines = "allow"
|
||||||
|
missing_errors_doc = "allow"
|
||||||
|
missing_panics_doc = "allow"
|
||||||
|
similar_names = "allow"
|
||||||
|
struct_excessive_bools = "allow"
|
||||||
|
fn_params_excessive_bools = "allow"
|
||||||
|
match_same_arms = "allow"
|
||||||
|
unreadable_literal = "allow"
|
||||||
|
many_single_char_names = "allow"
|
||||||
|
doc_markdown = "allow"
|
||||||
|
must_use_candidate = "allow"
|
||||||
|
return_self_not_must_use = "allow"
|
||||||
|
used_underscore_binding = "allow"
|
||||||
|
large_stack_arrays = "allow"
|
||||||
|
inline_always = "allow"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
temp-env = "0.3.6"
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Host-side glue for the `ghostty_js` imports (see
|
||||||
|
// crates/ghostty-os/src/wasm/js.rs for the protocol) and `env.log`.
|
||||||
|
// Usable from a browser (ES module) or node (CommonJS).
|
||||||
|
//
|
||||||
|
// const host = createHost({ globalThis, log });
|
||||||
|
// const { instance } = await WebAssembly.instantiate(bytes, host.imports);
|
||||||
|
// host.setInstance(instance);
|
||||||
|
|
||||||
|
function createHost({ global = globalThis, log = console.log } = {}) {
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
// Handle table. Handle 0 is reserved for "invalid / threw".
|
||||||
|
const table = [undefined];
|
||||||
|
const free = [];
|
||||||
|
let instance = null;
|
||||||
|
|
||||||
|
const put = (v) => {
|
||||||
|
if (free.length > 0) {
|
||||||
|
const h = free.pop();
|
||||||
|
table[h] = v;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
table.push(v);
|
||||||
|
return table.length - 1;
|
||||||
|
};
|
||||||
|
const mem = () => new Uint8Array(instance.exports.memory.buffer);
|
||||||
|
const str = (ptr, len) => dec.decode(mem().subarray(ptr, ptr + len));
|
||||||
|
const guard = (f) => {
|
||||||
|
try {
|
||||||
|
return f();
|
||||||
|
} catch (e) {
|
||||||
|
host.lastError = e;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ghostty_js = {
|
||||||
|
global: () => put(global),
|
||||||
|
get: (obj, ptr, len) => guard(() => put(table[obj][str(ptr, len)])),
|
||||||
|
set: (obj, ptr, len, val) =>
|
||||||
|
guard(() => {
|
||||||
|
table[obj][str(ptr, len)] = table[val];
|
||||||
|
return 1;
|
||||||
|
}),
|
||||||
|
call: (obj, ptr, len, argsPtr, argc) =>
|
||||||
|
guard(() => {
|
||||||
|
const args = Array.from(new Uint32Array(instance.exports.memory.buffer, argsPtr, argc), (h) => table[h]);
|
||||||
|
const o = table[obj];
|
||||||
|
return put(o[str(ptr, len)](...args));
|
||||||
|
}),
|
||||||
|
new: (ctor, argsPtr, argc) =>
|
||||||
|
guard(() => {
|
||||||
|
const args = Array.from(new Uint32Array(instance.exports.memory.buffer, argsPtr, argc), (h) => table[h]);
|
||||||
|
return put(new table[ctor](...args));
|
||||||
|
}),
|
||||||
|
string: (ptr, len) => put(str(ptr, len)),
|
||||||
|
number: (v) => put(v),
|
||||||
|
boolean: (v) => put(v !== 0),
|
||||||
|
to_f64: (h) => Number(table[h]),
|
||||||
|
string_len: (h) => enc.encode(String(table[h])).length,
|
||||||
|
string_copy: (h, ptr, cap) => {
|
||||||
|
const bytes = enc.encode(String(table[h]));
|
||||||
|
const n = Math.min(bytes.length, cap);
|
||||||
|
mem().set(bytes.subarray(0, n), ptr);
|
||||||
|
return n;
|
||||||
|
},
|
||||||
|
is_nullish: (h) => (table[h] === undefined || table[h] === null ? 1 : 0),
|
||||||
|
bytes: (ptr, len) => put(new Uint8ClampedArray(mem().slice(ptr, ptr + len).buffer)),
|
||||||
|
copy_bytes: (h, dst, len) =>
|
||||||
|
guard(() => {
|
||||||
|
const src = table[h];
|
||||||
|
if (src.length !== len) throw new Error(`length mismatch ${src.length} != ${len}`);
|
||||||
|
mem().set(new Uint8Array(src.buffer, src.byteOffset, len), dst);
|
||||||
|
return 1;
|
||||||
|
}),
|
||||||
|
release: (h) => {
|
||||||
|
if (h > 0) {
|
||||||
|
table[h] = undefined;
|
||||||
|
free.push(h);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const host = {
|
||||||
|
lastError: null,
|
||||||
|
imports: { ghostty_js, env: { log: (ptr, len) => log(str(ptr, len)) } },
|
||||||
|
setInstance(i) {
|
||||||
|
instance = i;
|
||||||
|
},
|
||||||
|
// Helpers for callers of the exports.
|
||||||
|
writeString(s) {
|
||||||
|
const bytes = enc.encode(s);
|
||||||
|
const ptr = instance.exports.wasm_alloc(bytes.length);
|
||||||
|
mem().set(bytes, ptr);
|
||||||
|
return { ptr, len: bytes.length, free: () => instance.exports.wasm_free(ptr, bytes.length) };
|
||||||
|
},
|
||||||
|
readString(ptr, len) {
|
||||||
|
return str(ptr, len);
|
||||||
|
},
|
||||||
|
value: (h) => table[h],
|
||||||
|
handleCount: () => table.length - 1 - free.length,
|
||||||
|
};
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined') module.exports = { createHost };
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
//! `App.zig` `Wasm`: the upstream exports (`app_new`, `app_free`) are
|
||||||
|
//! commented out, so nothing is exported here either.
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
//! `font/Atlas.zig` `Wasm`: the wasm-compatible atlas API.
|
||||||
|
|
||||||
|
use ghostty_font::atlas::{Atlas, Format, Region};
|
||||||
|
use ghostty_os::wasm::js::{self, Arg, Object};
|
||||||
|
|
||||||
|
use crate::{ensure_init, host_bytes};
|
||||||
|
|
||||||
|
/// C layout of `Atlas.Region`.
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct RegionC {
|
||||||
|
pub x: u32,
|
||||||
|
pub y: u32,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RegionC> for Region {
|
||||||
|
fn from(r: RegionC) -> Region {
|
||||||
|
Region {
|
||||||
|
x: r.x,
|
||||||
|
y: r.y,
|
||||||
|
width: r.width,
|
||||||
|
height: r.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_from(v: u8) -> Option<Format> {
|
||||||
|
match v {
|
||||||
|
0 => Some(Format::Grayscale),
|
||||||
|
1 => Some(Format::Bgr),
|
||||||
|
2 => Some(Format::Bgra),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn atlas_new(size: u32, format: u8) -> *mut Atlas {
|
||||||
|
ensure_init();
|
||||||
|
let Some(format) = format_from(format) else {
|
||||||
|
return std::ptr::null_mut();
|
||||||
|
};
|
||||||
|
Box::into_raw(Box::new(Atlas::new(size, format)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn atlas_free(ptr: *mut Atlas) {
|
||||||
|
if !ptr.is_null() {
|
||||||
|
// SAFETY: pointer came from `atlas_new` and is freed once.
|
||||||
|
drop(unsafe { Box::from_raw(ptr) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The return value must be freed by the caller with [`region_free`].
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn atlas_reserve(atlas: *mut Atlas, width: u32, height: u32) -> *mut RegionC {
|
||||||
|
// SAFETY: the host passes a live pointer from `atlas_new`.
|
||||||
|
let atlas = unsafe { &mut *atlas };
|
||||||
|
match atlas.reserve(width, height) {
|
||||||
|
Ok(r) => Box::into_raw(Box::new(RegionC {
|
||||||
|
x: r.x,
|
||||||
|
y: r.y,
|
||||||
|
width: r.width,
|
||||||
|
height: r.height,
|
||||||
|
})),
|
||||||
|
Err(_) => std::ptr::null_mut(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn region_free(ptr: *mut RegionC) {
|
||||||
|
if !ptr.is_null() {
|
||||||
|
// SAFETY: pointer came from `atlas_reserve` and is freed once.
|
||||||
|
drop(unsafe { Box::from_raw(ptr) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn atlas_set(atlas: *mut Atlas, reg: *const RegionC, data: *const u8, len: usize) {
|
||||||
|
// SAFETY: live pointers from the host; `data` is valid for `len` bytes.
|
||||||
|
let (atlas, reg, data) = unsafe { (&mut *atlas, &*reg, host_bytes(data, len)) };
|
||||||
|
atlas.set(
|
||||||
|
Region {
|
||||||
|
x: reg.x,
|
||||||
|
y: reg.y,
|
||||||
|
width: reg.width,
|
||||||
|
height: reg.height,
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn atlas_grow(atlas: *mut Atlas, size_new: u32) -> bool {
|
||||||
|
// SAFETY: live pointer from `atlas_new`.
|
||||||
|
unsafe { &mut *atlas }.grow(size_new);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn atlas_clear(atlas: *mut Atlas) {
|
||||||
|
// SAFETY: live pointer from `atlas_new`.
|
||||||
|
unsafe { &mut *atlas }.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a pointer to the atlas pixel data and its length through `len`
|
||||||
|
/// (host convenience; Zig hosts read `atlas.data` through the struct layout).
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn atlas_data(atlas: *const Atlas, len: *mut usize) -> *const u8 {
|
||||||
|
// SAFETY: live pointers from the host.
|
||||||
|
let (atlas, len) = unsafe { (&*atlas, &mut *len) };
|
||||||
|
*len = atlas.data.len();
|
||||||
|
atlas.data.as_ptr()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The atlas edge length in pixels.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn atlas_size(atlas: *const Atlas) -> u32 {
|
||||||
|
// SAFETY: live pointer from `atlas_new`.
|
||||||
|
unsafe { &*atlas }.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This creates a Canvas element identified by the handle returned that the
|
||||||
|
/// caller can draw into the DOM to visualize the atlas. The returned handle
|
||||||
|
/// must be released from the JS runtime (`ghostty_js` release / zigjs
|
||||||
|
/// `deleteValue`). Returns 0 on error.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn atlas_debug_canvas(atlas: *const Atlas) -> u32 {
|
||||||
|
// SAFETY: live pointer from `atlas_new`.
|
||||||
|
let atlas = unsafe { &*atlas };
|
||||||
|
match debug_canvas(atlas) {
|
||||||
|
Ok(c) => c.into_raw(),
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("error dumping atlas canvas err={err}");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn debug_canvas(atlas: &Atlas) -> Result<Object, js::Error> {
|
||||||
|
// Create our canvas
|
||||||
|
let doc = js::global()?.get_object("document")?;
|
||||||
|
let canvas = doc.call("createElement", &[Arg::Str("canvas")])?;
|
||||||
|
|
||||||
|
// Setup our canvas size
|
||||||
|
{
|
||||||
|
canvas.set("width", Arg::from(atlas.size))?;
|
||||||
|
canvas.set("height", Arg::from(atlas.size))?;
|
||||||
|
let style = canvas.get_object("style")?;
|
||||||
|
let px = format!("{}px", atlas.size);
|
||||||
|
style.set("width", Arg::Str(&px))?;
|
||||||
|
style.set("height", Arg::Str(&px))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This will return the same context on subsequent calls.
|
||||||
|
let ctx = canvas.call("getContext", &[Arg::Str("2d")])?;
|
||||||
|
|
||||||
|
// We need to draw pixels so this is format dependent.
|
||||||
|
let buf: Vec<u8> = match atlas.format {
|
||||||
|
// Convert from BGRA to RGBA by swapping every R and B.
|
||||||
|
Format::Bgra => {
|
||||||
|
let mut buf = atlas.data.clone();
|
||||||
|
for px in buf.as_chunks_mut::<4>().0 {
|
||||||
|
px.swap(0, 2);
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
// Convert from A8 to RGBA so every 4th byte is set to a value.
|
||||||
|
Format::Grayscale => {
|
||||||
|
let mut buf = vec![0u8; atlas.data.len() * 4];
|
||||||
|
for (i, v) in atlas.data.iter().enumerate() {
|
||||||
|
buf[i * 4 + 3] = *v;
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
Format::Bgr => {
|
||||||
|
log::warn!("error dumping atlas canvas err=UnsupportedAtlasFormat");
|
||||||
|
return Err(js::Error::Js);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create an ImageData from our buffer and then write it to the canvas
|
||||||
|
let arr = Object::clamped_array(&buf)?;
|
||||||
|
let image_data_ctor = js::global()?.get_object("ImageData")?;
|
||||||
|
let image_data = image_data_ctor.new_instance(&[
|
||||||
|
Arg::Object(&arr),
|
||||||
|
Arg::from(atlas.size),
|
||||||
|
Arg::from(atlas.size),
|
||||||
|
])?;
|
||||||
|
ctx.call(
|
||||||
|
"putImageData",
|
||||||
|
&[Arg::Object(&image_data), Arg::Num(0.0), Arg::Num(0.0)],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(canvas)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//! `config/Wasm.zig`: the wasm-compatible config API.
|
||||||
|
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
use ghostty_config::{
|
||||||
|
cli_args::{self, LineIterator},
|
||||||
|
config::Config,
|
||||||
|
file_formatter::FileFormatter,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{ensure_init, host_bytes};
|
||||||
|
|
||||||
|
/// Create a new configuration filled with the initial default values.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn config_new() -> *mut Config {
|
||||||
|
ensure_init();
|
||||||
|
Box::into_raw(Box::new(Config::new_default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn config_free(ptr: *mut Config) {
|
||||||
|
if !ptr.is_null() {
|
||||||
|
// SAFETY: pointer came from `config_new` and is freed once.
|
||||||
|
drop(unsafe { Box::from_raw(ptr) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the configuration from a string in the same format as the file-based
|
||||||
|
/// syntax for the desktop version of the terminal.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn config_load_string(config: *mut Config, s: *const u8, len: usize) {
|
||||||
|
// SAFETY: live pointer from `config_new`; `s` is valid for `len` bytes.
|
||||||
|
let (config, s) = unsafe { (&mut *config, host_bytes(s, len)) };
|
||||||
|
let mut iter = LineIterator::new(Cursor::new(s));
|
||||||
|
if let Err(err) = cli_args::parse(config, &mut iter) {
|
||||||
|
log::error!("error loading config err={err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn config_finalize(config: *mut Config) {
|
||||||
|
// SAFETY: live pointer from `config_new`.
|
||||||
|
if let Err(err) = unsafe { &mut *config }.finalize() {
|
||||||
|
log::error!("error finalizing config err={err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extra (not in Zig): writes the config in file format (like
|
||||||
|
/// `+show-config`, only changed values) to `out` (capacity `cap`) and returns
|
||||||
|
/// the full length; the output is truncated if it does not fit.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn config_format(config: *const Config, out: *mut u8, cap: usize) -> usize {
|
||||||
|
// SAFETY: live pointer from `config_new`; `out` is valid for `cap` bytes.
|
||||||
|
let (config, out) = unsafe { (&*config, std::slice::from_raw_parts_mut(out, cap)) };
|
||||||
|
let mut fmt = FileFormatter::new(config);
|
||||||
|
fmt.changed = true;
|
||||||
|
let s = fmt.to_string();
|
||||||
|
let n = s.len().min(cap);
|
||||||
|
out[..n].copy_from_slice(&s.as_bytes()[..n]);
|
||||||
|
s.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extra (not in Zig): the number of diagnostics (parse errors) recorded.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn config_diagnostics_count(config: *const Config) -> usize {
|
||||||
|
// SAFETY: live pointer from `config_new`.
|
||||||
|
unsafe { &*config }.diagnostics.items().len()
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//! `font/face/web_canvas.zig` `Wasm` (`face_*`) and `font/DeferredFace.zig`
|
||||||
|
//! `Wasm` (`deferred_face_*`).
|
||||||
|
|
||||||
|
use ghostty_font::{
|
||||||
|
Glyph,
|
||||||
|
Presentation,
|
||||||
|
atlas::Atlas,
|
||||||
|
deferred_face::{DeferredFace, WebCanvas},
|
||||||
|
face::{DesiredSize, web_canvas::Face},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{ensure_init, host_bytes};
|
||||||
|
|
||||||
|
fn presentation_from(v: u16) -> Option<Presentation> {
|
||||||
|
match v {
|
||||||
|
0 => Some(Presentation::Text),
|
||||||
|
1 => Some(Presentation::Emoji),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C layout of `font.Glyph`.
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct GlyphC {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub offset_x: i32,
|
||||||
|
pub offset_y: i32,
|
||||||
|
pub atlas_x: u32,
|
||||||
|
pub atlas_y: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Glyph> for GlyphC {
|
||||||
|
fn from(g: Glyph) -> GlyphC {
|
||||||
|
GlyphC {
|
||||||
|
width: g.width,
|
||||||
|
height: g.height,
|
||||||
|
offset_x: g.offset_x,
|
||||||
|
offset_y: g.offset_y,
|
||||||
|
atlas_x: g.atlas_x,
|
||||||
|
atlas_y: g.atlas_y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn face_new(ptr: *const u8, len: usize, pts: u16, p: u16) -> *mut Face {
|
||||||
|
ensure_init();
|
||||||
|
// SAFETY: the host passes a buffer valid for `len` bytes.
|
||||||
|
let raw = unsafe { host_bytes(ptr, len) };
|
||||||
|
let (Ok(raw), Some(p)) = (std::str::from_utf8(raw), presentation_from(p)) else {
|
||||||
|
return std::ptr::null_mut();
|
||||||
|
};
|
||||||
|
match Face::init_named(raw, DesiredSize::new(f32::from(pts)), p) {
|
||||||
|
Ok(face) => Box::into_raw(Box::new(face)),
|
||||||
|
Err(_) => std::ptr::null_mut(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn face_free(ptr: *mut Face) {
|
||||||
|
if !ptr.is_null() {
|
||||||
|
// SAFETY: pointer came from `face_new` and is freed once.
|
||||||
|
drop(unsafe { Box::from_raw(ptr) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resulting pointer must be freed with [`glyph_free`].
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn face_render_glyph(
|
||||||
|
face: *mut Face,
|
||||||
|
atlas: *mut Atlas,
|
||||||
|
codepoint: u32,
|
||||||
|
) -> *mut GlyphC {
|
||||||
|
// SAFETY: live pointers from the host.
|
||||||
|
let (face, atlas) = unsafe { (&*face, &mut *atlas) };
|
||||||
|
match face.render_glyph(atlas, codepoint) {
|
||||||
|
Ok(g) => Box::into_raw(Box::new(g.into())),
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("error rendering glyph err={err}");
|
||||||
|
std::ptr::null_mut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn glyph_free(ptr: *mut GlyphC) {
|
||||||
|
if !ptr.is_null() {
|
||||||
|
// SAFETY: pointer came from `face_render_glyph` and is freed once.
|
||||||
|
drop(unsafe { Box::from_raw(ptr) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn face_debug_canvas(face: *mut Face) {
|
||||||
|
// SAFETY: live pointer from `face_new`.
|
||||||
|
if let Err(err) = unsafe { &*face }.debug_canvas() {
|
||||||
|
log::warn!("error adding debug canvas err={err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extra (not in Zig): the cell metrics `cell_width, cell_height,
|
||||||
|
/// cell_baseline` of a face, written to `out[0..3]`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn face_metrics(face: *const Face, out: *mut u32) {
|
||||||
|
// SAFETY: live pointer from `face_new`; `out` has room for 3 u32.
|
||||||
|
let (face, out) = unsafe { (&*face, std::slice::from_raw_parts_mut(out, 3)) };
|
||||||
|
out[0] = face.metrics.cell_width;
|
||||||
|
out[1] = face.metrics.cell_height;
|
||||||
|
out[2] = face.metrics.cell_baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extra (not in Zig): `Face.glyphIndex`; returns 0xFFFF_FFFF for none.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn face_glyph_index(face: *const Face, cp: u32) -> u32 {
|
||||||
|
// SAFETY: live pointer from `face_new`.
|
||||||
|
unsafe { &*face }.glyph_index(cp).unwrap_or(u32::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn deferred_face_new(
|
||||||
|
ptr: *const u8,
|
||||||
|
len: usize,
|
||||||
|
presentation: u16,
|
||||||
|
) -> *mut DeferredFace {
|
||||||
|
ensure_init();
|
||||||
|
// SAFETY: the host passes a buffer valid for `len` bytes.
|
||||||
|
let raw = unsafe { host_bytes(ptr, len) };
|
||||||
|
let (Ok(raw), Some(p)) = (std::str::from_utf8(raw), presentation_from(presentation)) else {
|
||||||
|
log::warn!("error creating deferred face err=InvalidArgument");
|
||||||
|
return std::ptr::null_mut();
|
||||||
|
};
|
||||||
|
Box::into_raw(Box::new(DeferredFace::from_web_canvas(WebCanvas {
|
||||||
|
font_str: raw.to_owned(),
|
||||||
|
presentation: p,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn deferred_face_free(ptr: *mut DeferredFace) {
|
||||||
|
if !ptr.is_null() {
|
||||||
|
// SAFETY: pointer came from `deferred_face_new` and is freed once.
|
||||||
|
drop(unsafe { Box::from_raw(ptr) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn deferred_face_load(face: *mut DeferredFace, pts: f32) {
|
||||||
|
// SAFETY: live pointer from `deferred_face_new`.
|
||||||
|
let face = unsafe { &mut *face };
|
||||||
|
// Like Zig, the loaded face is dropped (this only checks that it loads).
|
||||||
|
if let Err(err) = face.load(DesiredSize::new(pts)) {
|
||||||
|
log::warn!("error loading deferred face err={err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//! The Ghostty WASM module. Port of `ZIG/src/main_wasm.zig`.
|
||||||
|
//!
|
||||||
|
//! The module exports a C ABI. Like Zig's `main_wasm.zig` it pulls in
|
||||||
|
//! `os/wasm` (logging, JS interop), `font` (atlas, deferred face, canvas face,
|
||||||
|
//! shaper), `terminal` and the config/app wasm APIs:
|
||||||
|
//!
|
||||||
|
//! - module `atlas`, `font/Atlas.zig` `Wasm` (`atlas_*`)
|
||||||
|
//! - module `face`, `font/face/web_canvas.zig` `Wasm` (`face_*`) and
|
||||||
|
//! `font/DeferredFace.zig` `Wasm` (`deferred_face_*`)
|
||||||
|
//! - module `shaper`, `font/shaper/web_canvas.zig` `Wasm` (`shaper_*`)
|
||||||
|
//! - module `config`, `config/Wasm.zig` (`config_*`)
|
||||||
|
//! - module `app`, `App.zig` `Wasm` (commented out upstream, nothing exported)
|
||||||
|
//!
|
||||||
|
//! The host provides the `ghostty_js` imports (see
|
||||||
|
//! `ghostty_os::wasm::js`) and `env.log`. Strings are passed as
|
||||||
|
//! pointer+length pairs into wasm memory allocated by the host with
|
||||||
|
//! [`wasm_alloc`] / released with [`wasm_free`].
|
||||||
|
//!
|
||||||
|
//! The crate is empty on non-wasm targets so the native workspace builds.
|
||||||
|
#![cfg(target_arch = "wasm32")]
|
||||||
|
// The C ABI needs raw pointers; every unsafe block has a SAFETY comment.
|
||||||
|
#![allow(unsafe_code)]
|
||||||
|
// Exports are `extern "C"` taking raw pointers by design.
|
||||||
|
#![allow(clippy::not_unsafe_ptr_arg_deref, clippy::missing_safety_doc)]
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
|
pub mod atlas;
|
||||||
|
pub mod config;
|
||||||
|
pub mod face;
|
||||||
|
pub mod shaper;
|
||||||
|
|
||||||
|
use std::sync::Once;
|
||||||
|
|
||||||
|
static INIT: Once = Once::new();
|
||||||
|
|
||||||
|
/// Installs the logger and panic hook (Zig: `std_options.logFn`). Called by
|
||||||
|
/// every constructor export so the host does not need a separate start call;
|
||||||
|
/// also exported for hosts that want logging before the first constructor.
|
||||||
|
pub(crate) fn ensure_init() {
|
||||||
|
INIT.call_once(|| {
|
||||||
|
ghostty_os::wasm::log::init();
|
||||||
|
std::panic::set_hook(Box::new(|info| {
|
||||||
|
ghostty_os::wasm::log::host_log(&format!("error(panic): {info}"));
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn ghostty_wasm_init() {
|
||||||
|
ensure_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocates `len` bytes in wasm memory for the host to write into (strings,
|
||||||
|
/// bitmaps). Release with [`wasm_free`]. Returns null on `len == 0`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn wasm_alloc(len: usize) -> *mut u8 {
|
||||||
|
if len == 0 {
|
||||||
|
return std::ptr::null_mut();
|
||||||
|
}
|
||||||
|
let mut v = vec![0u8; len].into_boxed_slice();
|
||||||
|
let p = v.as_mut_ptr();
|
||||||
|
std::mem::forget(v);
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases memory from [`wasm_alloc`].
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn wasm_free(ptr: *mut u8, len: usize) {
|
||||||
|
if ptr.is_null() || len == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: `ptr`/`len` came from `wasm_alloc` (boxed slice of `len` bytes).
|
||||||
|
drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrows a host string (`ptr`, `len`) as bytes.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `ptr` must be valid for `len` bytes (or `len == 0`).
|
||||||
|
pub(crate) unsafe fn host_bytes<'a>(ptr: *const u8, len: usize) -> &'a [u8] {
|
||||||
|
if len == 0 {
|
||||||
|
return &[];
|
||||||
|
}
|
||||||
|
// SAFETY: guaranteed by the caller.
|
||||||
|
unsafe { std::slice::from_raw_parts(ptr, len) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
//! `font/shaper/web_canvas.zig` `Wasm` (`shaper_*`).
|
||||||
|
|
||||||
|
use ghostty_font::{face::web_canvas::Face, shaper::web_canvas::Shaper};
|
||||||
|
use ghostty_terminal::terminal::{Options, Terminal};
|
||||||
|
|
||||||
|
use crate::{ensure_init, host_bytes};
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn shaper_new(cap: usize) -> *mut Shaper {
|
||||||
|
ensure_init();
|
||||||
|
Box::into_raw(Box::new(Shaper::new(cap)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn shaper_free(ptr: *mut Shaper) {
|
||||||
|
if !ptr.is_null() {
|
||||||
|
// SAFETY: pointer came from `shaper_new` and is freed once.
|
||||||
|
drop(unsafe { Box::from_raw(ptr) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a test to verify shaping works properly: prints the string into a
|
||||||
|
/// terminal, then shapes and logs every row.
|
||||||
|
///
|
||||||
|
/// Zig takes a `GroupCache` (stale upstream); this takes the face that
|
||||||
|
/// reserves grapheme glyph indexes.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn shaper_test(shaper: *mut Shaper, face: *mut Face, s: *const u8, len: usize) {
|
||||||
|
// SAFETY: live pointers from the host; `s` is valid for `len` bytes.
|
||||||
|
let (shaper, face, s) = unsafe { (&mut *shaper, &mut *face, host_bytes(s, len)) };
|
||||||
|
if let Err(err) = shaper_test_(shaper, face, s) {
|
||||||
|
log::warn!("error during shaper test err={err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shaper_test_(shaper: &mut Shaper, face: &mut Face, s: &[u8]) -> Result<(), String> {
|
||||||
|
let s = std::str::from_utf8(s).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Create a terminal and print all our characters into it.
|
||||||
|
let cols = u16::try_from(shaper.cell_buf_len()).map_err(|e| e.to_string())?;
|
||||||
|
let mut term = Terminal::new(Options::new(cols, 80)).map_err(|e| format!("{e:?}"))?;
|
||||||
|
for c in s.chars() {
|
||||||
|
term.print(u32::from(c)).map_err(|e| format!("{e:?}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate over the rows and print out all the runs we get.
|
||||||
|
let plain = term.plain_string().map_err(|e| format!("{e:?}"))?;
|
||||||
|
for (y, row) in plain.lines().enumerate() {
|
||||||
|
if row.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
shaper.prepare();
|
||||||
|
for (x, c) in row.chars().enumerate() {
|
||||||
|
shaper.add_codepoint(u32::from(c), x as u32);
|
||||||
|
}
|
||||||
|
shaper.finalize();
|
||||||
|
let cells = shaper
|
||||||
|
.shape(|cluster| face.grapheme_glyph_index(cluster))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
log::info!("y={y} shape={cells:?}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extra (not in Zig): prepares a run for the hook-style API.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn shaper_prepare(shaper: *mut Shaper) {
|
||||||
|
// SAFETY: live pointer from `shaper_new`.
|
||||||
|
unsafe { &mut *shaper }.prepare();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extra (not in Zig): `RunIteratorHook.addCodepoint`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn shaper_add_codepoint(shaper: *mut Shaper, cp: u32, cluster: u32) {
|
||||||
|
// SAFETY: live pointer from `shaper_new`.
|
||||||
|
unsafe { &mut *shaper }.add_codepoint(cp, cluster);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extra (not in Zig): shapes the current run. Writes `(x, glyph_index)` u32
|
||||||
|
/// pairs to `out` (capacity `cap` pairs) and returns the number of cells, or
|
||||||
|
/// `u32::MAX` on error.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn shaper_shape(
|
||||||
|
shaper: *mut Shaper,
|
||||||
|
face: *mut Face,
|
||||||
|
out: *mut u32,
|
||||||
|
cap: usize,
|
||||||
|
) -> u32 {
|
||||||
|
// SAFETY: live pointers from the host; `out` has room for `cap` pairs.
|
||||||
|
let (shaper, face, out) = unsafe {
|
||||||
|
(
|
||||||
|
&mut *shaper,
|
||||||
|
&mut *face,
|
||||||
|
std::slice::from_raw_parts_mut(out, cap * 2),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
match shaper.shape(|cluster| face.grapheme_glyph_index(cluster)) {
|
||||||
|
Ok(cells) if cells.len() <= cap => {
|
||||||
|
for (i, c) in cells.iter().enumerate() {
|
||||||
|
out[i * 2] = u32::from(c.x);
|
||||||
|
out[i * 2 + 1] = c.glyph_index;
|
||||||
|
}
|
||||||
|
cells.len() as u32
|
||||||
|
}
|
||||||
|
Ok(_) => u32::MAX,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("error shaping err={err}");
|
||||||
|
u32::MAX
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//! Builds the wasm module and runs `tests/node_test.js` against it with
|
||||||
|
//! node, using stub JS imports (a fake canvas measuring API). Skipped when
|
||||||
|
//! node or the `wasm32-unknown-unknown` target is not available.
|
||||||
|
|
||||||
|
use std::{path::PathBuf, process::Command};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wasm_module_runs_under_node() {
|
||||||
|
if Command::new("node").arg("--version").output().is_err() {
|
||||||
|
eprintln!("skipping: node not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let target_dir = std::env::var_os("CARGO_TARGET_DIR")
|
||||||
|
.map_or_else(|| manifest.join("../../target"), PathBuf::from);
|
||||||
|
// A separate directory so this does not contend with the outer build.
|
||||||
|
let target_dir = target_dir.join("wasm-test");
|
||||||
|
|
||||||
|
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_owned());
|
||||||
|
let out = Command::new(cargo)
|
||||||
|
.args([
|
||||||
|
"build",
|
||||||
|
"--target",
|
||||||
|
"wasm32-unknown-unknown",
|
||||||
|
"-p",
|
||||||
|
"ghostty-wasm",
|
||||||
|
"--manifest-path",
|
||||||
|
])
|
||||||
|
.arg(manifest.join("Cargo.toml"))
|
||||||
|
.env("CARGO_TARGET_DIR", &target_dir)
|
||||||
|
.output()
|
||||||
|
.expect("run cargo");
|
||||||
|
if !out.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
if stderr.contains("can't find crate for `std`")
|
||||||
|
|| stderr.contains("target may not be installed")
|
||||||
|
{
|
||||||
|
eprintln!("skipping: wasm32-unknown-unknown target not installed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panic!("wasm build failed:\n{stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let wasm = target_dir.join("wasm32-unknown-unknown/debug/ghostty_wasm.wasm");
|
||||||
|
assert!(wasm.exists(), "missing {}", wasm.display());
|
||||||
|
|
||||||
|
let out = Command::new("node")
|
||||||
|
.arg(manifest.join("tests/node_test.js"))
|
||||||
|
.arg(&wasm)
|
||||||
|
.output()
|
||||||
|
.expect("run node");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"node test failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
assert!(String::from_utf8_lossy(&out.stdout).contains("ok"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Instantiates ghostty_wasm.wasm with stub JS imports and exercises the
|
||||||
|
// exported config / atlas / canvas face / shaper functions. Run by
|
||||||
|
// tests/node.rs: `node node_test.js <path-to-wasm>`.
|
||||||
|
const fs = require('fs');
|
||||||
|
const assert = require('assert');
|
||||||
|
const path = require('path');
|
||||||
|
const { createHost } = require(path.join(__dirname, '..', 'js', 'ghostty_host.js'));
|
||||||
|
|
||||||
|
// A fake canvas: every glyph is a 7px wide, 12px ascent, 4px descent box
|
||||||
|
// (emoji are 14px wide and "colored"). measureText records the font in use.
|
||||||
|
class FakeCanvas {
|
||||||
|
constructor() {
|
||||||
|
this.width = 300;
|
||||||
|
this.height = 150;
|
||||||
|
this.style = {};
|
||||||
|
this.calls = [];
|
||||||
|
const self = this;
|
||||||
|
this.ctx = {
|
||||||
|
font: '',
|
||||||
|
textBaseline: 'alphabetic',
|
||||||
|
fillStyle: '',
|
||||||
|
clearRect: (...a) => self.calls.push(['clearRect', ...a]),
|
||||||
|
fillRect: (...a) => self.calls.push(['fillRect', ...a]),
|
||||||
|
fillText: (t, x, y) => {
|
||||||
|
self.lastText = { t, x, y };
|
||||||
|
self.calls.push(['fillText', t, x, y]);
|
||||||
|
},
|
||||||
|
measureText: (t) => {
|
||||||
|
const n = [...t].length;
|
||||||
|
const emoji = /\p{Extended_Pictographic}/u.test(t);
|
||||||
|
const w = emoji ? 14 : 7 * n;
|
||||||
|
return {
|
||||||
|
width: w + 1,
|
||||||
|
actualBoundingBoxLeft: 0,
|
||||||
|
actualBoundingBoxRight: w,
|
||||||
|
actualBoundingBoxAscent: 12,
|
||||||
|
actualBoundingBoxDescent: t.includes('_') ? 4 : 0.5,
|
||||||
|
_t: t,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getImageData: (x, y, w, h) => {
|
||||||
|
const data = new Uint8ClampedArray(w * h * 4);
|
||||||
|
const emoji = /\p{Extended_Pictographic}/u.test(self.lastText ? self.lastText.t : '');
|
||||||
|
for (let i = 0; i < w * h; i++) {
|
||||||
|
if (emoji) {
|
||||||
|
data[i * 4] = 200; // red channel => color
|
||||||
|
data[i * 4 + 3] = 255;
|
||||||
|
} else {
|
||||||
|
data[i * 4 + 3] = (i % 2) * 255; // alpha only (black text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { data };
|
||||||
|
},
|
||||||
|
putImageData: (...a) => self.calls.push(['putImageData', ...a]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
getContext(kind) {
|
||||||
|
assert.strictEqual(kind, '2d');
|
||||||
|
return this.ctx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class FakeImageData {
|
||||||
|
constructor(data, w, h) {
|
||||||
|
this.data = data;
|
||||||
|
this.width = w;
|
||||||
|
this.height = h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const canvases = [];
|
||||||
|
const fakeGlobal = {
|
||||||
|
document: {
|
||||||
|
createElement: (tag) => {
|
||||||
|
assert.strictEqual(tag, 'canvas');
|
||||||
|
const c = new FakeCanvas();
|
||||||
|
canvases.push(c);
|
||||||
|
return c;
|
||||||
|
},
|
||||||
|
getElementById: () => null,
|
||||||
|
},
|
||||||
|
ImageData: FakeImageData,
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const logs = [];
|
||||||
|
const host = createHost({ global: fakeGlobal, log: (s) => logs.push(s) });
|
||||||
|
const bytes = fs.readFileSync(process.argv[2]);
|
||||||
|
const { instance } = await WebAssembly.instantiate(bytes, host.imports);
|
||||||
|
host.setInstance(instance);
|
||||||
|
const x = instance.exports;
|
||||||
|
x.ghostty_wasm_init();
|
||||||
|
|
||||||
|
// ---- config ----
|
||||||
|
{
|
||||||
|
const cfg = x.config_new();
|
||||||
|
assert.ok(cfg);
|
||||||
|
const s = host.writeString('font-size = 18\nwindow-padding-x = 7\nnot-a-key = 1\n');
|
||||||
|
x.config_load_string(cfg, s.ptr, s.len);
|
||||||
|
s.free();
|
||||||
|
assert.strictEqual(x.config_diagnostics_count(cfg), 1);
|
||||||
|
x.config_finalize(cfg);
|
||||||
|
const cap = 1 << 16;
|
||||||
|
const out = x.wasm_alloc(cap);
|
||||||
|
const n = x.config_format(cfg, out, cap);
|
||||||
|
assert.ok(n > 0 && n <= cap);
|
||||||
|
const text = host.readString(out, n);
|
||||||
|
x.wasm_free(out, cap);
|
||||||
|
assert.match(text, /font-size = 18/);
|
||||||
|
assert.match(text, /window-padding-x = 7/);
|
||||||
|
x.config_free(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- atlas ----
|
||||||
|
const atlas = x.atlas_new(64, 0); // grayscale
|
||||||
|
assert.ok(atlas);
|
||||||
|
assert.strictEqual(x.atlas_size(atlas), 64);
|
||||||
|
|
||||||
|
// ---- face ----
|
||||||
|
const f = host.writeString('monospace');
|
||||||
|
const face = x.face_new(f.ptr, f.len, 12, 0);
|
||||||
|
assert.ok(face, 'face_new');
|
||||||
|
const m = x.wasm_alloc(12);
|
||||||
|
x.face_metrics(face, m);
|
||||||
|
const metrics = new Uint32Array(instance.exports.memory.buffer, m, 3).slice();
|
||||||
|
assert.deepStrictEqual([...metrics], [7, 16, 4]);
|
||||||
|
assert.ok(canvases[0].ctx.font === '12px monospace', canvases[0].ctx.font);
|
||||||
|
|
||||||
|
// Text presentation face supports ascii/latin text but not color emoji
|
||||||
|
assert.strictEqual((x.face_glyph_index(face, 0x41) >>> 0), 0x41);
|
||||||
|
assert.strictEqual((x.face_glyph_index(face, 0x4e2d) >>> 0), 0x4e2d);
|
||||||
|
assert.strictEqual((x.face_glyph_index(face, 0x1f600) >>> 0), 0xffffffff);
|
||||||
|
|
||||||
|
// Render a glyph into the atlas
|
||||||
|
const g = x.face_render_glyph(face, atlas, 0x41);
|
||||||
|
assert.ok(g, 'face_render_glyph');
|
||||||
|
const gv = new Uint32Array(instance.exports.memory.buffer, g, 6);
|
||||||
|
assert.strictEqual(gv[0], 8); // width: 7 + 1 border
|
||||||
|
assert.strictEqual(gv[1], 14); // height: ceil(12 + 0.5) + 1
|
||||||
|
x.glyph_free(g);
|
||||||
|
const sz = x.wasm_alloc(8);
|
||||||
|
const dp = x.atlas_data(atlas, sz);
|
||||||
|
const len = new Uint32Array(instance.exports.memory.buffer, sz, 1)[0];
|
||||||
|
assert.strictEqual(len, 64 * 64);
|
||||||
|
const atlasBytes = new Uint8Array(instance.exports.memory.buffer, dp, len);
|
||||||
|
assert.ok(atlasBytes.some((b) => b === 255), 'atlas received glyph alpha');
|
||||||
|
|
||||||
|
// Emoji face
|
||||||
|
const ef = host.writeString('sans-serif');
|
||||||
|
const eface = x.face_new(ef.ptr, ef.len, 14, 1);
|
||||||
|
assert.ok(eface);
|
||||||
|
assert.strictEqual((x.face_glyph_index(eface, 0x1f600) >>> 0), 0x1f600);
|
||||||
|
assert.strictEqual((x.face_glyph_index(eface, 0x4e2d) >>> 0), 0xffffffff);
|
||||||
|
|
||||||
|
// ---- shaper ----
|
||||||
|
const sh = x.shaper_new(16);
|
||||||
|
x.shaper_prepare(sh);
|
||||||
|
// 'e' + U+0301, then 'x'
|
||||||
|
x.shaper_add_codepoint(sh, 0x65, 0);
|
||||||
|
x.shaper_add_codepoint(sh, 0x301, 0);
|
||||||
|
x.shaper_add_codepoint(sh, 0x78, 1);
|
||||||
|
const outp = x.wasm_alloc(16 * 8);
|
||||||
|
const cells = x.shaper_shape(sh, face, outp, 16);
|
||||||
|
assert.strictEqual(cells, 2);
|
||||||
|
const ov = new Uint32Array(instance.exports.memory.buffer, outp, 4);
|
||||||
|
assert.deepStrictEqual([ov[0], ov[1] >= 0x110000, ov[2], ov[3]], [0, true, 1, 0x78]);
|
||||||
|
// Render the reserved grapheme glyph through the canvas
|
||||||
|
const gg = x.face_render_glyph(face, atlas, ov[1]);
|
||||||
|
assert.ok(gg);
|
||||||
|
x.glyph_free(gg);
|
||||||
|
assert.strictEqual(canvases[0].lastText.t, 'é');
|
||||||
|
|
||||||
|
// shaper_test prints through a terminal and logs the shaped runs
|
||||||
|
const st = host.writeString('hello é');
|
||||||
|
x.shaper_test(sh, face, st.ptr, st.len);
|
||||||
|
assert.ok(logs.some((l) => l.startsWith('info(')), logs.join('\n'));
|
||||||
|
|
||||||
|
// ---- deferred face ----
|
||||||
|
const d = x.deferred_face_new(f.ptr, f.len, 0);
|
||||||
|
assert.ok(d);
|
||||||
|
const before = canvases.length;
|
||||||
|
x.deferred_face_load(d, 12);
|
||||||
|
assert.strictEqual(canvases.length, before + 1);
|
||||||
|
x.deferred_face_free(d);
|
||||||
|
|
||||||
|
// ---- atlas debug canvas ----
|
||||||
|
const h = x.atlas_debug_canvas(atlas);
|
||||||
|
assert.ok(h > 0);
|
||||||
|
const dc = host.value(h);
|
||||||
|
assert.strictEqual(dc.width, 64);
|
||||||
|
assert.ok(dc.calls.some((c) => c[0] === 'putImageData'));
|
||||||
|
|
||||||
|
// ---- cleanup / handle leaks ----
|
||||||
|
x.shaper_free(sh);
|
||||||
|
x.face_free(face);
|
||||||
|
x.face_free(eface);
|
||||||
|
x.atlas_free(atlas);
|
||||||
|
f.free();
|
||||||
|
ef.free();
|
||||||
|
st.free();
|
||||||
|
console.log('ok');
|
||||||
|
})().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
Reference in new issue
Block a user