From 04a6b7c97adc66dc89b6923f7ffc552e09b8a792 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 11 Oct 2026 21:53:29 +0200 Subject: [PATCH] feat(font): port font metrics, atlas and the sprite font Ports ZIG/src/font/{Metrics,Atlas,Glyph}.zig and the whole sprite font (font/sprite/**): box drawing, block elements, braille, branch/git graph symbols, geometric shapes, powerline, and the symbols for legacy computing (+ supplement, incl. the octants table). Ghostty draws sprites with z2d; we use tiny-skia for path coverage, composited onto an alpha8 canvas, while following the Zig geometry (positions, thickness, rounding). Comptime discovery of draw functions is replaced by explicit, overlap-checked codepoint range tables. Upstream reference sheets are vendored in testdata/ and compared with a tolerance: braille and U+1CD00 are pixel-identical; other sheets differ only in antialiasing on thin diagonals/curves. Review renders are written to target/sprite_sheets/. The crate temporarily drops its config/unicode/terminal deps until the rest of the font engine is ported (Phase 3). Test Plan: - cargo test -p ghostty-font (49 tests incl. reference sheet comparison) - inspect target/sprite_sheets/review-all-18x36.png --- Cargo.lock | 3 +- crates/ghostty-font/Cargo.toml | 7 +- crates/ghostty-font/src/atlas.rs | 553 +++++ crates/ghostty-font/src/glyph.rs | 21 + crates/ghostty-font/src/lib.rs | 15 +- crates/ghostty-font/src/metrics.rs | 908 +++++++++ crates/ghostty-font/src/sprite/canvas.rs | 787 ++++++++ crates/ghostty-font/src/sprite/draw/block.rs | 172 ++ crates/ghostty-font/src/sprite/draw/box_.rs | 1778 +++++++++++++++++ .../ghostty-font/src/sprite/draw/braille.rs | 205 ++ crates/ghostty-font/src/sprite/draw/branch.rs | 686 +++++++ crates/ghostty-font/src/sprite/draw/common.rs | 397 ++++ .../src/sprite/draw/geometric_shapes.rs | 143 ++ crates/ghostty-font/src/sprite/draw/mod.rs | 16 + .../ghostty-font/src/sprite/draw/octants.txt | 234 +++ .../ghostty-font/src/sprite/draw/powerline.rs | 496 +++++ .../ghostty-font/src/sprite/draw/special.rs | 383 ++++ .../draw/symbols_for_legacy_computing.rs | 1074 ++++++++++ ...symbols_for_legacy_computing_supplement.rs | 800 ++++++++ crates/ghostty-font/src/sprite/face.rs | 551 +++++ crates/ghostty-font/src/sprite/mod.rs | 72 + .../testdata/U+1CC00...U+1CCFF-11x21+2.png | Bin 0 -> 1032 bytes .../testdata/U+1CC00...U+1CCFF-12x24+3.png | Bin 0 -> 1295 bytes .../testdata/U+1CC00...U+1CCFF-18x36+4.png | Bin 0 -> 2193 bytes .../testdata/U+1CC00...U+1CCFF-9x17+1.png | Bin 0 -> 794 bytes .../testdata/U+1CD00...U+1CDFF-11x21+2.png | Bin 0 -> 1280 bytes .../testdata/U+1CD00...U+1CDFF-12x24+3.png | Bin 0 -> 1870 bytes .../testdata/U+1CD00...U+1CDFF-18x36+4.png | Bin 0 -> 3411 bytes .../testdata/U+1CD00...U+1CDFF-9x17+1.png | Bin 0 -> 1103 bytes .../testdata/U+1CE00...U+1CEFF-11x21+2.png | Bin 0 -> 1006 bytes .../testdata/U+1CE00...U+1CEFF-12x24+3.png | Bin 0 -> 1255 bytes .../testdata/U+1CE00...U+1CEFF-18x36+4.png | Bin 0 -> 2247 bytes .../testdata/U+1CE00...U+1CEFF-9x17+1.png | Bin 0 -> 751 bytes .../testdata/U+1FB00...U+1FBFF-11x21+2.png | Bin 0 -> 5427 bytes .../testdata/U+1FB00...U+1FBFF-12x24+3.png | Bin 0 -> 5724 bytes .../testdata/U+1FB00...U+1FBFF-18x36+4.png | Bin 0 -> 9975 bytes .../testdata/U+1FB00...U+1FBFF-9x17+1.png | Bin 0 -> 4341 bytes .../testdata/U+2500...U+25FF-11x21+2.png | Bin 0 -> 2225 bytes .../testdata/U+2500...U+25FF-12x24+3.png | Bin 0 -> 2635 bytes .../testdata/U+2500...U+25FF-18x36+4.png | Bin 0 -> 4570 bytes .../testdata/U+2500...U+25FF-9x17+1.png | Bin 0 -> 1853 bytes .../testdata/U+2800...U+28FF-11x21+2.png | Bin 0 -> 1022 bytes .../testdata/U+2800...U+28FF-12x24+3.png | Bin 0 -> 1541 bytes .../testdata/U+2800...U+28FF-18x36+4.png | Bin 0 -> 2501 bytes .../testdata/U+2800...U+28FF-9x17+1.png | Bin 0 -> 917 bytes .../testdata/U+E000...U+E0FF-11x21+2.png | Bin 0 -> 1102 bytes .../testdata/U+E000...U+E0FF-12x24+3.png | Bin 0 -> 1255 bytes .../testdata/U+E000...U+E0FF-18x36+4.png | Bin 0 -> 2217 bytes .../testdata/U+E000...U+E0FF-9x17+1.png | Bin 0 -> 894 bytes .../testdata/U+F500...U+F5FF-11x21+2.png | Bin 0 -> 1114 bytes .../testdata/U+F500...U+F5FF-12x24+3.png | Bin 0 -> 1421 bytes .../testdata/U+F500...U+F5FF-18x36+4.png | Bin 0 -> 2473 bytes .../testdata/U+F500...U+F5FF-9x17+1.png | Bin 0 -> 872 bytes .../testdata/U+F600...U+F6FF-11x21+2.png | Bin 0 -> 495 bytes .../testdata/U+F600...U+F6FF-12x24+3.png | Bin 0 -> 637 bytes .../testdata/U+F600...U+F6FF-18x36+4.png | Bin 0 -> 1210 bytes .../testdata/U+F600...U+F6FF-9x17+1.png | Bin 0 -> 393 bytes docs/rewrite/notes/ghostty-font-W7.md | 36 + 58 files changed, 9331 insertions(+), 6 deletions(-) create mode 100644 crates/ghostty-font/src/atlas.rs create mode 100644 crates/ghostty-font/src/glyph.rs create mode 100644 crates/ghostty-font/src/metrics.rs create mode 100644 crates/ghostty-font/src/sprite/canvas.rs create mode 100644 crates/ghostty-font/src/sprite/draw/block.rs create mode 100644 crates/ghostty-font/src/sprite/draw/box_.rs create mode 100644 crates/ghostty-font/src/sprite/draw/braille.rs create mode 100644 crates/ghostty-font/src/sprite/draw/branch.rs create mode 100644 crates/ghostty-font/src/sprite/draw/common.rs create mode 100644 crates/ghostty-font/src/sprite/draw/geometric_shapes.rs create mode 100644 crates/ghostty-font/src/sprite/draw/mod.rs create mode 100644 crates/ghostty-font/src/sprite/draw/octants.txt create mode 100644 crates/ghostty-font/src/sprite/draw/powerline.rs create mode 100644 crates/ghostty-font/src/sprite/draw/special.rs create mode 100644 crates/ghostty-font/src/sprite/draw/symbols_for_legacy_computing.rs create mode 100644 crates/ghostty-font/src/sprite/draw/symbols_for_legacy_computing_supplement.rs create mode 100644 crates/ghostty-font/src/sprite/face.rs create mode 100644 crates/ghostty-font/src/sprite/mod.rs create mode 100644 crates/ghostty-font/testdata/U+1CC00...U+1CCFF-11x21+2.png create mode 100644 crates/ghostty-font/testdata/U+1CC00...U+1CCFF-12x24+3.png create mode 100644 crates/ghostty-font/testdata/U+1CC00...U+1CCFF-18x36+4.png create mode 100644 crates/ghostty-font/testdata/U+1CC00...U+1CCFF-9x17+1.png create mode 100644 crates/ghostty-font/testdata/U+1CD00...U+1CDFF-11x21+2.png create mode 100644 crates/ghostty-font/testdata/U+1CD00...U+1CDFF-12x24+3.png create mode 100644 crates/ghostty-font/testdata/U+1CD00...U+1CDFF-18x36+4.png create mode 100644 crates/ghostty-font/testdata/U+1CD00...U+1CDFF-9x17+1.png create mode 100644 crates/ghostty-font/testdata/U+1CE00...U+1CEFF-11x21+2.png create mode 100644 crates/ghostty-font/testdata/U+1CE00...U+1CEFF-12x24+3.png create mode 100644 crates/ghostty-font/testdata/U+1CE00...U+1CEFF-18x36+4.png create mode 100644 crates/ghostty-font/testdata/U+1CE00...U+1CEFF-9x17+1.png create mode 100644 crates/ghostty-font/testdata/U+1FB00...U+1FBFF-11x21+2.png create mode 100644 crates/ghostty-font/testdata/U+1FB00...U+1FBFF-12x24+3.png create mode 100644 crates/ghostty-font/testdata/U+1FB00...U+1FBFF-18x36+4.png create mode 100644 crates/ghostty-font/testdata/U+1FB00...U+1FBFF-9x17+1.png create mode 100644 crates/ghostty-font/testdata/U+2500...U+25FF-11x21+2.png create mode 100644 crates/ghostty-font/testdata/U+2500...U+25FF-12x24+3.png create mode 100644 crates/ghostty-font/testdata/U+2500...U+25FF-18x36+4.png create mode 100644 crates/ghostty-font/testdata/U+2500...U+25FF-9x17+1.png create mode 100644 crates/ghostty-font/testdata/U+2800...U+28FF-11x21+2.png create mode 100644 crates/ghostty-font/testdata/U+2800...U+28FF-12x24+3.png create mode 100644 crates/ghostty-font/testdata/U+2800...U+28FF-18x36+4.png create mode 100644 crates/ghostty-font/testdata/U+2800...U+28FF-9x17+1.png create mode 100644 crates/ghostty-font/testdata/U+E000...U+E0FF-11x21+2.png create mode 100644 crates/ghostty-font/testdata/U+E000...U+E0FF-12x24+3.png create mode 100644 crates/ghostty-font/testdata/U+E000...U+E0FF-18x36+4.png create mode 100644 crates/ghostty-font/testdata/U+E000...U+E0FF-9x17+1.png create mode 100644 crates/ghostty-font/testdata/U+F500...U+F5FF-11x21+2.png create mode 100644 crates/ghostty-font/testdata/U+F500...U+F5FF-12x24+3.png create mode 100644 crates/ghostty-font/testdata/U+F500...U+F5FF-18x36+4.png create mode 100644 crates/ghostty-font/testdata/U+F500...U+F5FF-9x17+1.png create mode 100644 crates/ghostty-font/testdata/U+F600...U+F6FF-11x21+2.png create mode 100644 crates/ghostty-font/testdata/U+F600...U+F6FF-12x24+3.png create mode 100644 crates/ghostty-font/testdata/U+F600...U+F6FF-18x36+4.png create mode 100644 crates/ghostty-font/testdata/U+F600...U+F6FF-9x17+1.png create mode 100644 docs/rewrite/notes/ghostty-font-W7.md diff --git a/Cargo.lock b/Cargo.lock index f3f5cad..14d0fec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -522,6 +522,7 @@ dependencies = [ "ghostty-vt", "log", "onig", + "thiserror", ] [[package]] @@ -600,8 +601,6 @@ dependencies = [ name = "ghostty-terminal" version = "0.1.0" dependencies = [ - "ghostty-datastruct", - "ghostty-unicode", "ghostty-vt", ] diff --git a/crates/ghostty-font/Cargo.toml b/crates/ghostty-font/Cargo.toml index 4597757..4229cc3 100644 --- a/crates/ghostty-font/Cargo.toml +++ b/crates/ghostty-font/Cargo.toml @@ -5,9 +5,10 @@ edition.workspace = true license.workspace = true [dependencies] -ghostty-config.workspace = true -ghostty-unicode.workspace = true -ghostty-terminal.workspace = true +tiny-skia = "0.12.0" +png = "0.18.1" +thiserror = "2.0.21" +log = "0.4.34" [lints] workspace = true diff --git a/crates/ghostty-font/src/atlas.rs b/crates/ghostty-font/src/atlas.rs new file mode 100644 index 0000000..aca4dd9 --- /dev/null +++ b/crates/ghostty-font/src/atlas.rs @@ -0,0 +1,553 @@ +//! Implements a texture atlas (). +//! Port of `font/Atlas.zig`. +//! +//! The implementation is based on "A Thousand Ways to Pack the Bin - A +//! Practical Approach to Two-Dimensional Rectangle Bin Packing" by Jukka +//! Jylänki. This specific implementation is based heavily on +//! Nicolas P. Rougier's freetype-gl project as well as Jukka's C++ +//! implementation: +//! +//! Limitations that are easy to fix, but I didn't need them: +//! +//! * Written data must be packed, no support for custom strides. +//! * Texture is always a square, no ability to set width != height. Note +//! that regions written INTO the atlas do not have to be square, only +//! the full atlas texture itself. + +use std::io::Write; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// The format of the texture data being written into the Atlas. This must be +/// uniform for all textures in the Atlas. If you have some textures with +/// different formats, you must use multiple atlases or convert the textures. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(u8)] +pub enum Format { + /// 1 byte per pixel grayscale. + #[default] + Grayscale = 0, + /// 3 bytes per pixel BGR. + Bgr = 1, + /// 4 bytes per pixel BGRA. + Bgra = 2, +} + +impl Format { + pub fn depth(self) -> u8 { + match self { + Format::Grayscale => 1, + Format::Bgr => 3, + Format::Bgra => 4, + } + } +} + +#[derive(Debug, Clone, Copy)] +struct Node { + x: u32, + y: u32, + width: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// Atlas cannot fit the desired region. You must enlarge the atlas. + #[error("atlas cannot fit the desired region")] + AtlasFull, +} + +/// A region within the texture atlas. These can be acquired using the +/// "reserve" function. A region reservation is required to write data. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Region { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, +} + +/// Number of nodes to preallocate in the list on init. +const NODE_PREALLOC: usize = 64; + +#[derive(Debug)] +pub struct Atlas { + /// Data is the raw texture data. + pub data: Vec, + + /// Width and height of the atlas texture. The current implementation is + /// always square so this is both the width and the height. + pub size: u32, + + /// The nodes (rectangles) of available space. + nodes: Vec, + + /// The format of the texture data being written into the Atlas. + pub format: Format, + + /// This will be incremented every time the atlas is modified. This is useful + /// for knowing if the texture data has changed since the last time it was + /// sent to the GPU. + pub modified: AtomicUsize, + + /// This will be incremented every time the atlas is resized. This is useful + /// for knowing if a GPU texture can be updated in-place or if it requires + /// a resize operation. + pub resized: AtomicUsize, +} + +impl Atlas { + pub fn new(size: u32, format: Format) -> Atlas { + let mut result = Atlas { + data: vec![0; size as usize * size as usize * format.depth() as usize], + size, + nodes: Vec::with_capacity(NODE_PREALLOC), + format, + modified: AtomicUsize::new(0), + resized: AtomicUsize::new(0), + }; + + // This sets up our initial state + result.clear(); + result + } + + /// Reserve a region within the atlas with the given width and height. + /// + /// This will not automatically enlarge the texture if it is full. + pub fn reserve(&mut self, width: u32, height: u32) -> Result { + // x, y are populated within the search below + let mut region = Region { + x: 0, + y: 0, + width, + height, + }; + + // If our width/height are 0, then we return the region as-is. This + // may seem like an error case but it simplifies downstream callers who + // might be trying to write empty data. + if width == 0 && height == 0 { + return Ok(region); + } + + // Find the location in our nodes list to insert the new node for this region. + let best_idx: usize = { + let mut best_height = u32::MAX; + let mut best_width = best_height; + let mut chosen: Option = None; + + for i in 0..self.nodes.len() { + // Check if our region fits within this node. + let Some(y) = self.fit(i, width, height) else { + continue; + }; + + let node = self.nodes[i]; + if (y + height) < best_height + || ((y + height) == best_height && (node.width > 0 && node.width < best_width)) + { + chosen = Some(i); + best_width = node.width; + best_height = y + height; + region.x = node.x; + region.y = y; + } + } + + // If we never found a chosen index, the atlas cannot fit our region. + chosen.ok_or(Error::AtlasFull)? + }; + + // Insert our new node for this rectangle at the exact best index + self.nodes.insert( + best_idx, + Node { + x: region.x, + y: region.y + height, + width, + }, + ); + + // Optimize our rectangles + let i = best_idx + 1; + while i < self.nodes.len() { + let prev = self.nodes[i - 1]; + let node = &mut self.nodes[i]; + if node.x < (prev.x + prev.width) { + let shrink = prev.x + prev.width - node.x; + node.x += shrink; + node.width = node.width.saturating_sub(shrink); + if node.width == 0 { + self.nodes.remove(i); + // Zig does `i -= 1; continue;` followed by `i += 1` in the + // while continue-expression, i.e. re-examines index i. + continue; + } + } + + break; + } + self.merge(); + + Ok(region) + } + + /// Attempts to fit a rectangle of width x height into the node at idx. + /// The return value is the y within the texture where the rectangle can be + /// placed. The x is the same as the node. + fn fit(&self, idx: usize, width: u32, height: u32) -> Option { + // If the added width exceeds our texture size, it doesn't fit. + let node = self.nodes[idx]; + if (node.x + width) > (self.size - 1) { + return None; + } + + // Go node by node looking for space that can fit our width. + let mut y = node.y; + let mut i = idx; + let mut width_left = width; + while width_left > 0 { + let n = self.nodes[i]; + if n.y > y { + y = n.y; + } + + // If the added height exceeds our texture size, it doesn't fit. + if (y + height) > (self.size - 1) { + return None; + } + + width_left = width_left.saturating_sub(n.width); + i += 1; + } + + Some(y) + } + + /// Merge adjacent nodes with the same y value. + fn merge(&mut self) { + let mut i = 0; + while i + 1 < self.nodes.len() { + let next = self.nodes[i + 1]; + let node = &mut self.nodes[i]; + if node.y == next.y { + node.width += next.width; + self.nodes.remove(i + 1); + continue; + } + + i += 1; + } + } + + /// Set the data associated with a reserved region. The data is expected + /// to fit exactly within the region. The data must be formatted with the + /// proper bpp configured on init. + pub fn set(&mut self, reg: Region, data: &[u8]) { + assert!(reg.x < (self.size - 1)); + assert!((reg.x + reg.width) <= (self.size - 1)); + assert!(reg.y < (self.size - 1)); + assert!((reg.y + reg.height) <= (self.size - 1)); + + let depth = self.format.depth() as usize; + let size = self.size as usize; + let row_len = reg.width as usize * depth; + for i in 0..reg.height as usize { + let tex_offset = ((reg.y as usize + i) * size + reg.x as usize) * depth; + let data_offset = i * row_len; + self.data[tex_offset..tex_offset + row_len] + .copy_from_slice(&data[data_offset..data_offset + row_len]); + } + + self.modified.fetch_add(1, Ordering::Relaxed); + } + + /// Like `set` but allows specifying a width for the source data and an + /// offset x and y, so that a section of a larger buffer may be copied + /// in to the atlas. + pub fn set_from_larger( + &mut self, + reg: Region, + src: &[u8], + src_width: u32, + src_x: u32, + src_y: u32, + ) { + assert!(reg.x < (self.size - 1)); + assert!((reg.x + reg.width) <= (self.size - 1)); + assert!(reg.y < (self.size - 1)); + assert!((reg.y + reg.height) <= (self.size - 1)); + + let depth = self.format.depth() as usize; + let size = self.size as usize; + let row_len = reg.width as usize * depth; + for i in 0..reg.height as usize { + let tex_offset = ((reg.y as usize + i) * size + reg.x as usize) * depth; + let src_offset = ((src_y as usize + i) * src_width as usize + src_x as usize) * depth; + self.data[tex_offset..tex_offset + row_len] + .copy_from_slice(&src[src_offset..src_offset + row_len]); + } + + self.modified.fetch_add(1, Ordering::Relaxed); + } + + /// Grow the texture to the new size, preserving all previously written data. + pub fn grow(&mut self, size_new: u32) { + assert!(size_new >= self.size); + if size_new == self.size { + return; + } + + let data_new = + vec![0u8; size_new as usize * size_new as usize * self.format.depth() as usize]; + + // Keep track of our old data so that we can copy it. + let data_old = std::mem::replace(&mut self.data, data_new); + let size_old = self.size; + + // Update our size to our new one. + self.size = size_new; + + // Copy the old data over (data is already zeroed). + self.set( + Region { + x: 0, // don't bother skipping border so we can avoid strides + y: 1, // skip the first border row + width: size_old, + height: size_old - 2, // skip the last border row + }, + &data_old[size_old as usize * self.format.depth() as usize..], + ); + + // Add the new rectangle for our added righthand space. + self.nodes.push(Node { + x: size_old - 1, + y: 1, + width: size_new - size_old, + }); + + // We are both modified and resized + self.modified.fetch_add(1, Ordering::Relaxed); + self.resized.fetch_add(1, Ordering::Relaxed); + } + + /// Empty the atlas. This doesn't reclaim any previously allocated memory. + pub fn clear(&mut self) { + self.modified.fetch_add(1, Ordering::Relaxed); + self.data.fill(0); + self.nodes.clear(); + + // Add our initial rectangle. This is the size of the full texture + // and is the initial rectangle we fit our regions in. We keep a 1px border + // to avoid artifacting when sampling the texture. + self.nodes.push(Node { + x: 1, + y: 1, + width: self.size - 2, + }); + } + + /// Dump the atlas as a PPM to a writer, for debug purposes. + /// Only supports grayscale and bgr atlases. + /// + /// NOTE: BGR atlases will have the red and blue channels + /// swapped because PPM expects RGB. + /// + /// # Panics + /// Panics for formats other than grayscale and bgr (as Zig does). + pub fn dump(&self, writer: &mut W) -> std::io::Result<()> { + let c = match self.format { + Format::Grayscale => '5', + Format::Bgr => '6', + f @ Format::Bgra => { + log::error!("Unsupported format for dump: {f:?}"); + panic!("Cannot dump this atlas format."); + } + }; + write!(writer, "P{c}\n{} {}\n255\n", self.size, self.size)?; + writer.write_all(&self.data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_fit() { + let mut atlas = Atlas::new(34, Format::Grayscale); // +2 for 1px border + + let modified = atlas.modified.load(Ordering::Relaxed); + atlas.reserve(32, 32).unwrap(); + assert_eq!(modified, atlas.modified.load(Ordering::Relaxed)); + assert_eq!(Err(Error::AtlasFull), atlas.reserve(1, 1)); + } + + #[test] + fn doesnt_fit() { + let mut atlas = Atlas::new(32, Format::Grayscale); + + // doesn't fit due to border + assert_eq!(Err(Error::AtlasFull), atlas.reserve(32, 32)); + } + + #[test] + fn fit_multiple() { + let mut atlas = Atlas::new(32, Format::Grayscale); + + atlas.reserve(15, 30).unwrap(); + atlas.reserve(15, 30).unwrap(); + assert_eq!(Err(Error::AtlasFull), atlas.reserve(1, 1)); + } + + #[test] + fn writing_data() { + let mut atlas = Atlas::new(32, Format::Grayscale); + + let reg = atlas.reserve(2, 2).unwrap(); + let old = atlas.modified.load(Ordering::Relaxed); + atlas.set(reg, &[1, 2, 3, 4]); + let new = atlas.modified.load(Ordering::Relaxed); + assert!(new > old); + + // 33 because of the 1px border and so on + assert_eq!(1, atlas.data[33]); + assert_eq!(2, atlas.data[34]); + assert_eq!(3, atlas.data[65]); + assert_eq!(4, atlas.data[66]); + } + + #[test] + fn writing_data_from_a_larger_source() { + let mut atlas = Atlas::new(32, Format::Grayscale); + + let reg = atlas.reserve(2, 2).unwrap(); + let old = atlas.modified.load(Ordering::Relaxed); + #[rustfmt::skip] + atlas.set_from_larger(reg, &[ + 8, 8, 8, 8, 8, + 8, 8, 1, 2, 8, + 8, 8, 3, 4, 8, + 8, 8, 8, 8, 8, + ], 5, 2, 1); + let new = atlas.modified.load(Ordering::Relaxed); + assert!(new > old); + + // 33 because of the 1px border and so on + assert_eq!(1, atlas.data[33]); + assert_eq!(2, atlas.data[34]); + assert_eq!(3, atlas.data[65]); + assert_eq!(4, atlas.data[66]); + + // None of the `8`s from the source data outside of the + // specified region should have made it on to the atlas. + assert!(!atlas.data.contains(&8)); + } + + #[test] + fn grow() { + let mut atlas = Atlas::new(4, Format::Grayscale); // +2 for 1px border + + let reg = atlas.reserve(2, 2).unwrap(); + assert_eq!(Err(Error::AtlasFull), atlas.reserve(1, 1)); + + // Write some data so we can verify that growing doesn't mess it up + atlas.set(reg, &[1, 2, 3, 4]); + assert_eq!(1, atlas.data[5]); + assert_eq!(2, atlas.data[6]); + assert_eq!(3, atlas.data[9]); + assert_eq!(4, atlas.data[10]); + + // Expand by exactly 1 should fit our new 1x1 block. + let old_modified = atlas.modified.load(Ordering::Relaxed); + let old_resized = atlas.resized.load(Ordering::Relaxed); + atlas.grow(atlas.size + 1); + let new_modified = atlas.modified.load(Ordering::Relaxed); + let new_resized = atlas.resized.load(Ordering::Relaxed); + assert!(new_modified > old_modified); + assert!(new_resized > old_resized); + atlas.reserve(1, 1).unwrap(); + + // Ensure our data is still set. Not the offsets change due to size. + let s = atlas.size as usize; + assert_eq!(1, atlas.data[s + 1]); + assert_eq!(2, atlas.data[s + 2]); + assert_eq!(3, atlas.data[s * 2 + 1]); + assert_eq!(4, atlas.data[s * 2 + 2]); + } + + #[test] + fn writing_bgr_data() { + let mut atlas = Atlas::new(32, Format::Bgr); + + // This is BGR so its 3 bpp + let reg = atlas.reserve(1, 2).unwrap(); + atlas.set(reg, &[1, 2, 3, 4, 5, 6]); + + // 33 because of the 1px border and so on + let depth = atlas.format.depth() as usize; + assert_eq!(1, atlas.data[33 * depth]); + assert_eq!(2, atlas.data[33 * depth + 1]); + assert_eq!(3, atlas.data[33 * depth + 2]); + assert_eq!(4, atlas.data[65 * depth]); + assert_eq!(5, atlas.data[65 * depth + 1]); + assert_eq!(6, atlas.data[65 * depth + 2]); + } + + #[test] + fn grow_bgr() { + // Atlas is 4x4 so its a 1px border meaning we only have 2x2 available + let mut atlas = Atlas::new(4, Format::Bgr); + + // Get our 2x2, which should be ALL our usable space + let reg = atlas.reserve(2, 2).unwrap(); + assert_eq!(Err(Error::AtlasFull), atlas.reserve(1, 1)); + + // This is BGR so its 3 bpp + #[rustfmt::skip] + atlas.set(reg, &[ + 10, 11, 12, // (0, 0) (x, y) from top-left + 13, 14, 15, // (1, 0) + 20, 21, 22, // (0, 1) + 23, 24, 25, // (1, 1) + ]); + + // Our top left skips the first row (size * depth) and the first + // column (depth) for the 1px border. + let depth = atlas.format.depth() as usize; + let check = |atlas: &Atlas| { + let mut tl = (atlas.size as usize * depth) + depth; + assert_eq!(&[10, 11, 12, 13, 14, 15, 0], &atlas.data[tl..tl + 7]); // 0 = border + tl += atlas.size as usize * depth; // next row + assert_eq!(&[20, 21, 22, 23, 24, 25, 0], &atlas.data[tl..tl + 7]); + }; + check(&atlas); + + // Expand by exactly 1 should fit our new 1x1 block. + atlas.grow(atlas.size + 1); + + // Data should be in same place accounting for the new size + check(&atlas); + + // Should fit the new blocks around the edges + atlas.reserve(1, 3).unwrap(); + atlas.reserve(2, 1).unwrap(); + assert_eq!(Err(Error::AtlasFull), atlas.reserve(1, 1)); + } + + #[test] + fn reserve_empty_region() { + let mut atlas = Atlas::new(32, Format::Grayscale); + let r = atlas.reserve(0, 0).unwrap(); + assert_eq!(Region::default(), r); + } + + #[test] + fn dump_ppm_header() { + let atlas = Atlas::new(4, Format::Grayscale); + let mut out = Vec::new(); + atlas.dump(&mut out).unwrap(); + assert!(out.starts_with(b"P5\n4 4\n255\n")); + assert_eq!(out.len(), 11 + 16); + } +} diff --git a/crates/ghostty-font/src/glyph.rs b/crates/ghostty-font/src/glyph.rs new file mode 100644 index 0000000..d5707a4 --- /dev/null +++ b/crates/ghostty-font/src/glyph.rs @@ -0,0 +1,21 @@ +//! Glyph is a single loaded glyph for a face. Port of `font/Glyph.zig`. + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Glyph { + /// width of glyph in pixels + pub width: u32, + + /// height of glyph in pixels + pub height: u32, + + /// left bearing + pub offset_x: i32, + + /// top bearing + pub offset_y: i32, + + /// coordinates in the atlas of the top-left corner. These have to + /// be normalized to be between 0 and 1 prior to use in shaders. + pub atlas_x: u32, + pub atlas_y: u32, +} diff --git a/crates/ghostty-font/src/lib.rs b/crates/ghostty-font/src/lib.rs index fe2d30b..ed5d362 100644 --- a/crates/ghostty-font/src/lib.rs +++ b/crates/ghostty-font/src/lib.rs @@ -1 +1,14 @@ -//! ghostty-font: see docs/rewrite/ARCHITECTURE.md for the module map. +//! Font engine. Currently contains the metrics, atlas, glyph and sprite-font +//! modules; the rest of the font engine (faces, shaping, collections, ...) is +//! ported later. + +#![cfg_attr(test, allow(clippy::unwrap_used))] + +pub mod atlas; +pub mod glyph; +pub mod metrics; +pub mod sprite; + +pub use atlas::Atlas; +pub use glyph::Glyph; +pub use metrics::Metrics; diff --git a/crates/ghostty-font/src/metrics.rs b/crates/ghostty-font/src/metrics.rs new file mode 100644 index 0000000..f22cbad --- /dev/null +++ b/crates/ghostty-font/src/metrics.rs @@ -0,0 +1,908 @@ +//! Font metrics for the terminal grid. Port of `font/Metrics.zig`. + +use std::collections::HashMap; +use std::str::FromStr; + +/// Metrics describing the terminal grid cell for a font. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Metrics { + /// Recommended cell width and height for a monospace grid using this font. + pub cell_width: u32, + pub cell_height: u32, + + /// Distance in pixels from the bottom of the cell to the text baseline. + pub cell_baseline: u32, + + /// Distance in pixels from the top of the cell to the top of the underline. + pub underline_position: u32, + /// Thickness in pixels of the underline. + pub underline_thickness: u32, + + /// Distance in pixels from the top of the cell to the top of the strikethrough. + pub strikethrough_position: u32, + /// Thickness in pixels of the strikethrough. + pub strikethrough_thickness: u32, + + /// Distance in pixels from the top of the cell to the top of the overline. + /// Can be negative to adjust the position above the top of the cell. + pub overline_position: i32, + /// Thickness in pixels of the overline. + pub overline_thickness: u32, + + /// Thickness in pixels of box drawing characters. + pub box_thickness: u32, + + /// The thickness in pixels of the cursor sprite. This has a default value + /// because it is not determined by fonts but rather by user configuration. + pub cursor_thickness: u32, + + /// The height in pixels of the cursor sprite. + pub cursor_height: u32, + + /// The constraint height for nerd fonts icons. + pub icon_height: f64, + + /// The constraint height for nerd fonts icons limited to a single cell width. + pub icon_height_single: f64, + + /// The unrounded face width, used in scaling calculations. + pub face_width: f64, + + /// The unrounded face height, used in scaling calculations. + pub face_height: f64, + + /// The offset from the bottom of the cell to the bottom + /// of the face's bounding box, based on the rounded and + /// potentially adjusted cell height. + pub face_y: f64, +} + +/// Minimum acceptable values for some fields to prevent modifiers +/// from being able to, for example, cause 0-thickness underlines. +mod minimums { + pub const CELL_WIDTH: u32 = 1; + pub const CELL_HEIGHT: u32 = 1; + pub const UNDERLINE_THICKNESS: u32 = 1; + pub const STRIKETHROUGH_THICKNESS: u32 = 1; + pub const OVERLINE_THICKNESS: u32 = 1; + pub const BOX_THICKNESS: u32 = 1; + pub const CURSOR_THICKNESS: u32 = 1; + pub const CURSOR_HEIGHT: u32 = 1; + pub const ICON_HEIGHT: f64 = 1.0; + pub const ICON_HEIGHT_SINGLE: f64 = 1.0; + pub const FACE_HEIGHT: f64 = 1.0; + pub const FACE_WIDTH: f64 = 1.0; +} + +/// Metrics extracted from a font face, based on +/// the metadata tables and glyph measurements. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct FaceMetrics { + /// Pixels per em, dividing the other values in this struct by this should + /// yield sizes in ems, to allow comparing metrics from faces of different + /// sizes. + pub px_per_em: f64, + + /// The minimum cell width that can contain any glyph in the ASCII range. + /// + /// Determined by measuring all printable glyphs in the ASCII range. + pub cell_width: f64, + + /// The typographic ascent metric from the font. + /// This represents the maximum vertical position of the highest ascender. + /// + /// Relative to the baseline, in px, +Y=up + pub ascent: f64, + + /// The typographic descent metric from the font. + /// This represents the minimum vertical position of the lowest descender. + /// + /// Relative to the baseline, in px, +Y=up + /// + /// Note: + /// As this value is generally below the baseline, it is typically negative. + pub descent: f64, + + /// The typographic line gap (aka "leading") metric from the font. + /// This represents the additional space to be added between lines in + /// addition to the space defined by the ascent and descent metrics. + /// + /// Positive value in px + pub line_gap: f64, + + /// The TOP of the underline stroke. + /// + /// Relative to the baseline, in px, +Y=up + pub underline_position: Option, + + /// The thickness of the underline stroke in px. + pub underline_thickness: Option, + + /// The TOP of the strikethrough stroke. + /// + /// Relative to the baseline, in px, +Y=up + pub strikethrough_position: Option, + + /// The thickness of the strikethrough stroke in px. + pub strikethrough_thickness: Option, + + /// The height of capital letters in the font, either derived from + /// a provided cap height metric or measured from the height of the + /// capital H glyph. + pub cap_height: Option, + + /// The height of lowercase letters in the font, either derived from + /// a provided ex height metric or measured from the height of the + /// lowercase x glyph. + pub ex_height: Option, + + /// The measured height of the bounding box containing all printable + /// ASCII characters. This can be different from ascent - descent for + /// two reasons: non-letter symbols like @ and $ often exceed the + /// the ascender and descender lines; and fonts often bake the line + /// gap into the ascent and descent metrics (as per, e.g., the Google + /// Fonts guidelines: https://simoncozens.github.io/gf-docs/metrics.html). + /// + /// Positive value in px + pub ascii_height: Option, + + /// The width of the character "水" (CJK water ideograph, U+6C34), + /// if present. This is used for font size adjustment, to normalize + /// the width of CJK fonts mixed with latin fonts. + /// + /// NOTE: IC = Ideograph Character + pub ic_width: Option, +} + +fn positive(v: Option) -> Option { + v.filter(|v| *v > 0.0) +} + +impl FaceMetrics { + /// Convenience function for getting the line height + /// (ascent - descent + line_gap). + #[inline] + pub fn line_height(&self) -> f64 { + self.ascent - self.descent + self.line_gap + } + + /// Convenience function for getting the cap height. If this is not + /// defined in the font, we estimate it as 75% of the ascent. + #[inline] + pub fn cap_height(&self) -> f64 { + positive(self.cap_height).unwrap_or(0.75 * self.ascent) + } + + /// Convenience function for getting the ex height. If this is not + /// defined in the font, we estimate it as 75% of the cap height. + #[inline] + pub fn ex_height(&self) -> f64 { + positive(self.ex_height).unwrap_or_else(|| 0.75 * self.cap_height()) + } + + /// Convenience function for getting the ASCII height. If we + /// couldn't measure this, we use 1.5 * cap_height as our + /// estimator, based on measurements across programming fonts. + #[inline] + pub fn ascii_height(&self) -> f64 { + positive(self.ascii_height).unwrap_or_else(|| 1.5 * self.cap_height()) + } + + /// Convenience function for getting the ideograph width. If this is + /// not defined in the font, we estimate it as the minimum of the + /// ascii height and two cell widths. + #[inline] + pub fn ic_width(&self) -> f64 { + positive(self.ic_width).unwrap_or_else(|| self.ascii_height().min(2.0 * self.cell_width)) + } + + /// Convenience function for getting the underline thickness. If + /// this is not defined in the font, we estimate it as 15% of the ex + /// height. + #[inline] + pub fn underline_thickness(&self) -> f64 { + positive(self.underline_thickness).unwrap_or_else(|| 0.15 * self.ex_height()) + } + + /// Convenience function for getting the strikethrough thickness. If + /// this is not defined in the font, we set it equal to the + /// underline thickness. + #[inline] + pub fn strikethrough_thickness(&self) -> f64 { + positive(self.strikethrough_thickness).unwrap_or_else(|| self.underline_thickness()) + } + + // NOTE: The getters below return positions, not sizes, so both + // positive and negative values are valid, hence no sign validation. + + /// Convenience function for getting the underline position. If + /// this is not defined in the font, we place it one underline + /// thickness below the baseline. + #[inline] + pub fn underline_position(&self) -> f64 { + self.underline_position + .unwrap_or_else(|| -self.underline_thickness()) + } + + /// Convenience function for getting the strikethrough position. If + /// this is not defined in the font, we center it at half the ex + /// height, so that it's perfectly centered on lower case text. + #[inline] + pub fn strikethrough_position(&self) -> f64 { + self.strikethrough_position + .unwrap_or_else(|| f64::midpoint(self.ex_height(), self.strikethrough_thickness())) + } +} + +/// Zig's `@round`: round half away from zero (same as `f64::round`). +#[inline] +fn zround(v: f64) -> f64 { + v.round() +} + +impl Metrics { + /// All-zero metrics, except `cursor_thickness` which defaults to 1. + /// (Zig's private `init()`; used by tests.) + #[cfg(test)] + fn init() -> Metrics { + Metrics { + cell_width: 0, + cell_height: 0, + cell_baseline: 0, + underline_position: 0, + underline_thickness: 0, + strikethrough_position: 0, + strikethrough_thickness: 0, + overline_position: 0, + overline_thickness: 0, + box_thickness: 0, + cursor_thickness: 1, + cursor_height: 0, + icon_height: 0.0, + icon_height_single: 0.0, + face_width: 0.0, + face_height: 0.0, + face_y: 0.0, + } + } + + /// Calculate our metrics based on values extracted from a font. + /// + /// Try to pass values with as much precision as possible, + /// do not round them before using them for this function. + /// + /// For any nullable options that are not provided, estimates will be used. + pub fn calc(face: FaceMetrics) -> Metrics { + // These are the unrounded advance width and line height values, + // which are retained separately from the rounded cell width and + // height values (below), for calculations that need to know how + // much error there is between the design dimensions of the font + // and the pixel dimensions of our cells. + let face_width = face.cell_width; + let face_height = face.line_height(); + + // The cell width and height values need to be integers since they + // represent pixel dimensions of the grid cells in the terminal. + // + // We use round for the cell width to limit the difference from + // the "true" width value to no more than 0.5px. This is a better + // approximation of the authorial intent of the font than ceiling + // would be, and makes the apparent spacing match better between + // low and high DPI displays. + // + // This does mean that it's possible for a glyph to overflow the + // edge of the cell by a pixel if it has no side bearings, but in + // reality such glyphs are generally meant to connect to adjacent + // glyphs in some way so it's not really an issue. + // + // The same is true for the height. Some fonts are poorly authored + // and have a descender on a normal glyph that extends right up to + // the descent value of the face, and this can result in the glyph + // overflowing the bottom of the cell by a pixel, which isn't good + // but if we try to prevent it by increasing the cell height then + // we get line heights that are too large for most users and even + // more inconsistent across DPIs. + // + // Users who experience such cell-height overflows should: + // + // 1. Nag the font author to either redesign the glyph to not go + // so low, or else adjust the descent value in the metadata. + // + // 2. Add an `adjust-cell-height` entry to their config to give + // the cell enough room for the glyph. + let cell_width = zround(face_width); + let cell_height = zround(face_height); + + // We split our line gap in two parts, and put half of it on the top + // of the cell and the other half on the bottom, so that our text never + // bumps up against either edge of the cell vertically. + let half_line_gap = face.line_gap / 2.0; + + // NOTE: Unlike all our other metrics, `cell_baseline` is + // relative to the BOTTOM of the cell rather than the top. + let face_baseline = half_line_gap - face.descent; + // We calculate the baseline by trying to center the face vertically + // in the pixel-rounded cell height, so that before rounding it will + // be an even distance from the top and bottom of the cell, meaning + // it either sticks out the same amount or is inset the same amount, + // depending on whether the cell height was rounded up or down from + // the line height. We do this by adding half the difference between + // the cell height and the face height. + let cell_baseline = zround(face_baseline - (cell_height - face_height) / 2.0); + + // We keep track of the offset from the bottom of the cell + // to the bottom of the face's "true" bounding box, which at + // this point, since nothing has been scaled yet, is equivalent + // to the offset between the baseline we draw at (cell_baseline) + // and the one the font wants (face_baseline). + let face_y = cell_baseline - face_baseline; + + // We calculate a top_to_baseline to make following calculations simpler. + let top_to_baseline = cell_height - cell_baseline; + + // Get the other font metrics or their estimates. See doc comments + // in FaceMetrics for explanations of the estimation heuristics. + let cap_height = face.cap_height(); + let underline_thickness = 1.0f64.max(face.underline_thickness().ceil()); + let strikethrough_thickness = 1.0f64.max(face.strikethrough_thickness().ceil()); + let underline_position = zround(top_to_baseline - face.underline_position()); + let strikethrough_position = zround(top_to_baseline - face.strikethrough_position()); + + // Same heuristic as the font_patcher script. We store icon_height + // separately from face_height such that modifiers can apply to the former + // without affecting the latter. + let icon_height = face_height; + let icon_height_single = (2.0 * cap_height + face_height) / 3.0; + + let mut result = 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: strikethrough_position as u32, + strikethrough_thickness: strikethrough_thickness as u32, + overline_position: 0, + overline_thickness: underline_thickness as u32, + box_thickness: underline_thickness as u32, + cursor_thickness: 1, + cursor_height: cell_height as u32, + icon_height, + icon_height_single, + face_width, + face_height, + face_y, + }; + + // Ensure all metrics are within their allowable range. + result.clamp(); + + result + } + + /// Apply a set of modifiers. + pub fn apply(&mut self, mods: &ModifierSet) { + // Zig iterates a hash map (unspecified order); we iterate in Key + // declaration order for determinism. + let mut entries: Vec<(Key, Modifier)> = mods.iter().map(|(k, v)| (*k, *v)).collect(); + entries.sort_by_key(|(k, _)| *k); + + for (key, modifier) in entries { + match key { + // We clamp these values to a minimum of 1 to prevent divide-by-zero + // in downstream operations. + Key::CellWidth | Key::CellHeight => { + let original = if key == Key::CellWidth { + self.cell_width + } else { + self.cell_height + }; + // Compute the new value. If it is the same avoid the work. + let new = modifier.apply_u32(original).max(1); + if new == original { + continue; + } + + if key == Key::CellWidth { + self.cell_width = new; + continue; + } + self.cell_height = new; + + // For cell height, we have to also modify some positions + // that are absolute from the top of the cell. The main goal + // here is to center the baseline so that text is vertically + // centered in the cell. + let original_f64 = f64::from(original); + let new_f64 = f64::from(new); + let diff = new_f64 - original_f64; + let half_diff = diff / 2.0; + + // If the diff is even, the number of pixels we add + // will be the same for the top and the bottom, but + // if the diff is odd then we want to add the extra + // pixel to the edge of the cell that needs it most. + // + // How much the edge "needs it" depends on whether + // the face is higher or lower than it should be to + // be perfectly centered in the cell. + // + // If the face were perfectly centered then face_y + // would be equal to half of the difference between + // the cell height and the face height. + let position_with_respect_to_center = + self.face_y - (original_f64 - self.face_height) / 2.0; + + let (diff_top, diff_bottom) = if position_with_respect_to_center > 0.0 { + // The baseline is higher than it should be, so we + // add the extra to the top, or if it's a negative + // diff it gets added to the bottom because of how + // floor and ceil work. + (half_diff.ceil(), half_diff.floor()) + } else { + // The baseline is lower than it should be, so we + // add the extra to the bottom, or vice versa for + // negative diffs. + (half_diff.floor(), half_diff.ceil()) + }; + + // The cell baseline and face_y values are relative to the + // bottom of the cell so we add the bottom diff to them. + add_float_to_int(&mut self.cell_baseline, diff_bottom); + self.face_y += diff_bottom; + + // These are all relative to the top of the cell. + add_float_to_int(&mut self.underline_position, diff_top); + add_float_to_int(&mut self.strikethrough_position, diff_top); + self.overline_position = self.overline_position.saturating_add(diff_top as i32); + } + Key::IconHeight => { + self.icon_height = modifier.apply_f64(self.icon_height); + self.icon_height_single = modifier.apply_f64(self.icon_height_single); + } + Key::CellBaseline => self.cell_baseline = modifier.apply_u32(self.cell_baseline), + Key::UnderlinePosition => { + self.underline_position = modifier.apply_u32(self.underline_position); + } + Key::UnderlineThickness => { + self.underline_thickness = modifier.apply_u32(self.underline_thickness); + } + Key::StrikethroughPosition => { + self.strikethrough_position = modifier.apply_u32(self.strikethrough_position); + } + Key::StrikethroughThickness => { + self.strikethrough_thickness = modifier.apply_u32(self.strikethrough_thickness); + } + Key::OverlinePosition => { + self.overline_position = modifier.apply_i32(self.overline_position); + } + Key::OverlineThickness => { + self.overline_thickness = modifier.apply_u32(self.overline_thickness); + } + Key::BoxThickness => self.box_thickness = modifier.apply_u32(self.box_thickness), + Key::CursorThickness => { + self.cursor_thickness = modifier.apply_u32(self.cursor_thickness); + } + Key::CursorHeight => self.cursor_height = modifier.apply_u32(self.cursor_height), + Key::IconHeightSingle => { + self.icon_height_single = modifier.apply_f64(self.icon_height_single); + } + Key::FaceWidth => self.face_width = modifier.apply_f64(self.face_width), + Key::FaceHeight => self.face_height = modifier.apply_f64(self.face_height), + Key::FaceY => self.face_y = modifier.apply_f64(self.face_y), + } + } + + // Prevent modifiers from pushing metrics out of their allowable range. + self.clamp(); + } + + /// Clamp all metrics to their allowable range. + fn clamp(&mut self) { + self.cell_width = self.cell_width.max(minimums::CELL_WIDTH); + self.cell_height = self.cell_height.max(minimums::CELL_HEIGHT); + self.underline_thickness = self.underline_thickness.max(minimums::UNDERLINE_THICKNESS); + self.strikethrough_thickness = self + .strikethrough_thickness + .max(minimums::STRIKETHROUGH_THICKNESS); + self.overline_thickness = self.overline_thickness.max(minimums::OVERLINE_THICKNESS); + self.box_thickness = self.box_thickness.max(minimums::BOX_THICKNESS); + self.cursor_thickness = self.cursor_thickness.max(minimums::CURSOR_THICKNESS); + self.cursor_height = self.cursor_height.max(minimums::CURSOR_HEIGHT); + self.icon_height = self.icon_height.max(minimums::ICON_HEIGHT); + self.icon_height_single = self.icon_height_single.max(minimums::ICON_HEIGHT_SINGLE); + self.face_height = self.face_height.max(minimums::FACE_HEIGHT); + self.face_width = self.face_width.max(minimums::FACE_WIDTH); + } +} + +/// Helper function for adding an f64 to a u32. +/// +/// Performs saturating addition or subtraction +/// depending on the sign of the provided float. +/// +/// The f64 is asserted to have an integer value. +#[inline] +fn add_float_to_int(int: &mut u32, float: f64) { + debug_assert!((float.floor() - float).abs() < f64::EPSILON); + *int = if float >= 0.0 { + int.saturating_add(float as u32) + } else { + int.saturating_sub((-float) as u32) + }; +} + +/// A set of modifiers to apply to metrics. We use a hash map because +/// we expect most metrics to be unmodified and want to take up as +/// little space as possible. +pub type ModifierSet = HashMap; + +/// Error returned by [`Modifier::parse`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ModifierParseError { + #[error("invalid format")] + InvalidFormat, + #[error("value required")] + ValueRequired, +} + +/// A modifier to apply to a metrics value. The modifier value represents +/// a delta, so percent is a percentage to change, not a percentage of. +/// For example, "20%" is 20% larger, not 20% of the value. Likewise, +/// an absolute value of "20" is 20 larger, not literally 20. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Modifier { + Percent(f64), + Absolute(i32), +} + +impl Modifier { + /// Parses the modifier value. If the value ends in "%" it is assumed + /// to be a percent, otherwise the value is parsed as an integer. + pub fn parse(input: &str) -> Result { + if input.is_empty() { + return Err(ModifierParseError::InvalidFormat); + } + + if let Some(num) = input.strip_suffix('%') { + let mut percent = parse_float(num).ok_or(ModifierParseError::InvalidFormat)?; + percent /= 100.0; + + if percent <= -1.0 { + return Ok(Modifier::Percent(0.0)); + } + if percent < 0.0 { + return Ok(Modifier::Percent(1.0 + percent)); + } + return Ok(Modifier::Percent(1.0 + percent)); + } + + parse_int_i32(input) + .map(Modifier::Absolute) + .ok_or(ModifierParseError::InvalidFormat) + } + + /// So it works with the config framework. + pub fn parse_cli(input: Option<&str>) -> Result { + Modifier::parse(input.ok_or(ModifierParseError::ValueRequired)?) + } + + /// Used by config formatter. Returns the value text of the config entry. + pub fn format_entry(&self) -> String { + match self { + Modifier::Percent(v) => format!("{}%", (v - 1.0) * 100.0), + Modifier::Absolute(v) => format!("{v}"), + } + } + + /// Apply a modifier to an unsigned 32-bit value. + pub fn apply_u32(&self, v: u32) -> u32 { + match *self { + Modifier::Percent(p) => { + let p_clamped = p.max(0.0); + (f64::from(v) * p_clamped).round() as u32 + } + Modifier::Absolute(abs) => { + let applied = i64::from(v).saturating_add(i64::from(abs)); + let clamped = applied.max(0); + u32::try_from(clamped).unwrap_or(u32::MAX) + } + } + } + + /// Apply a modifier to a signed 32-bit value. + pub fn apply_i32(&self, v: i32) -> i32 { + match *self { + Modifier::Percent(p) => { + let p_clamped = p.max(0.0); + (f64::from(v) * p_clamped).round() as i32 + } + Modifier::Absolute(abs) => { + let applied = i64::from(v).saturating_add(i64::from(abs)); + // Mirrors Zig: out-of-range yields maxInt * sign. + i32::try_from(applied).unwrap_or(if applied > 0 { i32::MAX } else { -i32::MAX }) + } + } + } + + /// Apply a modifier to a float value. + pub fn apply_f64(&self, v: f64) -> f64 { + match *self { + Modifier::Percent(p) => v * p.max(0.0), + Modifier::Absolute(abs) => v + f64::from(abs), + } + } +} + +impl FromStr for Modifier { + type Err = ModifierParseError; + fn from_str(s: &str) -> Result { + Modifier::parse(s) + } +} + +/// Zig `std.fmt.parseInt(i32, s, 10)`: optional sign, digits only +/// (underscores are only accepted with base 0, so not here). +fn parse_int_i32(s: &str) -> Option { + let digits = s.strip_prefix(['+', '-']).unwrap_or(s); + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse::().ok() +} + +/// Approximation of Zig `std.fmt.parseFloat(f64, s)` for decimal input. +fn parse_float(s: &str) -> Option { + if s.is_empty() || s.contains(char::is_whitespace) { + return None; + } + s.parse::().ok() +} + +/// Key is an enum of all the available metrics keys (every u32/i32/f64 +/// field of [`Metrics`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Key { + CellWidth, + CellHeight, + CellBaseline, + UnderlinePosition, + UnderlineThickness, + StrikethroughPosition, + StrikethroughThickness, + OverlinePosition, + OverlineThickness, + BoxThickness, + CursorThickness, + CursorHeight, + IconHeight, + IconHeightSingle, + FaceWidth, + FaceHeight, + FaceY, +} + +impl Key { + /// The snake_case field name, matching the Zig `@tagName`. + pub fn name(&self) -> &'static str { + match self { + Key::CellWidth => "cell_width", + Key::CellHeight => "cell_height", + Key::CellBaseline => "cell_baseline", + Key::UnderlinePosition => "underline_position", + Key::UnderlineThickness => "underline_thickness", + Key::StrikethroughPosition => "strikethrough_position", + Key::StrikethroughThickness => "strikethrough_thickness", + Key::OverlinePosition => "overline_position", + Key::OverlineThickness => "overline_thickness", + Key::BoxThickness => "box_thickness", + Key::CursorThickness => "cursor_thickness", + Key::CursorHeight => "cursor_height", + Key::IconHeight => "icon_height", + Key::IconHeightSingle => "icon_height_single", + Key::FaceWidth => "face_width", + Key::FaceHeight => "face_height", + Key::FaceY => "face_y", + } + } +} + +impl FromStr for Key { + type Err = (); + fn from_str(s: &str) -> Result { + Ok(match s { + "cell_width" => Key::CellWidth, + "cell_height" => Key::CellHeight, + "cell_baseline" => Key::CellBaseline, + "underline_position" => Key::UnderlinePosition, + "underline_thickness" => Key::UnderlineThickness, + "strikethrough_position" => Key::StrikethroughPosition, + "strikethrough_thickness" => Key::StrikethroughThickness, + "overline_position" => Key::OverlinePosition, + "overline_thickness" => Key::OverlineThickness, + "box_thickness" => Key::BoxThickness, + "cursor_thickness" => Key::CursorThickness, + "cursor_height" => Key::CursorHeight, + "icon_height" => Key::IconHeight, + "icon_height_single" => Key::IconHeightSingle, + "face_width" => Key::FaceWidth, + "face_height" => Key::FaceHeight, + "face_y" => Key::FaceY, + _ => return Err(()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formatconfig_percent() { + let p = Modifier::parse_cli(Some("24%")).unwrap(); + // Zig test checks the config-formatted line "a = 24%\n"; the config + // entry formatter lives in ghostty-config, so check the value text. + assert_eq!(p.format_entry(), "24%"); + } + + #[test] + fn formatconfig_absolute() { + let p = Modifier::parse_cli(Some("-30")).unwrap(); + assert_eq!(p.format_entry(), "-30"); + } + + #[test] + fn metrics_apply_modifiers() { + let mut set = ModifierSet::new(); + set.insert(Key::CellWidth, Modifier::Percent(1.2)); + + let mut m = Metrics::init(); + m.cell_width = 100; + m.apply(&set); + assert_eq!(120, m.cell_width); + } + + #[test] + fn metrics_adjust_cell_height_smaller() { + let mut set = ModifierSet::new(); + // We choose numbers such that the subtracted number of pixels is odd, + // as that's the case that could most easily have off-by-one errors. + // Here we're removing 25 pixels: 13 on the bottom, 12 on top, split + // that way because we're simulating a face that's 0.33px higher than + // it "should" be (due to rounding). + set.insert(Key::CellHeight, Modifier::Percent(0.75)); + + let mut m = Metrics::init(); + m.face_y = 0.33; + m.cell_baseline = 50; + m.underline_position = 55; + m.strikethrough_position = 30; + m.overline_position = 0; + m.cell_height = 100; + m.face_height = 99.67; + m.cursor_height = 100; + m.apply(&set); + assert_eq!(-12.67, m.face_y); + assert_eq!(75, m.cell_height); + assert_eq!(37, m.cell_baseline); + assert_eq!(43, m.underline_position); + assert_eq!(18, m.strikethrough_position); + assert_eq!(-12, m.overline_position); + // Cursor height is separate from cell height and does not follow it. + assert_eq!(100, m.cursor_height); + } + + #[test] + fn metrics_adjust_cell_height_larger() { + let mut set = ModifierSet::new(); + // We choose numbers such that the added number of pixels is odd, + // as that's the case that could most easily have off-by-one errors. + // Here we're adding 75 pixels: 37 on the bottom, 38 on top, split + // that way because we're simulating a face that's 0.33px higher + // than it "should" be (due to rounding). + set.insert(Key::CellHeight, Modifier::Percent(1.75)); + + let mut m = Metrics::init(); + m.face_y = 0.33; + m.cell_baseline = 50; + m.underline_position = 55; + m.strikethrough_position = 30; + m.overline_position = 0; + m.cell_height = 100; + m.face_height = 99.67; + m.cursor_height = 100; + m.apply(&set); + assert_eq!(37.33, m.face_y); + assert_eq!(175, m.cell_height); + assert_eq!(87, m.cell_baseline); + assert_eq!(93, m.underline_position); + assert_eq!(68, m.strikethrough_position); + assert_eq!(38, m.overline_position); + // Cursor height is separate from cell height and does not follow it. + assert_eq!(100, m.cursor_height); + } + + #[test] + fn metrics_adjust_icon_height_by_percentage() { + let mut set = ModifierSet::new(); + set.insert(Key::IconHeight, Modifier::Percent(0.75)); + + let mut m = Metrics::init(); + m.icon_height = 100.0; + m.icon_height_single = 80.0; + m.face_height = 100.0; + m.face_y = 1.0; + m.apply(&set); + assert_eq!(75.0, m.icon_height); + assert_eq!(60.0, m.icon_height_single); + // Face metrics not affected + assert_eq!(100.0, m.face_height); + assert_eq!(1.0, m.face_y); + } + + #[test] + fn metrics_adjust_icon_height_by_absolute_pixels() { + let mut set = ModifierSet::new(); + set.insert(Key::IconHeight, Modifier::Absolute(-5)); + + let mut m = Metrics::init(); + m.icon_height = 100.0; + m.icon_height_single = 80.0; + m.face_height = 100.0; + m.face_y = 1.0; + m.apply(&set); + assert_eq!(95.0, m.icon_height); + assert_eq!(75.0, m.icon_height_single); + // Face metrics not affected + assert_eq!(100.0, m.face_height); + assert_eq!(1.0, m.face_y); + } + + #[test] + fn modifier_parse_absolute() { + assert_eq!(Modifier::Absolute(100), Modifier::parse("100").unwrap()); + assert_eq!(Modifier::Absolute(-100), Modifier::parse("-100").unwrap()); + } + + #[test] + fn modifier_parse_percent() { + assert_eq!(Modifier::Percent(1.2), Modifier::parse("20%").unwrap()); + assert_eq!(Modifier::Percent(0.8), Modifier::parse("-20%").unwrap()); + assert_eq!(Modifier::Percent(1.0), Modifier::parse("0%").unwrap()); + } + + #[test] + fn modifier_parse_errors() { + assert_eq!(Err(ModifierParseError::InvalidFormat), Modifier::parse("")); + assert_eq!( + Err(ModifierParseError::InvalidFormat), + Modifier::parse("abc") + ); + assert_eq!(Err(ModifierParseError::InvalidFormat), Modifier::parse("%")); + assert_eq!(Ok(Modifier::Percent(0.0)), Modifier::parse("-150%")); + assert_eq!( + Err(ModifierParseError::ValueRequired), + Modifier::parse_cli(None) + ); + } + + #[test] + fn modifier_percent() { + assert_eq!(80, Modifier::Percent(0.8).apply_u32(100)); + assert_eq!(180, Modifier::Percent(1.8).apply_u32(100)); + } + + #[test] + fn modifier_absolute() { + assert_eq!(0, Modifier::Absolute(-100).apply_u32(100)); + assert_eq!(0, Modifier::Absolute(-120).apply_u32(100)); + assert_eq!(200, Modifier::Absolute(100).apply_u32(100)); + } + + #[test] + fn key_names_roundtrip() { + for k in [Key::CellWidth, Key::FaceY, Key::IconHeightSingle] { + assert_eq!(Ok(k), k.name().parse::()); + } + } +} diff --git a/crates/ghostty-font/src/sprite/canvas.rs b/crates/ghostty-font/src/sprite/canvas.rs new file mode 100644 index 0000000..e06fa51 --- /dev/null +++ b/crates/ghostty-font/src/sprite/canvas.rs @@ -0,0 +1,787 @@ +//! This exposes primitives to draw 2D graphics and export the graphic to +//! a font atlas. Port of `font/sprite/canvas.zig`. +//! +//! Ghostty uses the `z2d` library for antialiased path drawing; this port +//! uses `tiny-skia` to rasterize path coverage (see [`Canvas::fill_path`]). + +use std::ops::Sub; + +use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform}; + +use crate::atlas::{self, Atlas, Format, Region}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Point { + pub x: T, + pub y: T, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Line { + pub p0: Point, + pub p1: Point, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Box { + pub p0: Point, + pub p1: Point, +} + +impl> Box { + pub fn rect(&self) -> Rect { + let min = |a: T, b: T| if b < a { b } else { a }; + let max = |a: T, b: T| if b > a { b } else { a }; + let tl_x = min(self.p0.x, self.p1.x); + let tl_y = min(self.p0.y, self.p1.y); + let br_x = max(self.p0.x, self.p1.x); + let br_y = max(self.p0.y, self.p1.y); + + Rect { + x: tl_x, + y: tl_y, + width: br_x - tl_x, + height: br_y - tl_y, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Rect { + pub x: T, + pub y: T, + pub width: T, + pub height: T, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Triangle { + pub p0: Point, + pub p1: Point, + pub p2: Point, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Quad { + pub p0: Point, + pub p1: Point, + pub p2: Point, + pub p3: Point, +} + +/// We only use alpha-channel so a pixel is an 8-bit alpha value. Zig models +/// this as a non-exhaustive `enum(u8)`; `ON` = 255, `OFF` = 0, and any other +/// value is a partial alpha (used for shades). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Color(pub u8); + +impl Color { + pub const ON: Color = Color(255); + pub const OFF: Color = Color(0); +} + +/// Errors that drawing functions can produce. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DrawError { + /// Something went wrong while doing math. + #[error("math error")] + MathError, + #[error("atlas error: {0}")] + Atlas(#[from] atlas::Error), + #[error("path error")] + Path, +} + +/// A node of a [`Path`]. +#[derive(Debug, Clone, Copy, PartialEq)] +enum PathNode { + MoveTo(f64, f64), + LineTo(f64, f64), + CurveTo(f64, f64, f64, f64, f64, f64), + ClosePath, +} + +/// A path in cell-relative coordinates (the canvas applies the padding +/// translation when the path is filled or stroked, like z2d's +/// `staticPath` transformation). +#[derive(Debug, Clone, Default)] +pub struct Path { + nodes: Vec, + current: Option<(f64, f64)>, + start: Option<(f64, f64)>, +} + +impl Path { + pub fn new() -> Path { + Path::default() + } + + pub fn move_to(&mut self, x: f64, y: f64) { + self.nodes.push(PathNode::MoveTo(x, y)); + self.current = Some((x, y)); + self.start = Some((x, y)); + } + + pub fn line_to(&mut self, x: f64, y: f64) { + if self.current.is_none() { + self.move_to(x, y); + return; + } + self.nodes.push(PathNode::LineTo(x, y)); + self.current = Some((x, y)); + } + + pub fn curve_to(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) { + if self.current.is_none() { + self.move_to(x1, y1); + } + self.nodes.push(PathNode::CurveTo(x1, y1, x2, y2, x3, y3)); + self.current = Some((x3, y3)); + } + + /// Close the current subpath. Like z2d, the current point returns to + /// the start of the subpath. + pub fn close(&mut self) { + if self.current.is_none() { + return; + } + self.nodes.push(PathNode::ClosePath); + self.current = self.start; + } + + /// Add a circular arc centered at `(x, y)` from angle `a0` to `a1` + /// (radians, clockwise in canvas space with +y down). If there is a + /// current point, a line is drawn to the start of the arc, otherwise + /// the arc starts a new subpath. + pub fn arc(&mut self, x: f64, y: f64, radius: f64, a0: f64, a1: f64) { + use std::f64::consts::FRAC_PI_2; + + let (sx, sy) = (x + radius * a0.cos(), y + radius * a0.sin()); + if self.current.is_some() { + self.line_to(sx, sy); + } else { + self.move_to(sx, sy); + } + + let mut sweep = a1 - a0; + // Split into segments of at most a quarter turn. + let segs = ((sweep.abs() / FRAC_PI_2).ceil() as usize).max(1); + sweep /= segs as f64; + let k = 4.0 / 3.0 * (sweep / 4.0).tan(); + let mut a = a0; + for _ in 0..segs { + let (s0, c0) = a.sin_cos(); + let (s1, c1) = (a + sweep).sin_cos(); + self.curve_to( + x + radius * (c0 - k * s0), + y + radius * (s0 + k * c0), + x + radius * (c1 + k * s1), + y + radius * (s1 - k * c1), + x + radius * c1, + y + radius * s1, + ); + a += sweep; + } + } + + fn to_skia(&self, dx: f64, dy: f64) -> Option { + let mut pb = PathBuilder::new(); + let f = |v: f64, d: f64| (v + d) as f32; + for n in &self.nodes { + match *n { + PathNode::MoveTo(x, y) => pb.move_to(f(x, dx), f(y, dy)), + PathNode::LineTo(x, y) => pb.line_to(f(x, dx), f(y, dy)), + PathNode::CurveTo(x1, y1, x2, y2, x3, y3) => pb.cubic_to( + f(x1, dx), + f(y1, dy), + f(x2, dx), + f(y2, dy), + f(x3, dx), + f(y3, dy), + ), + PathNode::ClosePath => pb.close(), + } + } + pb.finish() + } +} + +/// Line cap modes (subset of z2d's). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CapMode { + #[default] + Butt, + Round, + Square, +} + +/// Stroke options (subset of z2d's `StrokeOptions`). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StrokeOptions { + pub line_width: f64, + pub line_cap_mode: CapMode, +} + +impl Default for StrokeOptions { + fn default() -> Self { + StrokeOptions { + line_width: 2.0, + line_cap_mode: CapMode::Butt, + } + } +} + +/// The canvas is an 8-bit alpha surface with padding on all sides, onto +/// which sprite glyphs are drawn. +#[derive(Debug)] +pub struct Canvas { + /// The underlying alpha8 surface, row-major, `sfc_width * sfc_height`. + pub sfc: Vec, + pub sfc_width: u32, + pub sfc_height: u32, + + pub padding_x: u32, + pub padding_y: u32, + + pub clip_top: u32, + pub clip_left: u32, + pub clip_right: u32, + pub clip_bottom: u32, + + /// Scratch pixmap used to rasterize path coverage. + scratch: Pixmap, +} + +impl Canvas { + pub fn new(width: u32, height: u32, padding_x: u32, padding_y: u32) -> Canvas { + // We add padding to both sides (hence `2 *`) + let w = width + 2 * padding_x; + let h = height + 2 * padding_y; + Canvas { + sfc: vec![0; w as usize * h as usize], + sfc_width: w, + sfc_height: h, + padding_x, + padding_y, + clip_top: 0, + clip_left: 0, + clip_right: 0, + clip_bottom: 0, + // tiny-skia can't make zero sized pixmaps; sprites are never empty + // but be defensive. + scratch: Pixmap::new(w.max(1), h.max(1)).expect("non-zero pixmap size"), + } + } + + /// Write the data in this drawing to the atlas. + pub fn write_atlas(&mut self, atlas: &mut Atlas) -> Result { + assert_eq!(atlas.format, Format::Grayscale); + + self.trim(); + + let sfc_width = self.sfc_width; + let sfc_height = self.sfc_height; + + // Subtract our clip margins from the + // width and height to get region size. + let region_width = sfc_width + .saturating_sub(self.clip_left) + .saturating_sub(self.clip_right); + let region_height = sfc_height + .saturating_sub(self.clip_top) + .saturating_sub(self.clip_bottom); + + // Allocate our texture atlas region + let region = atlas.reserve(region_width, region_height)?; + + if region.width > 0 && region.height > 0 { + // Write the glyph information into the atlas + assert_eq!(region.width, region_width); + assert_eq!(region.height, region_height); + atlas.set_from_larger(region, &self.sfc, sfc_width, self.clip_left, self.clip_top); + } + + Ok(region) + } + + /// Adjust clip boundaries to trim off any fully transparent rows or columns. + fn trim(&mut self) { + let width = self.sfc_width; + let height = self.sfc_height; + let buf = &self.sfc; + let px = |x: u32, y: u32| buf[y as usize * width as usize + x as usize]; + + while self.clip_top < height - self.clip_bottom { + let y = self.clip_top; + let x0 = self.clip_left; + let x1 = width - self.clip_right; + if (x0..x1).any(|x| px(x, y) != 0) { + break; + } + self.clip_top += 1; + } + + while self.clip_bottom < height - self.clip_top { + let y = (height - self.clip_bottom).saturating_sub(1); + let x0 = self.clip_left; + let x1 = width - self.clip_right; + if (x0..x1).any(|x| px(x, y) != 0) { + break; + } + self.clip_bottom += 1; + } + + while self.clip_left < width - self.clip_right { + let x = self.clip_left; + let y0 = self.clip_top; + let y1 = height - self.clip_bottom; + if (y0..y1).any(|y| px(x, y) != 0) { + break; + } + self.clip_left += 1; + } + + while self.clip_right < width - self.clip_left { + let x = (width - self.clip_right).saturating_sub(1); + let y0 = self.clip_top; + let y1 = height - self.clip_bottom; + if (y0..y1).any(|y| px(x, y) != 0) { + break; + } + self.clip_right += 1; + } + } + + /// Only really useful for test purposes, since the clipping region is + /// automatically excluded when writing to an atlas with `write_atlas`. + pub fn clear_clipping_regions(&mut self) { + let width = self.sfc_width as usize; + let height = self.sfc_height as usize; + + for y in 0..height { + for x in 0..self.clip_left as usize { + self.sfc[y * width + x] = 0; + } + } + + for y in 0..height { + for x in (width - self.clip_right as usize)..width { + self.sfc[y * width + x] = 0; + } + } + + for y in 0..self.clip_top as usize { + for x in 0..width { + self.sfc[y * width + x] = 0; + } + } + + for y in (height - self.clip_bottom as usize)..height { + for x in 0..width { + self.sfc[y * width + x] = 0; + } + } + } + + /// Reset the canvas (pixels and clip margins) for reuse. Used by tests + /// that render many glyphs onto a single canvas. + pub fn reset(&mut self) { + self.sfc.fill(0); + self.clip_top = 0; + self.clip_left = 0; + self.clip_right = 0; + self.clip_bottom = 0; + } + + /// Draw and fill a single pixel (out-of-bounds pixels are ignored). + pub fn pixel(&mut self, x: i32, y: i32, color: Color) { + let x = i64::from(x) + i64::from(self.padding_x); + let y = i64::from(y) + i64::from(self.padding_y); + if x < 0 || y < 0 || x >= i64::from(self.sfc_width) || y >= i64::from(self.sfc_height) { + return; + } + self.sfc[y as usize * self.sfc_width as usize + x as usize] = color.0; + } + + /// Draw and fill a rectangle. This is the main primitive for drawing + /// lines as well (which are just generally skinny rectangles...) + pub fn rect(&mut self, v: Rect, color: Color) { + let mut y = v.y; + while y < v.y + v.height { + let mut x = v.x; + while x < v.x + v.width { + self.pixel(x, y, color); + x += 1; + } + y += 1; + } + } + + /// Convenience wrapper for `Canvas::rect` + pub fn box_(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: Color) { + self.rect( + Box { + p0: Point { x: x0, y: y0 }, + p1: Point { x: x1, y: y1 }, + } + .rect(), + color, + ); + } + + /// Draw and fill a quad. + pub fn quad(&mut self, q: Quad, color: Color) -> Result<(), DrawError> { + let mut path = Path::new(); + path.move_to(q.p0.x, q.p0.y); + path.line_to(q.p1.x, q.p1.y); + path.line_to(q.p2.x, q.p2.y); + path.line_to(q.p3.x, q.p3.y); + path.close(); + self.fill_path(&path, color) + } + + /// Draw and fill a triangle. + pub fn triangle(&mut self, t: Triangle, color: Color) -> Result<(), DrawError> { + let mut path = Path::new(); + path.move_to(t.p0.x, t.p0.y); + path.line_to(t.p1.x, t.p1.y); + path.line_to(t.p2.x, t.p2.y); + path.close(); + self.fill_path(&path, color) + } + + /// Stroke a line. + pub fn line(&mut self, l: Line, thickness: f64, color: Color) -> Result<(), DrawError> { + let mut path = Path::new(); + path.move_to(l.p0.x, l.p0.y); + path.line_to(l.p1.x, l.p1.y); + self.stroke_path( + &path, + StrokeOptions { + line_cap_mode: CapMode::Butt, + line_width: thickness, + }, + color, + ) + } + + /// Rasterize `path` (filled or stroked) into the scratch pixmap and + /// return nothing; the coverage is then in `self.scratch`'s alpha. + fn rasterize(&mut self, path: &Path, stroke: Option) -> bool { + self.scratch.fill(tiny_skia::Color::TRANSPARENT); + let Some(p) = path.to_skia(0.0, 0.0) else { + return false; + }; + let mut paint = Paint::default(); + paint.set_color_rgba8(0, 0, 0, 255); + paint.anti_alias = true; + let ts = Transform::from_translate(self.padding_x as f32, self.padding_y as f32); + match stroke { + None => { + self.scratch + .fill_path(&p, &paint, FillRule::Winding, ts, None); + } + Some(opts) => { + let s = Stroke { + width: opts.line_width as f32, + miter_limit: 10.0, + line_cap: match opts.line_cap_mode { + CapMode::Butt => LineCap::Butt, + CapMode::Round => LineCap::Round, + CapMode::Square => LineCap::Square, + }, + line_join: LineJoin::Miter, + dash: None, + }; + self.scratch.stroke_path(&p, &paint, &s, ts, None); + } + } + true + } + + /// Composite the scratch coverage onto the canvas using `src_over` + /// with an opaque pattern of alpha `color`. + fn composite_scratch(&mut self, color: Color) { + let a = u32::from(color.0); + for (dst, px) in self + .sfc + .iter_mut() + .zip(self.scratch.data().as_chunks::<4>().0.iter()) + { + let cov = u32::from(px[3]); + if cov == 0 { + continue; + } + let src = (cov * a + 127) / 255; + let d = u32::from(*dst); + *dst = (src + (d * (255 - src) + 127) / 255).min(255) as u8; + } + } + + /// Stroke a path. + pub fn stroke_path( + &mut self, + path: &Path, + opts: StrokeOptions, + color: Color, + ) -> Result<(), DrawError> { + if self.rasterize(path, Some(opts)) { + self.composite_scratch(color); + } + Ok(()) + } + + /// Do an inner stroke on a path: the shape is filled to make a mask, + /// then stroked with double width, and the two are multiplied so only + /// the part of the stroke inside the shape remains. + pub fn inner_stroke_path( + &mut self, + path: &Path, + opts: StrokeOptions, + color: Color, + ) -> Result<(), DrawError> { + // Make a closed version of the path for our fill, so + // that we can support open paths for inner stroke. + let mut closed = path.clone(); + closed.close(); + + let mut fill = vec![0u8; self.sfc.len()]; + if self.rasterize(&closed, None) { + for (d, px) in fill + .iter_mut() + .zip(self.scratch.data().as_chunks::<4>().0.iter()) + { + *d = px[3]; + } + } + + // Stroke the shape with double the desired width. + let mut stroke = vec![0u8; self.sfc.len()]; + let mut mut_opts = opts; + mut_opts.line_width *= 2.0; + if self.rasterize(path, Some(mut_opts)) { + let a = u32::from(color.0); + for (d, px) in stroke + .iter_mut() + .zip(self.scratch.data().as_chunks::<4>().0.iter()) + { + *d = ((u32::from(px[3]) * a + 127) / 255) as u8; + } + } + + // Multiply the stroke onto the fill, then composite over the canvas. + for ((dst, f), s) in self.sfc.iter_mut().zip(&fill).zip(&stroke) { + let m = (255.0 * (f64::from(*s) / 255.0) * (f64::from(*f) / 255.0)).round() as u32; + let d = u32::from(*dst); + *dst = (m + (d * (255 - m) + 127) / 255).min(255) as u8; + } + Ok(()) + } + + /// Fill a path (non-zero winding). + pub fn fill_path(&mut self, path: &Path, color: Color) -> Result<(), DrawError> { + if self.rasterize(path, None) { + self.composite_scratch(color); + } + Ok(()) + } + + /// Invert all pixels on the canvas. + pub fn invert(&mut self) { + for v in &mut self.sfc { + *v = 255 - *v; + } + } + + /// Mirror the canvas horizontally. + pub fn flip_horizontal(&mut self) { + let width = self.sfc_width as usize; + for row in self.sfc.chunks_exact_mut(width) { + row.reverse(); + } + std::mem::swap(&mut self.clip_left, &mut self.clip_right); + } + + /// Mirror the canvas vertically. + pub fn flip_vertical(&mut self) { + let width = self.sfc_width as usize; + let height = self.sfc_height as usize; + for y in 0..height / 2 { + let (top, bottom) = self.sfc.split_at_mut((height - y - 1) * width); + top[y * width..(y + 1) * width].swap_with_slice(&mut bottom[..width]); + } + std::mem::swap(&mut self.clip_top, &mut self.clip_bottom); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(c: &Canvas, x: u32, y: u32) -> u8 { + c.sfc[y as usize * c.sfc_width as usize + x as usize] + } + + #[test] + fn box_rect_normalizes() { + let b = Box { + p0: Point { x: 5, y: 9 }, + p1: Point { x: 2, y: 3 }, + }; + assert_eq!( + Rect { + x: 2, + y: 3, + width: 3, + height: 6 + }, + b.rect() + ); + } + + #[test] + fn rect_and_padding() { + let mut c = Canvas::new(4, 4, 1, 1); + assert_eq!((6, 6), (c.sfc_width, c.sfc_height)); + c.rect( + Rect { + x: 0, + y: 0, + width: 2, + height: 1, + }, + Color::ON, + ); + assert_eq!(255, at(&c, 1, 1)); + assert_eq!(255, at(&c, 2, 1)); + assert_eq!(0, at(&c, 3, 1)); + assert_eq!(0, at(&c, 1, 2)); + // out of bounds is ignored + c.rect( + Rect { + x: -10, + y: -10, + width: 100, + height: 100, + }, + Color::OFF, + ); + assert!(c.sfc.iter().all(|v| *v == 0)); + } + + #[test] + fn trim_and_write_atlas() { + let mut c = Canvas::new(8, 8, 2, 2); + c.rect( + Rect { + x: 1, + y: 2, + width: 3, + height: 2, + }, + Color::ON, + ); + let mut atlas = Atlas::new(32, Format::Grayscale); + let r = c.write_atlas(&mut atlas).unwrap(); + assert_eq!((3, 2), (r.width, r.height)); + assert_eq!(2 + 1, c.clip_left); + assert_eq!(2 + 2, c.clip_top); + assert_eq!(atlas.data[(r.y * 32 + r.x) as usize], 255); + } + + #[test] + fn empty_canvas_writes_empty_region() { + let mut c = Canvas::new(8, 8, 2, 2); + let mut atlas = Atlas::new(32, Format::Grayscale); + let r = c.write_atlas(&mut atlas).unwrap(); + assert_eq!((0, 0), (r.width, r.height)); + } + + #[test] + fn flips() { + let mut c = Canvas::new(4, 2, 0, 0); + c.pixel(0, 0, Color::ON); + c.flip_horizontal(); + assert_eq!(255, at(&c, 3, 0)); + c.flip_vertical(); + assert_eq!(255, at(&c, 3, 1)); + } + + #[test] + fn invert_all() { + let mut c = Canvas::new(2, 2, 0, 0); + c.pixel(0, 0, Color::ON); + c.invert(); + assert_eq!(0, at(&c, 0, 0)); + assert_eq!(255, at(&c, 1, 1)); + } + + #[test] + fn fill_triangle_covers_interior() { + let mut c = Canvas::new(20, 20, 5, 5); + c.triangle( + Triangle { + p0: Point { x: 0.0, y: 0.0 }, + p1: Point { x: 20.0, y: 0.0 }, + p2: Point { x: 0.0, y: 20.0 }, + }, + Color::ON, + ) + .unwrap(); + assert_eq!(255, at(&c, 5 + 2, 5 + 2)); + assert_eq!(0, at(&c, 5 + 18, 5 + 18)); + } + + #[test] + fn stroke_line_thickness() { + let mut c = Canvas::new(20, 20, 5, 5); + c.line( + Line { + p0: Point { x: 0.0, y: 10.0 }, + p1: Point { x: 20.0, y: 10.0 }, + }, + 4.0, + Color::ON, + ) + .unwrap(); + // rows 8..12 inclusive of padding offset + assert_eq!(255, at(&c, 15, 5 + 8)); + assert_eq!(255, at(&c, 15, 5 + 11)); + assert_eq!(0, at(&c, 15, 5 + 7)); + assert_eq!(0, at(&c, 15, 5 + 12)); + } + + #[test] + fn arc_full_circle_fills() { + let mut c = Canvas::new(20, 20, 0, 0); + let mut p = Path::new(); + p.arc(10.0, 10.0, 8.0, 0.0, std::f64::consts::TAU); + p.close(); + c.fill_path(&p, Color::ON).unwrap(); + assert_eq!(255, at(&c, 10, 10)); + assert_eq!(0, at(&c, 0, 0)); + } + + #[test] + fn inner_stroke_stays_inside() { + let mut c = Canvas::new(20, 20, 0, 0); + let mut p = Path::new(); + p.move_to(2.0, 2.0); + p.line_to(18.0, 2.0); + p.line_to(18.0, 18.0); + p.line_to(2.0, 18.0); + p.close(); + c.inner_stroke_path( + &p, + StrokeOptions { + line_width: 2.0, + ..Default::default() + }, + Color::ON, + ) + .unwrap(); + assert_eq!(0, at(&c, 1, 10)); // outside + assert_eq!(255, at(&c, 3, 10)); // inside stroke + assert_eq!(0, at(&c, 10, 10)); // interior + } +} diff --git a/crates/ghostty-font/src/sprite/draw/block.rs b/crates/ghostty-font/src/sprite/draw/block.rs new file mode 100644 index 0000000..e44b766 --- /dev/null +++ b/crates/ghostty-font/src/sprite/draw/block.rs @@ -0,0 +1,172 @@ +//! Block Elements | U+2580...U+259F +//! +//! +//! ▀▁▂▃▄▅▆▇█▉▊▋▌▍▎▏ +//! ▐░▒▓▔▕▖▗▘▙▚▛▜▝▞▟ +//! +//! Port of `sprite/draw/block.zig`. + +use super::common::{ + Alignment, DrawFn, FIVE_EIGHTHS, Fraction, HALF, HAlign, ONE_EIGHTH, ONE_QUARTER, Quads, Range, + SEVEN_EIGHTHS, Shade, THREE_EIGHTHS, THREE_QUARTERS, VAlign, fill, +}; +use crate::metrics::Metrics; +use crate::sprite::canvas::{Canvas, Color, DrawError, Rect}; + +pub const RANGES: &[Range] = &[Range { + min: 0x2580, + max: 0x259f, + draw: draw2580_259f as DrawFn, +}]; + +pub fn draw2580_259f( + cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + let q = |tl, tr, bl, br| Quads { tl, tr, bl, br }; + match cp { + // '▀' UPPER HALF BLOCK + 0x2580 => block(metrics, canvas, Alignment::UPPER, 1.0, HALF), + // '▁' LOWER ONE EIGHTH BLOCK + 0x2581 => block(metrics, canvas, Alignment::LOWER, 1.0, ONE_EIGHTH), + // '▂' LOWER ONE QUARTER BLOCK + 0x2582 => block(metrics, canvas, Alignment::LOWER, 1.0, ONE_QUARTER), + // '▃' LOWER THREE EIGHTHS BLOCK + 0x2583 => block(metrics, canvas, Alignment::LOWER, 1.0, THREE_EIGHTHS), + // '▄' LOWER HALF BLOCK + 0x2584 => block(metrics, canvas, Alignment::LOWER, 1.0, HALF), + // '▅' LOWER FIVE EIGHTHS BLOCK + 0x2585 => block(metrics, canvas, Alignment::LOWER, 1.0, FIVE_EIGHTHS), + // '▆' LOWER THREE QUARTERS BLOCK + 0x2586 => block(metrics, canvas, Alignment::LOWER, 1.0, THREE_QUARTERS), + // '▇' LOWER SEVEN EIGHTHS BLOCK + 0x2587 => block(metrics, canvas, Alignment::LOWER, 1.0, SEVEN_EIGHTHS), + // '█' FULL BLOCK + 0x2588 => full_block_shade(metrics, canvas, Shade::ON), + // '▉' LEFT SEVEN EIGHTHS BLOCK + 0x2589 => block(metrics, canvas, Alignment::LEFT, SEVEN_EIGHTHS, 1.0), + // '▊' LEFT THREE QUARTERS BLOCK + 0x258a => block(metrics, canvas, Alignment::LEFT, THREE_QUARTERS, 1.0), + // '▋' LEFT FIVE EIGHTHS BLOCK + 0x258b => block(metrics, canvas, Alignment::LEFT, FIVE_EIGHTHS, 1.0), + // '▌' LEFT HALF BLOCK + 0x258c => block(metrics, canvas, Alignment::LEFT, HALF, 1.0), + // '▍' LEFT THREE EIGHTHS BLOCK + 0x258d => block(metrics, canvas, Alignment::LEFT, THREE_EIGHTHS, 1.0), + // '▎' LEFT ONE QUARTER BLOCK + 0x258e => block(metrics, canvas, Alignment::LEFT, ONE_QUARTER, 1.0), + // '▏' LEFT ONE EIGHTH BLOCK + 0x258f => block(metrics, canvas, Alignment::LEFT, ONE_EIGHTH, 1.0), + + // '▐' RIGHT HALF BLOCK + 0x2590 => block(metrics, canvas, Alignment::RIGHT, HALF, 1.0), + // '░' + 0x2591 => full_block_shade(metrics, canvas, Shade::LIGHT), + // '▒' + 0x2592 => full_block_shade(metrics, canvas, Shade::MEDIUM), + // '▓' + 0x2593 => full_block_shade(metrics, canvas, Shade::DARK), + // '▔' UPPER ONE EIGHTH BLOCK + 0x2594 => block(metrics, canvas, Alignment::UPPER, 1.0, ONE_EIGHTH), + // '▕' RIGHT ONE EIGHTH BLOCK + 0x2595 => block(metrics, canvas, Alignment::RIGHT, ONE_EIGHTH, 1.0), + // '▖' + 0x2596 => quadrant(metrics, canvas, q(false, false, true, false)), + // '▗' + 0x2597 => quadrant(metrics, canvas, q(false, false, false, true)), + // '▘' + 0x2598 => quadrant(metrics, canvas, q(true, false, false, false)), + // '▙' + 0x2599 => quadrant(metrics, canvas, q(true, false, true, true)), + // '▚' + 0x259a => quadrant(metrics, canvas, q(true, false, false, true)), + // '▛' + 0x259b => quadrant(metrics, canvas, q(true, true, true, false)), + // '▜' + 0x259c => quadrant(metrics, canvas, q(true, true, false, true)), + // '▝' + 0x259d => quadrant(metrics, canvas, q(false, true, false, false)), + // '▞' + 0x259e => quadrant(metrics, canvas, q(false, true, true, false)), + // '▟' + 0x259f => quadrant(metrics, canvas, q(false, true, true, true)), + + _ => unreachable!(), + } + Ok(()) +} + +pub fn block( + metrics: &Metrics, + canvas: &mut Canvas, + alignment: Alignment, + width: f64, + height: f64, +) { + block_shade(metrics, canvas, alignment, width, height, Shade::ON); +} + +pub fn block_shade( + metrics: &Metrics, + canvas: &mut Canvas, + alignment: Alignment, + width: f64, + height: f64, + shade: Shade, +) { + let float_width = f64::from(metrics.cell_width); + let float_height = f64::from(metrics.cell_height); + + let w = (float_width * width).round() as u32; + let h = (float_height * height).round() as u32; + + let x = match alignment.horizontal { + HAlign::Left => 0, + HAlign::Right => metrics.cell_width - w, + HAlign::Center => (metrics.cell_width - w) / 2, + }; + let y = match alignment.vertical { + VAlign::Top => 0, + VAlign::Bottom => metrics.cell_height - h, + VAlign::Middle => (metrics.cell_height - h) / 2, + }; + + canvas.rect( + Rect { + x: x as i32, + y: y as i32, + width: w as i32, + height: h as i32, + }, + Color(shade.0), + ); +} + +pub fn full_block_shade(metrics: &Metrics, canvas: &mut Canvas, shade: Shade) { + canvas.box_( + 0, + 0, + metrics.cell_width as i32, + metrics.cell_height as i32, + Color(shade.0), + ); +} + +fn quadrant(metrics: &Metrics, canvas: &mut Canvas, quads: Quads) { + use Fraction::{Full, Half, Zero}; + if quads.tl { + fill(metrics, canvas, Zero, Half, Zero, Half); + } + if quads.tr { + fill(metrics, canvas, Half, Full, Zero, Half); + } + if quads.bl { + fill(metrics, canvas, Zero, Half, Half, Full); + } + if quads.br { + fill(metrics, canvas, Half, Full, Half, Full); + } +} diff --git a/crates/ghostty-font/src/sprite/draw/box_.rs b/crates/ghostty-font/src/sprite/draw/box_.rs new file mode 100644 index 0000000..732bd44 --- /dev/null +++ b/crates/ghostty-font/src/sprite/draw/box_.rs @@ -0,0 +1,1778 @@ +//! Box Drawing | U+2500...U+257F +//! +//! +//! ─━│┃┄┅┆┇┈┉┊┋┌┍┎┏ +//! ┐┑┒┓└┕┖┗┘┙┚┛├┝┞┟ +//! ┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯ +//! ┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿ +//! ╀╁╂╃╄╅╆╇╈╉╊╋╌╍╎╏ +//! ═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟ +//! ╠╡╢╣╤╥╦╧╨╩╪╫╬╭╮╯ +//! ╰╱╲╳╴╵╶╷╸╹╺╻╼╽╾╿ +//! +//! Port of `sprite/draw/box.zig`. + +#![allow(clippy::needless_update)] + +use super::common::{Corner, DrawFn, Range, Thickness, hline, hline_middle, vline, vline_middle}; +use crate::metrics::Metrics; +use crate::sprite::canvas::{Canvas, CapMode, Color, DrawError, Line, Path, Point, StrokeOptions}; + +pub const RANGES: &[Range] = &[Range { + min: 0x2500, + max: 0x257f, + draw: draw2500_257f as DrawFn, +}]; + +/// Style of a line from an edge to the center. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Style { + #[default] + None, + Light, + Heavy, + Double, +} + +/// Specification of a traditional intersection-style line/box-drawing char, +/// which can have a different style of line from each edge to the center. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Lines { + pub up: Style, + pub right: Style, + pub down: Style, + pub left: Style, +} + +impl Lines { + pub const NONE: Lines = Lines { + up: Style::None, + right: Style::None, + down: Style::None, + left: Style::None, + }; +} + +pub fn draw2500_257f( + cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + let light = Thickness::Light.height(metrics.box_thickness); + let heavy = Thickness::Heavy.height(metrics.box_thickness); + let gap4 = 4u32.max(light); + match cp { + // '┄' '┅' '┆' '┇' '┈' '┉' '┊' '┋' + 0x2504 => dash_horizontal(metrics, canvas, 3, light, gap4), + 0x2505 => dash_horizontal(metrics, canvas, 3, heavy, gap4), + 0x2506 => dash_vertical(metrics, canvas, 3, light, gap4), + 0x2507 => dash_vertical(metrics, canvas, 3, heavy, gap4), + 0x2508 => dash_horizontal(metrics, canvas, 4, light, gap4), + 0x2509 => dash_horizontal(metrics, canvas, 4, heavy, gap4), + 0x250a => dash_vertical(metrics, canvas, 4, light, gap4), + 0x250b => dash_vertical(metrics, canvas, 4, heavy, gap4), + // '╌' '╍' '╎' '╏' + 0x254c => dash_horizontal(metrics, canvas, 2, light, light), + 0x254d => dash_horizontal(metrics, canvas, 2, heavy, heavy), + 0x254e => dash_vertical(metrics, canvas, 2, light, heavy), + 0x254f => dash_vertical(metrics, canvas, 2, heavy, heavy), + + // '╭' + 0x256d => arc(metrics, canvas, Corner::Br, Thickness::Light)?, + // '╮' + 0x256e => arc(metrics, canvas, Corner::Bl, Thickness::Light)?, + // '╯' + 0x256f => arc(metrics, canvas, Corner::Tl, Thickness::Light)?, + // '╰' + 0x2570 => arc(metrics, canvas, Corner::Tr, Thickness::Light)?, + // '╱' + 0x2571 => light_diagonal_upper_right_to_lower_left(metrics, canvas), + // '╲' + 0x2572 => light_diagonal_upper_left_to_lower_right(metrics, canvas), + // '╳' + 0x2573 => light_diagonal_cross(metrics, canvas), + + // All the remaining codepoints are intersection-style lines. + 0x2500 => lines_char( + metrics, + canvas, + Lines { + left: Style::Light, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x2501 => lines_char( + metrics, + canvas, + Lines { + left: Style::Heavy, + right: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2502 => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + down: Style::Light, + ..Lines::NONE + }, + ), + 0x2503 => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + down: Style::Heavy, + ..Lines::NONE + }, + ), + 0x250c => lines_char( + metrics, + canvas, + Lines { + down: Style::Light, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x250d => lines_char( + metrics, + canvas, + Lines { + down: Style::Light, + right: Style::Heavy, + ..Lines::NONE + }, + ), + 0x250e => lines_char( + metrics, + canvas, + Lines { + down: Style::Heavy, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x250f => lines_char( + metrics, + canvas, + Lines { + down: Style::Heavy, + right: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2510 => lines_char( + metrics, + canvas, + Lines { + down: Style::Light, + left: Style::Light, + ..Lines::NONE + }, + ), + 0x2511 => lines_char( + metrics, + canvas, + Lines { + down: Style::Light, + left: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2512 => lines_char( + metrics, + canvas, + Lines { + down: Style::Heavy, + left: Style::Light, + ..Lines::NONE + }, + ), + 0x2513 => lines_char( + metrics, + canvas, + Lines { + down: Style::Heavy, + left: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2514 => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x2515 => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + right: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2516 => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x2517 => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + right: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2518 => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + left: Style::Light, + ..Lines::NONE + }, + ), + 0x2519 => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + left: Style::Heavy, + ..Lines::NONE + }, + ), + 0x251a => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + left: Style::Light, + ..Lines::NONE + }, + ), + 0x251b => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + left: Style::Heavy, + ..Lines::NONE + }, + ), + 0x251c => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + down: Style::Light, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x251d => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + down: Style::Light, + right: Style::Heavy, + ..Lines::NONE + }, + ), + 0x251e => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + right: Style::Light, + down: Style::Light, + ..Lines::NONE + }, + ), + 0x251f => lines_char( + metrics, + canvas, + Lines { + down: Style::Heavy, + right: Style::Light, + up: Style::Light, + ..Lines::NONE + }, + ), + 0x2520 => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + down: Style::Heavy, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x2521 => lines_char( + metrics, + canvas, + Lines { + down: Style::Light, + right: Style::Heavy, + up: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2522 => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + right: Style::Heavy, + down: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2523 => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + down: Style::Heavy, + right: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2524 => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + down: Style::Light, + left: Style::Light, + ..Lines::NONE + }, + ), + 0x2525 => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + down: Style::Light, + left: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2526 => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + left: Style::Light, + down: Style::Light, + ..Lines::NONE + }, + ), + 0x2527 => lines_char( + metrics, + canvas, + Lines { + down: Style::Heavy, + left: Style::Light, + up: Style::Light, + ..Lines::NONE + }, + ), + 0x2528 => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + down: Style::Heavy, + left: Style::Light, + ..Lines::NONE + }, + ), + 0x2529 => lines_char( + metrics, + canvas, + Lines { + down: Style::Light, + left: Style::Heavy, + up: Style::Heavy, + ..Lines::NONE + }, + ), + 0x252a => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + left: Style::Heavy, + down: Style::Heavy, + ..Lines::NONE + }, + ), + 0x252b => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + down: Style::Heavy, + left: Style::Heavy, + ..Lines::NONE + }, + ), + 0x252c => lines_char( + metrics, + canvas, + Lines { + down: Style::Light, + left: Style::Light, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x252d => lines_char( + metrics, + canvas, + Lines { + left: Style::Heavy, + right: Style::Light, + down: Style::Light, + ..Lines::NONE + }, + ), + 0x252e => lines_char( + metrics, + canvas, + Lines { + right: Style::Heavy, + left: Style::Light, + down: Style::Light, + ..Lines::NONE + }, + ), + 0x252f => lines_char( + metrics, + canvas, + Lines { + down: Style::Light, + left: Style::Heavy, + right: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2530 => lines_char( + metrics, + canvas, + Lines { + down: Style::Heavy, + left: Style::Light, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x2531 => lines_char( + metrics, + canvas, + Lines { + right: Style::Light, + left: Style::Heavy, + down: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2532 => lines_char( + metrics, + canvas, + Lines { + left: Style::Light, + right: Style::Heavy, + down: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2533 => lines_char( + metrics, + canvas, + Lines { + down: Style::Heavy, + left: Style::Heavy, + right: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2534 => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + left: Style::Light, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x2535 => lines_char( + metrics, + canvas, + Lines { + left: Style::Heavy, + right: Style::Light, + up: Style::Light, + ..Lines::NONE + }, + ), + 0x2536 => lines_char( + metrics, + canvas, + Lines { + right: Style::Heavy, + left: Style::Light, + up: Style::Light, + ..Lines::NONE + }, + ), + 0x2537 => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + left: Style::Heavy, + right: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2538 => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + left: Style::Light, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x2539 => lines_char( + metrics, + canvas, + Lines { + right: Style::Light, + left: Style::Heavy, + up: Style::Heavy, + ..Lines::NONE + }, + ), + 0x253a => lines_char( + metrics, + canvas, + Lines { + left: Style::Light, + right: Style::Heavy, + up: Style::Heavy, + ..Lines::NONE + }, + ), + 0x253b => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + left: Style::Heavy, + right: Style::Heavy, + ..Lines::NONE + }, + ), + 0x253c => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + down: Style::Light, + left: Style::Light, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x253d => lines_char( + metrics, + canvas, + Lines { + left: Style::Heavy, + right: Style::Light, + up: Style::Light, + down: Style::Light, + ..Lines::NONE + }, + ), + 0x253e => lines_char( + metrics, + canvas, + Lines { + right: Style::Heavy, + left: Style::Light, + up: Style::Light, + down: Style::Light, + ..Lines::NONE + }, + ), + 0x253f => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + down: Style::Light, + left: Style::Heavy, + right: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2540 => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + down: Style::Light, + left: Style::Light, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x2541 => lines_char( + metrics, + canvas, + Lines { + down: Style::Heavy, + up: Style::Light, + left: Style::Light, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x2542 => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + down: Style::Heavy, + left: Style::Light, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x2543 => lines_char( + metrics, + canvas, + Lines { + left: Style::Heavy, + up: Style::Heavy, + right: Style::Light, + down: Style::Light, + ..Lines::NONE + }, + ), + 0x2544 => lines_char( + metrics, + canvas, + Lines { + right: Style::Heavy, + up: Style::Heavy, + left: Style::Light, + down: Style::Light, + ..Lines::NONE + }, + ), + 0x2545 => lines_char( + metrics, + canvas, + Lines { + left: Style::Heavy, + down: Style::Heavy, + right: Style::Light, + up: Style::Light, + ..Lines::NONE + }, + ), + 0x2546 => lines_char( + metrics, + canvas, + Lines { + right: Style::Heavy, + down: Style::Heavy, + left: Style::Light, + up: Style::Light, + ..Lines::NONE + }, + ), + 0x2547 => lines_char( + metrics, + canvas, + Lines { + down: Style::Light, + up: Style::Heavy, + left: Style::Heavy, + right: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2548 => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + down: Style::Heavy, + left: Style::Heavy, + right: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2549 => lines_char( + metrics, + canvas, + Lines { + right: Style::Light, + left: Style::Heavy, + up: Style::Heavy, + down: Style::Heavy, + ..Lines::NONE + }, + ), + 0x254a => lines_char( + metrics, + canvas, + Lines { + left: Style::Light, + right: Style::Heavy, + up: Style::Heavy, + down: Style::Heavy, + ..Lines::NONE + }, + ), + 0x254b => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + down: Style::Heavy, + left: Style::Heavy, + right: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2550 => lines_char( + metrics, + canvas, + Lines { + left: Style::Double, + right: Style::Double, + ..Lines::NONE + }, + ), + 0x2551 => lines_char( + metrics, + canvas, + Lines { + up: Style::Double, + down: Style::Double, + ..Lines::NONE + }, + ), + 0x2552 => lines_char( + metrics, + canvas, + Lines { + down: Style::Light, + right: Style::Double, + ..Lines::NONE + }, + ), + 0x2553 => lines_char( + metrics, + canvas, + Lines { + down: Style::Double, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x2554 => lines_char( + metrics, + canvas, + Lines { + down: Style::Double, + right: Style::Double, + ..Lines::NONE + }, + ), + 0x2555 => lines_char( + metrics, + canvas, + Lines { + down: Style::Light, + left: Style::Double, + ..Lines::NONE + }, + ), + 0x2556 => lines_char( + metrics, + canvas, + Lines { + down: Style::Double, + left: Style::Light, + ..Lines::NONE + }, + ), + 0x2557 => lines_char( + metrics, + canvas, + Lines { + down: Style::Double, + left: Style::Double, + ..Lines::NONE + }, + ), + 0x2558 => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + right: Style::Double, + ..Lines::NONE + }, + ), + 0x2559 => lines_char( + metrics, + canvas, + Lines { + up: Style::Double, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x255a => lines_char( + metrics, + canvas, + Lines { + up: Style::Double, + right: Style::Double, + ..Lines::NONE + }, + ), + 0x255b => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + left: Style::Double, + ..Lines::NONE + }, + ), + 0x255c => lines_char( + metrics, + canvas, + Lines { + up: Style::Double, + left: Style::Light, + ..Lines::NONE + }, + ), + 0x255d => lines_char( + metrics, + canvas, + Lines { + up: Style::Double, + left: Style::Double, + ..Lines::NONE + }, + ), + 0x255e => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + down: Style::Light, + right: Style::Double, + ..Lines::NONE + }, + ), + 0x255f => lines_char( + metrics, + canvas, + Lines { + up: Style::Double, + down: Style::Double, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x2560 => lines_char( + metrics, + canvas, + Lines { + up: Style::Double, + down: Style::Double, + right: Style::Double, + ..Lines::NONE + }, + ), + 0x2561 => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + down: Style::Light, + left: Style::Double, + ..Lines::NONE + }, + ), + 0x2562 => lines_char( + metrics, + canvas, + Lines { + up: Style::Double, + down: Style::Double, + left: Style::Light, + ..Lines::NONE + }, + ), + 0x2563 => lines_char( + metrics, + canvas, + Lines { + up: Style::Double, + down: Style::Double, + left: Style::Double, + ..Lines::NONE + }, + ), + 0x2564 => lines_char( + metrics, + canvas, + Lines { + down: Style::Light, + left: Style::Double, + right: Style::Double, + ..Lines::NONE + }, + ), + 0x2565 => lines_char( + metrics, + canvas, + Lines { + down: Style::Double, + left: Style::Light, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x2566 => lines_char( + metrics, + canvas, + Lines { + down: Style::Double, + left: Style::Double, + right: Style::Double, + ..Lines::NONE + }, + ), + 0x2567 => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + left: Style::Double, + right: Style::Double, + ..Lines::NONE + }, + ), + 0x2568 => lines_char( + metrics, + canvas, + Lines { + up: Style::Double, + left: Style::Light, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x2569 => lines_char( + metrics, + canvas, + Lines { + up: Style::Double, + left: Style::Double, + right: Style::Double, + ..Lines::NONE + }, + ), + 0x256a => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + down: Style::Light, + left: Style::Double, + right: Style::Double, + ..Lines::NONE + }, + ), + 0x256b => lines_char( + metrics, + canvas, + Lines { + up: Style::Double, + down: Style::Double, + left: Style::Light, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x256c => lines_char( + metrics, + canvas, + Lines { + up: Style::Double, + down: Style::Double, + left: Style::Double, + right: Style::Double, + ..Lines::NONE + }, + ), + 0x2574 => lines_char( + metrics, + canvas, + Lines { + left: Style::Light, + ..Lines::NONE + }, + ), + 0x2575 => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + ..Lines::NONE + }, + ), + 0x2576 => lines_char( + metrics, + canvas, + Lines { + right: Style::Light, + ..Lines::NONE + }, + ), + 0x2577 => lines_char( + metrics, + canvas, + Lines { + down: Style::Light, + ..Lines::NONE + }, + ), + 0x2578 => lines_char( + metrics, + canvas, + Lines { + left: Style::Heavy, + ..Lines::NONE + }, + ), + 0x2579 => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + ..Lines::NONE + }, + ), + 0x257a => lines_char( + metrics, + canvas, + Lines { + right: Style::Heavy, + ..Lines::NONE + }, + ), + 0x257b => lines_char( + metrics, + canvas, + Lines { + down: Style::Heavy, + ..Lines::NONE + }, + ), + 0x257c => lines_char( + metrics, + canvas, + Lines { + left: Style::Light, + right: Style::Heavy, + ..Lines::NONE + }, + ), + 0x257d => lines_char( + metrics, + canvas, + Lines { + up: Style::Light, + down: Style::Heavy, + ..Lines::NONE + }, + ), + 0x257e => lines_char( + metrics, + canvas, + Lines { + left: Style::Heavy, + right: Style::Light, + ..Lines::NONE + }, + ), + 0x257f => lines_char( + metrics, + canvas, + Lines { + up: Style::Heavy, + down: Style::Light, + ..Lines::NONE + }, + ), + _ => unreachable!(), + } + Ok(()) +} + +pub fn lines_char(metrics: &Metrics, canvas: &mut Canvas, lines: Lines) { + use Style::{Double, Heavy, Light, None}; + + let light_px = Thickness::Light.height(metrics.box_thickness); + let heavy_px = Thickness::Heavy.height(metrics.box_thickness); + + // Top of light horizontal strokes + let h_light_top = metrics.cell_height.saturating_sub(light_px) / 2; + // Bottom of light horizontal strokes + let h_light_bottom = h_light_top.saturating_add(light_px); + + // Top of heavy horizontal strokes + let h_heavy_top = metrics.cell_height.saturating_sub(heavy_px) / 2; + // Bottom of heavy horizontal strokes + let h_heavy_bottom = h_heavy_top.saturating_add(heavy_px); + + // Top of the top doubled horizontal stroke (bottom is `h_light_top`) + let h_double_top = h_light_top.saturating_sub(light_px); + // Bottom of the bottom doubled horizontal stroke (top is `h_light_bottom`) + let h_double_bottom = h_light_bottom.saturating_add(light_px); + + // Left of light vertical strokes + let v_light_left = metrics.cell_width.saturating_sub(light_px) / 2; + // Right of light vertical strokes + let v_light_right = v_light_left.saturating_add(light_px); + + // Left of heavy vertical strokes + let v_heavy_left = metrics.cell_width.saturating_sub(heavy_px) / 2; + // Right of heavy vertical strokes + let v_heavy_right = v_heavy_left.saturating_add(heavy_px); + + // Left of the left doubled vertical stroke (right is `v_light_left`) + let v_double_left = v_light_left.saturating_sub(light_px); + // Right of the right doubled vertical stroke (left is `v_light_right`) + let v_double_right = v_light_right.saturating_add(light_px); + + // The bottom of the up line + let up_bottom = if lines.left == Heavy || lines.right == Heavy { + h_heavy_bottom + } else if lines.left != lines.right || lines.down == lines.up { + if lines.left == Double || lines.right == Double { + h_double_bottom + } else { + h_light_bottom + } + } else if lines.left == None && lines.right == None { + h_light_bottom + } else { + h_light_top + }; + + // The top of the down line + let down_top = if lines.left == Heavy || lines.right == Heavy { + h_heavy_top + } else if lines.left != lines.right || lines.up == lines.down { + if lines.left == Double || lines.right == Double { + h_double_top + } else { + h_light_top + } + } else if lines.left == None && lines.right == None { + h_light_top + } else { + h_light_bottom + }; + + // The right of the left line + let left_right = if lines.up == Heavy || lines.down == Heavy { + v_heavy_right + } else if lines.up != lines.down || lines.left == lines.right { + if lines.up == Double || lines.down == Double { + v_double_right + } else { + v_light_right + } + } else if lines.up == None && lines.down == None { + v_light_right + } else { + v_light_left + }; + + // The left of the right line + let right_left = if lines.up == Heavy || lines.down == Heavy { + v_heavy_left + } else if lines.up != lines.down || lines.right == lines.left { + if lines.up == Double || lines.down == Double { + v_double_left + } else { + v_light_left + } + } else if lines.up == None && lines.down == None { + v_light_left + } else { + v_light_right + }; + + let ch = metrics.cell_height as i32; + let cw = metrics.cell_width as i32; + + match lines.up { + None => {} + Light => canvas.box_( + v_light_left as i32, + 0, + v_light_right as i32, + up_bottom as i32, + Color::ON, + ), + Heavy => canvas.box_( + v_heavy_left as i32, + 0, + v_heavy_right as i32, + up_bottom as i32, + Color::ON, + ), + Double => { + let left_bottom = if lines.left == Double { + h_light_top + } else { + up_bottom + }; + let right_bottom = if lines.right == Double { + h_light_top + } else { + up_bottom + }; + + canvas.box_( + v_double_left as i32, + 0, + v_light_left as i32, + left_bottom as i32, + Color::ON, + ); + canvas.box_( + v_light_right as i32, + 0, + v_double_right as i32, + right_bottom as i32, + Color::ON, + ); + } + } + + match lines.right { + None => {} + Light => canvas.box_( + right_left as i32, + h_light_top as i32, + cw, + h_light_bottom as i32, + Color::ON, + ), + Heavy => canvas.box_( + right_left as i32, + h_heavy_top as i32, + cw, + h_heavy_bottom as i32, + Color::ON, + ), + Double => { + let top_left = if lines.up == Double { + v_light_right + } else { + right_left + }; + let bottom_left = if lines.down == Double { + v_light_right + } else { + right_left + }; + + canvas.box_( + top_left as i32, + h_double_top as i32, + cw, + h_light_top as i32, + Color::ON, + ); + canvas.box_( + bottom_left as i32, + h_light_bottom as i32, + cw, + h_double_bottom as i32, + Color::ON, + ); + } + } + + match lines.down { + None => {} + Light => canvas.box_( + v_light_left as i32, + down_top as i32, + v_light_right as i32, + ch, + Color::ON, + ), + Heavy => canvas.box_( + v_heavy_left as i32, + down_top as i32, + v_heavy_right as i32, + ch, + Color::ON, + ), + Double => { + let left_top = if lines.left == Double { + h_light_bottom + } else { + down_top + }; + let right_top = if lines.right == Double { + h_light_bottom + } else { + down_top + }; + + canvas.box_( + v_double_left as i32, + left_top as i32, + v_light_left as i32, + ch, + Color::ON, + ); + canvas.box_( + v_light_right as i32, + right_top as i32, + v_double_right as i32, + ch, + Color::ON, + ); + } + } + + match lines.left { + None => {} + Light => canvas.box_( + 0, + h_light_top as i32, + left_right as i32, + h_light_bottom as i32, + Color::ON, + ), + Heavy => canvas.box_( + 0, + h_heavy_top as i32, + left_right as i32, + h_heavy_bottom as i32, + Color::ON, + ), + Double => { + let top_right = if lines.up == Double { + v_light_left + } else { + left_right + }; + let bottom_right = if lines.down == Double { + v_light_left + } else { + left_right + }; + + canvas.box_( + 0, + h_double_top as i32, + top_right as i32, + h_light_top as i32, + Color::ON, + ); + canvas.box_( + 0, + h_light_bottom as i32, + bottom_right as i32, + h_double_bottom as i32, + Color::ON, + ); + } + } +} + +pub fn light_diagonal_upper_right_to_lower_left(metrics: &Metrics, canvas: &mut Canvas) { + let float_width = f64::from(metrics.cell_width); + let float_height = f64::from(metrics.cell_height); + + // We overshoot the corners by a tiny bit, but we need to + // maintain the correct slope, so we calculate that here. + let slope_x = (float_width / float_height).min(1.0); + let slope_y = (float_height / float_width).min(1.0); + + let _ = canvas.line( + Line { + p0: Point { + x: float_width + 0.5 * slope_x, + y: -0.5 * slope_y, + }, + p1: Point { + x: -0.5 * slope_x, + y: float_height + 0.5 * slope_y, + }, + }, + f64::from(Thickness::Light.height(metrics.box_thickness)), + Color::ON, + ); +} + +pub fn light_diagonal_upper_left_to_lower_right(metrics: &Metrics, canvas: &mut Canvas) { + let float_width = f64::from(metrics.cell_width); + let float_height = f64::from(metrics.cell_height); + + // We overshoot the corners by a tiny bit, but we need to + // maintain the correct slope, so we calculate that here. + let slope_x = (float_width / float_height).min(1.0); + let slope_y = (float_height / float_width).min(1.0); + + let _ = canvas.line( + Line { + p0: Point { + x: -0.5 * slope_x, + y: -0.5 * slope_y, + }, + p1: Point { + x: float_width + 0.5 * slope_x, + y: float_height + 0.5 * slope_y, + }, + }, + f64::from(Thickness::Light.height(metrics.box_thickness)), + Color::ON, + ); +} + +pub fn light_diagonal_cross(metrics: &Metrics, canvas: &mut Canvas) { + light_diagonal_upper_right_to_lower_left(metrics, canvas); + light_diagonal_upper_left_to_lower_right(metrics, canvas); +} + +pub fn arc( + metrics: &Metrics, + canvas: &mut Canvas, + corner: Corner, + thickness: Thickness, +) -> Result<(), DrawError> { + let thick_px = thickness.height(metrics.box_thickness); + let float_width = f64::from(metrics.cell_width); + let float_height = f64::from(metrics.cell_height); + let float_thick = f64::from(thick_px); + let center_x = f64::from(metrics.cell_width.saturating_sub(thick_px) / 2) + float_thick / 2.0; + let center_y = f64::from(metrics.cell_height.saturating_sub(thick_px) / 2) + float_thick / 2.0; + + let r = float_width.min(float_height) / 2.0; + + // Fraction away from the center to place the middle control points, + let s = 0.25; + + let mut path = Path::new(); + + match corner { + Corner::Tl => { + path.move_to(center_x, 0.0); + path.line_to(center_x, center_y - r); + path.curve_to( + center_x, + center_y - s * r, + center_x - s * r, + center_y, + center_x - r, + center_y, + ); + path.line_to(0.0, center_y); + } + Corner::Tr => { + path.move_to(center_x, 0.0); + path.line_to(center_x, center_y - r); + path.curve_to( + center_x, + center_y - s * r, + center_x + s * r, + center_y, + center_x + r, + center_y, + ); + path.line_to(float_width, center_y); + } + Corner::Bl => { + path.move_to(center_x, float_height); + path.line_to(center_x, center_y + r); + path.curve_to( + center_x, + center_y + s * r, + center_x - s * r, + center_y, + center_x - r, + center_y, + ); + path.line_to(0.0, center_y); + } + Corner::Br => { + path.move_to(center_x, float_height); + path.line_to(center_x, center_y + r); + path.curve_to( + center_x, + center_y + s * r, + center_x + s * r, + center_y, + center_x + r, + center_y, + ); + path.line_to(float_width, center_y); + } + } + + canvas.stroke_path( + &path, + StrokeOptions { + line_cap_mode: CapMode::Butt, + line_width: float_thick, + }, + Color::ON, + ) +} + +fn dash_horizontal( + metrics: &Metrics, + canvas: &mut Canvas, + count: u8, + thick_px: u32, + desired_gap: u32, +) { + assert!((2..=4).contains(&count)); + let count_u = u32::from(count); + + // Our dashed line should be made such that when tiled horizontally + // it creates one consistent line with no uneven gap or segment sizes. + // In order to make sure this is the case, we should have half-sized + // gaps on the left and right so that it is centered properly. + + // For N dashes, there are N - 1 gaps between them, but we also have + // half-sized gaps on either side, adding up to N total gaps. + let gap_count = count_u; + + // We need at least 1 pixel for each gap and each dash, if we don't + // have that then we can't draw our dashed line correctly so we just + // draw a solid line and return. + if metrics.cell_width < count_u + gap_count { + hline_middle(metrics, canvas, Thickness::Light); + return; + } + + // We never want the gaps to take up more than 50% of the space, + // because if they do the dashes are too small and look wrong. + let gap_width = desired_gap.min(metrics.cell_width / (2 * count_u)) as i32; + let total_gap_width = gap_count as i32 * gap_width; + let total_dash_width = metrics.cell_width as i32 - total_gap_width; + let dash_width = total_dash_width.div_euclid(i32::from(count)); + let remaining = total_dash_width.rem_euclid(i32::from(count)); + + debug_assert_eq!( + dash_width * i32::from(count) + gap_width * gap_count as i32 + remaining, + metrics.cell_width as i32 + ); + + // Our dashes should be centered vertically. + let y = (metrics.cell_height.saturating_sub(thick_px) / 2) as i32; + + // We start at half a gap from the left edge, in order to center + // our dashes properly. + let mut x = gap_width.div_euclid(2); + + // We'll distribute the extra space in to dash widths, 1px at a + // time. We prefer this to making gaps larger since that is much + // more visually obvious. + let mut extra = remaining; + + for _ in 0..count { + let mut x1 = x + dash_width; + // We distribute left-over size in to dash widths, + // since it's less obvious there than in the gaps. + if extra > 0 { + extra -= 1; + x1 += 1; + } + hline(canvas, x, x1, y, thick_px); + // Advance by the width of the dash we drew and the width + // of a gap to get the the start of the next dash. + x = x1 + gap_width; + } +} + +fn dash_vertical( + metrics: &Metrics, + canvas: &mut Canvas, + count: u8, + thick_px: u32, + desired_gap: u32, +) { + assert!((2..=4).contains(&count)); + let count_u = u32::from(count); + + // Our dashed line should be made such that when tiled vertically it + // it creates one consistent line with no uneven gap or segment sizes. + // In order to make sure this is the case, we should have an extra gap + // gap at the bottom. + // + // A single full-sized extra gap is preferred to two half-sized ones for + // vertical to allow better joining to solid characters without creating + // visible half-sized gaps. Unlike horizontal, centering is a lot less + // important, visually. + + // Because of the extra gap at the bottom, there are as many gaps as + // there are dashes. + let gap_count = count_u; + + // We need at least 1 pixel for each gap and each dash, if we don't + // have that then we can't draw our dashed line correctly so we just + // draw a solid line and return. + if metrics.cell_height < count_u + gap_count { + vline_middle(metrics, canvas, Thickness::Light); + return; + } + + // We never want the gaps to take up more than 50% of the space, + // because if they do the dashes are too small and look wrong. + let gap_height = desired_gap.min(metrics.cell_height / (2 * count_u)) as i32; + let total_gap_height = gap_count as i32 * gap_height; + let total_dash_height = metrics.cell_height as i32 - total_gap_height; + let dash_height = total_dash_height.div_euclid(i32::from(count)); + let remaining = total_dash_height.rem_euclid(i32::from(count)); + + debug_assert_eq!( + dash_height * i32::from(count) + gap_height * gap_count as i32 + remaining, + metrics.cell_height as i32 + ); + + // Our dashes should be centered horizontally. + let x = (metrics.cell_width.saturating_sub(thick_px) / 2) as i32; + + // We start at the top of the cell. + let mut y = 0; + + // We'll distribute the extra space in to dash heights, 1px at a + // time. We prefer this to making gaps larger since that is much + // more visually obvious. + let mut extra = remaining; + + for _ in 0..count { + let mut y1 = y + dash_height; + // We distribute left-over size in to dash widths, + // since it's less obvious there than in the gaps. + if extra > 0 { + extra -= 1; + y1 += 1; + } + vline(canvas, y, y1, x, thick_px); + // Advance by the height of the dash we drew and the height + // of a gap to get the the start of the next dash. + y = y1 + gap_height; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn metrics(w: u32, h: u32, t: u32) -> Metrics { + Metrics::calc(crate::metrics::FaceMetrics { + px_per_em: 16.0, + cell_width: f64::from(w), + ascent: f64::from(h) * 0.8, + descent: -(f64::from(h) * 0.2), + line_gap: 0.0, + underline_thickness: Some(f64::from(t)), + ..Default::default() + }) + } + + #[test] + fn all_box_codepoints_draw() { + for (w, h, t) in [(9, 17, 1), (12, 24, 3), (3, 5, 1)] { + let m = metrics(w, h, t); + for cp in 0x2500..=0x257f { + let mut c = Canvas::new( + m.cell_width, + m.cell_height, + m.cell_width / 4, + m.cell_height / 4, + ); + draw2500_257f(cp, &mut c, m.cell_width, m.cell_height, &m).expect("draw"); + assert!(c.sfc.iter().any(|v| *v != 0), "U+{cp:X} empty at {w}x{h}"); + } + } + } + + #[test] + fn tiny_cells_do_not_panic() { + let m = metrics(1, 1, 1); + for cp in 0x2500..=0x257f { + let mut c = Canvas::new(1, 1, 0, 0); + draw2500_257f(cp, &mut c, 1, 1, &m).expect("draw"); + } + } + + #[test] + fn light_horizontal_line_is_centered() { + let m = metrics(10, 20, 2); + let mut c = Canvas::new(10, 20, 0, 0); + lines_char( + &m, + &mut c, + Lines { + left: Style::Light, + right: Style::Light, + ..Lines::NONE + }, + ); + let t = m.box_thickness as usize; + let top = (20 - t) / 2; + for y in 0..20usize { + let on = c.sfc[y * 10] == 255; + assert_eq!(on, y >= top && y < top + t, "row {y}"); + } + } +} diff --git a/crates/ghostty-font/src/sprite/draw/braille.rs b/crates/ghostty-font/src/sprite/draw/braille.rs new file mode 100644 index 0000000..940eba4 --- /dev/null +++ b/crates/ghostty-font/src/sprite/draw/braille.rs @@ -0,0 +1,205 @@ +//! Braille Patterns | U+2800...U+28FF +//! +//! +//! (6 dot patterns) +//! ⠀ ⠁ ⠂ ⠃ ⠄ ⠅ ⠆ ⠇ ⠈ ⠉ ⠊ ⠋ ⠌ ⠍ ⠎ ⠏ +//! ⠐ ⠑ ⠒ ⠓ ⠔ ⠕ ⠖ ⠗ ⠘ ⠙ ⠚ ⠛ ⠜ ⠝ ⠞ ⠟ +//! ⠠ ⠡ ⠢ ⠣ ⠤ ⠥ ⠦ ⠧ ⠨ ⠩ ⠪ ⠫ ⠬ ⠭ ⠮ ⠯ +//! ⠰ ⠱ ⠲ ⠳ ⠴ ⠵ ⠶ ⠷ ⠸ ⠹ ⠺ ⠻ ⠼ ⠽ ⠾ ⠿ +//! +//! (8 dot patterns) +//! ⡀ ⡁ ⡂ ⡃ ⡄ ⡅ ⡆ ⡇ ⡈ ⡉ ⡊ ⡋ ⡌ ⡍ ⡎ ⡏ +//! ⡐ ⡑ ⡒ ⡓ ⡔ ⡕ ⡖ ⡗ ⡘ ⡙ ⡚ ⡛ ⡜ ⡝ ⡞ ⡟ +//! ⡠ ⡡ ⡢ ⡣ ⡤ ⡥ ⡦ ⡧ ⡨ ⡩ ⡪ ⡫ ⡬ ⡭ ⡮ ⡯ +//! ⡰ ⡱ ⡲ ⡳ ⡴ ⡵ ⡶ ⡷ ⡸ ⡹ ⡺ ⡻ ⡼ ⡽ ⡾ ⡿ +//! ⢀ ⢁ ⢂ ⢃ ⢄ ⢅ ⢆ ⢇ ⢈ ⢉ ⢊ ⢋ ⢌ ⢍ ⢎ ⢏ +//! ⢐ ⢑ ⢒ ⢓ ⢔ ⢕ ⢖ ⢗ ⢘ ⢙ ⢚ ⢛ ⢜ ⢝ ⢞ ⢟ +//! ⢠ ⢡ ⢢ ⢣ ⢤ ⢥ ⢦ ⢧ ⢨ ⢩ ⢪ ⢫ ⢬ ⢭ ⢮ ⢯ +//! ⢰ ⢱ ⢲ ⢳ ⢴ ⢵ ⢶ ⢷ ⢸ ⢹ ⢺ ⢻ ⢼ ⢽ ⢾ ⢿ +//! ⣀ ⣁ ⣂ ⣃ ⣄ ⣅ ⣆ ⣇ ⣈ ⣉ ⣊ ⣋ ⣌ ⣍ ⣎ ⣏ +//! ⣐ ⣑ ⣒ ⣓ ⣔ ⣕ ⣖ ⣗ ⣘ ⣙ ⣚ ⣛ ⣜ ⣝ ⣞ ⣟ +//! ⣠ ⣡ ⣢ ⣣ ⣤ ⣥ ⣦ ⣧ ⣨ ⣩ ⣪ ⣫ ⣬ ⣭ ⣮ ⣯ +//! ⣰ ⣱ ⣲ ⣳ ⣴ ⣵ ⣶ ⣷ ⣸ ⣹ ⣺ ⣻ ⣼ ⣽ ⣾ ⣿ +//! +//! Port of `sprite/draw/braille.zig`. + +use super::common::{DrawFn, Range}; +use crate::metrics::Metrics; +use crate::sprite::canvas::{Canvas, Color, DrawError}; + +pub const RANGES: &[Range] = &[Range { + min: 0x2800, + max: 0x28ff, + draw: draw2800_28ff as DrawFn, +}]; + +/// A braille pattern. +/// +/// Mnemonic: +/// [t]op - . . +/// [u]pper - . . +/// [l]ower - . . +/// [b]ottom - . . +/// | | +/// [l]eft, [r]ight +/// +/// Bit layout matches bit patterns of unicode codepoints +/// (bit 0 = `tl`, ... bit 7 = `br`). +#[derive(Debug, Clone, Copy)] +struct Pattern { + tl: bool, + ul: bool, + ll: bool, + tr: bool, + ur: bool, + lr: bool, + bl: bool, + br: bool, +} + +impl Pattern { + fn from(cp: u32) -> Pattern { + let b = cp as u8; + Pattern { + tl: b & 1 != 0, + ul: b & (1 << 1) != 0, + ll: b & (1 << 2) != 0, + tr: b & (1 << 3) != 0, + ur: b & (1 << 4) != 0, + lr: b & (1 << 5) != 0, + bl: b & (1 << 6) != 0, + br: b & (1 << 7) != 0, + } + } +} + +pub fn draw2800_28ff( + cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + _metrics: &Metrics, +) -> Result<(), DrawError> { + let (wi, hi) = (width as i32, height as i32); + let mut w: i32 = (width / 4).min(height / 8) as i32; + let mut x_spacing: i32 = (width / 4) as i32; + let mut y_spacing: i32 = (height / 8) as i32; + let mut x_margin: i32 = x_spacing.div_euclid(2); + let mut y_margin: i32 = y_spacing.div_euclid(2); + + let mut x_px_left: i32 = wi - 2 * x_margin - x_spacing - 2 * w; + + let mut y_px_left: i32 = hi - 2 * y_margin - 3 * y_spacing - 4 * w; + + // First, try hard to ensure the DOT width is non-zero + if x_px_left >= 2 && y_px_left >= 4 && w == 0 { + w += 1; + x_px_left -= 2; + y_px_left -= 4; + } + + // Second, prefer a non-zero margin + if x_px_left >= 2 && x_margin == 0 { + x_margin = 1; + x_px_left -= 2; + } + if y_px_left >= 2 && y_margin == 0 { + y_margin = 1; + y_px_left -= 2; + } + + // Third, increase spacing + if x_px_left >= 1 { + x_spacing += 1; + x_px_left -= 1; + } + if y_px_left >= 3 { + y_spacing += 1; + y_px_left -= 3; + } + + // Fourth, margins (“spacing”, but on the sides) + if x_px_left >= 2 { + x_margin += 1; + x_px_left -= 2; + } + if y_px_left >= 2 { + y_margin += 1; + y_px_left -= 2; + } + + // Last - increase dot width + if x_px_left >= 2 && y_px_left >= 4 { + w += 1; + x_px_left -= 2; + y_px_left -= 4; + } + + debug_assert!(x_px_left <= 1 || y_px_left <= 1); + debug_assert!(2 * x_margin + 2 * w + x_spacing <= wi); + debug_assert!(2 * y_margin + 4 * w + 3 * y_spacing <= hi); + + let x = [x_margin, x_margin + w + x_spacing]; + let y0 = y_margin; + let y1 = y0 + w + y_spacing; + let y2 = y1 + w + y_spacing; + let y3 = y2 + w + y_spacing; + let y = [y0, y1, y2, y3]; + + debug_assert!(cp >= 0x2800); + debug_assert!(cp <= 0x28ff); + let p = Pattern::from(cp); + + let mut dot = |on: bool, xi: usize, yi: usize| { + if on { + canvas.box_(x[xi], y[yi], x[xi] + w, y[yi] + w, Color::ON); + } + }; + dot(p.tl, 0, 0); + dot(p.ul, 0, 1); + dot(p.ll, 0, 2); + dot(p.bl, 0, 3); + dot(p.tr, 1, 0); + dot(p.ur, 1, 1); + dot(p.lr, 1, 2); + dot(p.br, 1, 3); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pattern_bits_match_codepoint() { + let p = Pattern::from(0x2801); + assert!(p.tl && !p.ul && !p.br); + let p = Pattern::from(0x2880); + assert!(p.br && !p.tl); + let p = Pattern::from(0x28ff); + assert!(p.tl && p.ul && p.ll && p.tr && p.ur && p.lr && p.bl && p.br); + } + + #[test] + fn all_braille_render_at_common_sizes() { + // Exercises the (debug) asserts in the layout algorithm. Like Zig, some + // odd sizes (e.g. 14x23) trip the `x_px_left <= 1 || y_px_left <= 1` + // assert in debug builds, so only realistic sizes are tested here. + let m = Metrics::calc(crate::metrics::FaceMetrics::default()); + for (w, h) in [ + (7, 14), + (8, 16), + (9, 17), + (10, 20), + (11, 21), + (12, 24), + (18, 36), + (20, 40), + ] { + let mut c = Canvas::new(w, h, w / 4, h / 4); + for cp in 0x2800..=0x28ff { + draw2800_28ff(cp, &mut c, w, h, &m).unwrap(); + } + } + } +} diff --git a/crates/ghostty-font/src/sprite/draw/branch.rs b/crates/ghostty-font/src/sprite/draw/branch.rs new file mode 100644 index 0000000..6412505 --- /dev/null +++ b/crates/ghostty-font/src/sprite/draw/branch.rs @@ -0,0 +1,686 @@ +//! Branch Drawing Characters | U+F5D0...U+F60D +//! +//! Branch drawing character set, used for drawing git-like +//! graphs in the terminal. Originally implemented in Kitty. +//! Ref: +//! - +//! - +//! +//! NOTE: Kitty is GPL licensed, and its code was not referenced +//! for these characters, only the loose specification of +//! the character set in the pull request descriptions. +//! +//! Port of `sprite/draw/branch.zig`. + +use std::f64::consts::PI; + +use super::box_; +use super::common::{Corner, DrawFn, Edge, Range, Shade, Thickness, hline_middle, vline_middle}; +use crate::metrics::Metrics; +use crate::sprite::canvas::{Canvas, CapMode, Color, DrawError, Path, StrokeOptions}; + +pub const RANGES: &[Range] = &[Range { + min: 0xf5d0, + max: 0xf60d, + draw: draw_f5d0_f60d as DrawFn, +}]; + +/// Specification of a branch drawing node, which consists of a +/// circle which is either empty or filled, and lines connecting +/// optionally between the circle and each of the 4 edges. +#[derive(Debug, Clone, Copy, Default)] +struct BranchNode { + up: bool, + right: bool, + down: bool, + left: bool, + filled: bool, +} + +pub fn draw_f5d0_f60d( + cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + match cp { + 0x0f5d0 => { + hline_middle(metrics, canvas, Thickness::Light); + } + 0x0f5d1 => { + vline_middle(metrics, canvas, Thickness::Light); + } + 0x0f5d2 => { + fading_line(metrics, canvas, Edge::Right, Thickness::Light); + } + 0x0f5d3 => { + fading_line(metrics, canvas, Edge::Left, Thickness::Light); + } + 0x0f5d4 => { + fading_line(metrics, canvas, Edge::Bottom, Thickness::Light); + } + 0x0f5d5 => { + fading_line(metrics, canvas, Edge::Top, Thickness::Light); + } + 0x0f5d6 => { + box_::arc(metrics, canvas, Corner::Br, Thickness::Light)?; + } + 0x0f5d7 => { + box_::arc(metrics, canvas, Corner::Bl, Thickness::Light)?; + } + 0x0f5d8 => { + box_::arc(metrics, canvas, Corner::Tr, Thickness::Light)?; + } + 0x0f5d9 => { + box_::arc(metrics, canvas, Corner::Tl, Thickness::Light)?; + } + 0x0f5da => { + vline_middle(metrics, canvas, Thickness::Light); + box_::arc(metrics, canvas, Corner::Tr, Thickness::Light)?; + } + 0x0f5db => { + vline_middle(metrics, canvas, Thickness::Light); + box_::arc(metrics, canvas, Corner::Br, Thickness::Light)?; + } + 0x0f5dc => { + box_::arc(metrics, canvas, Corner::Tr, Thickness::Light)?; + box_::arc(metrics, canvas, Corner::Br, Thickness::Light)?; + } + 0x0f5dd => { + vline_middle(metrics, canvas, Thickness::Light); + box_::arc(metrics, canvas, Corner::Tl, Thickness::Light)?; + } + 0x0f5de => { + vline_middle(metrics, canvas, Thickness::Light); + box_::arc(metrics, canvas, Corner::Bl, Thickness::Light)?; + } + 0x0f5df => { + box_::arc(metrics, canvas, Corner::Tl, Thickness::Light)?; + box_::arc(metrics, canvas, Corner::Bl, Thickness::Light)?; + } + 0x0f5e0 => { + box_::arc(metrics, canvas, Corner::Bl, Thickness::Light)?; + hline_middle(metrics, canvas, Thickness::Light); + } + 0x0f5e1 => { + box_::arc(metrics, canvas, Corner::Br, Thickness::Light)?; + hline_middle(metrics, canvas, Thickness::Light); + } + 0x0f5e2 => { + box_::arc(metrics, canvas, Corner::Br, Thickness::Light)?; + box_::arc(metrics, canvas, Corner::Bl, Thickness::Light)?; + } + 0x0f5e3 => { + box_::arc(metrics, canvas, Corner::Tl, Thickness::Light)?; + hline_middle(metrics, canvas, Thickness::Light); + } + 0x0f5e4 => { + box_::arc(metrics, canvas, Corner::Tr, Thickness::Light)?; + hline_middle(metrics, canvas, Thickness::Light); + } + 0x0f5e5 => { + box_::arc(metrics, canvas, Corner::Tr, Thickness::Light)?; + box_::arc(metrics, canvas, Corner::Tl, Thickness::Light)?; + } + 0x0f5e6 => { + vline_middle(metrics, canvas, Thickness::Light); + box_::arc(metrics, canvas, Corner::Tl, Thickness::Light)?; + box_::arc(metrics, canvas, Corner::Tr, Thickness::Light)?; + } + 0x0f5e7 => { + vline_middle(metrics, canvas, Thickness::Light); + box_::arc(metrics, canvas, Corner::Bl, Thickness::Light)?; + box_::arc(metrics, canvas, Corner::Br, Thickness::Light)?; + } + 0x0f5e8 => { + hline_middle(metrics, canvas, Thickness::Light); + box_::arc(metrics, canvas, Corner::Bl, Thickness::Light)?; + box_::arc(metrics, canvas, Corner::Tl, Thickness::Light)?; + } + 0x0f5e9 => { + hline_middle(metrics, canvas, Thickness::Light); + box_::arc(metrics, canvas, Corner::Tr, Thickness::Light)?; + box_::arc(metrics, canvas, Corner::Br, Thickness::Light)?; + } + 0x0f5ea => { + vline_middle(metrics, canvas, Thickness::Light); + box_::arc(metrics, canvas, Corner::Tl, Thickness::Light)?; + box_::arc(metrics, canvas, Corner::Br, Thickness::Light)?; + } + 0x0f5eb => { + vline_middle(metrics, canvas, Thickness::Light); + box_::arc(metrics, canvas, Corner::Tr, Thickness::Light)?; + box_::arc(metrics, canvas, Corner::Bl, Thickness::Light)?; + } + 0x0f5ec => { + hline_middle(metrics, canvas, Thickness::Light); + box_::arc(metrics, canvas, Corner::Tl, Thickness::Light)?; + box_::arc(metrics, canvas, Corner::Br, Thickness::Light)?; + } + 0x0f5ed => { + hline_middle(metrics, canvas, Thickness::Light); + box_::arc(metrics, canvas, Corner::Tr, Thickness::Light)?; + box_::arc(metrics, canvas, Corner::Bl, Thickness::Light)?; + } + 0x0f5ee => branch_node( + metrics, + canvas, + BranchNode { + up: false, + right: false, + down: false, + left: false, + filled: true, + }, + Thickness::Light, + ), + 0x0f5ef => branch_node( + metrics, + canvas, + BranchNode { + up: false, + right: false, + down: false, + left: false, + filled: false, + }, + Thickness::Light, + ), + 0x0f5f0 => branch_node( + metrics, + canvas, + BranchNode { + up: false, + right: true, + down: false, + left: false, + filled: true, + }, + Thickness::Light, + ), + 0x0f5f1 => branch_node( + metrics, + canvas, + BranchNode { + up: false, + right: true, + down: false, + left: false, + filled: false, + }, + Thickness::Light, + ), + 0x0f5f2 => branch_node( + metrics, + canvas, + BranchNode { + up: false, + right: false, + down: false, + left: true, + filled: true, + }, + Thickness::Light, + ), + 0x0f5f3 => branch_node( + metrics, + canvas, + BranchNode { + up: false, + right: false, + down: false, + left: true, + filled: false, + }, + Thickness::Light, + ), + 0x0f5f4 => branch_node( + metrics, + canvas, + BranchNode { + up: false, + right: true, + down: false, + left: true, + filled: true, + }, + Thickness::Light, + ), + 0x0f5f5 => branch_node( + metrics, + canvas, + BranchNode { + up: false, + right: true, + down: false, + left: true, + filled: false, + }, + Thickness::Light, + ), + 0x0f5f6 => branch_node( + metrics, + canvas, + BranchNode { + up: false, + right: false, + down: true, + left: false, + filled: true, + }, + Thickness::Light, + ), + 0x0f5f7 => branch_node( + metrics, + canvas, + BranchNode { + up: false, + right: false, + down: true, + left: false, + filled: false, + }, + Thickness::Light, + ), + 0x0f5f8 => branch_node( + metrics, + canvas, + BranchNode { + up: true, + right: false, + down: false, + left: false, + filled: true, + }, + Thickness::Light, + ), + 0x0f5f9 => branch_node( + metrics, + canvas, + BranchNode { + up: true, + right: false, + down: false, + left: false, + filled: false, + }, + Thickness::Light, + ), + 0x0f5fa => branch_node( + metrics, + canvas, + BranchNode { + up: true, + right: false, + down: true, + left: false, + filled: true, + }, + Thickness::Light, + ), + 0x0f5fb => branch_node( + metrics, + canvas, + BranchNode { + up: true, + right: false, + down: true, + left: false, + filled: false, + }, + Thickness::Light, + ), + 0x0f5fc => branch_node( + metrics, + canvas, + BranchNode { + up: false, + right: true, + down: true, + left: false, + filled: true, + }, + Thickness::Light, + ), + 0x0f5fd => branch_node( + metrics, + canvas, + BranchNode { + up: false, + right: true, + down: true, + left: false, + filled: false, + }, + Thickness::Light, + ), + 0x0f5fe => branch_node( + metrics, + canvas, + BranchNode { + up: false, + right: false, + down: true, + left: true, + filled: true, + }, + Thickness::Light, + ), + 0x0f5ff => branch_node( + metrics, + canvas, + BranchNode { + up: false, + right: false, + down: true, + left: true, + filled: false, + }, + Thickness::Light, + ), + 0x0f600 => branch_node( + metrics, + canvas, + BranchNode { + up: true, + right: true, + down: false, + left: false, + filled: true, + }, + Thickness::Light, + ), + 0x0f601 => branch_node( + metrics, + canvas, + BranchNode { + up: true, + right: true, + down: false, + left: false, + filled: false, + }, + Thickness::Light, + ), + 0x0f602 => branch_node( + metrics, + canvas, + BranchNode { + up: true, + right: false, + down: false, + left: true, + filled: true, + }, + Thickness::Light, + ), + 0x0f603 => branch_node( + metrics, + canvas, + BranchNode { + up: true, + right: false, + down: false, + left: true, + filled: false, + }, + Thickness::Light, + ), + 0x0f604 => branch_node( + metrics, + canvas, + BranchNode { + up: true, + right: true, + down: true, + left: false, + filled: true, + }, + Thickness::Light, + ), + 0x0f605 => branch_node( + metrics, + canvas, + BranchNode { + up: true, + right: true, + down: true, + left: false, + filled: false, + }, + Thickness::Light, + ), + 0x0f606 => branch_node( + metrics, + canvas, + BranchNode { + up: true, + right: false, + down: true, + left: true, + filled: true, + }, + Thickness::Light, + ), + 0x0f607 => branch_node( + metrics, + canvas, + BranchNode { + up: true, + right: false, + down: true, + left: true, + filled: false, + }, + Thickness::Light, + ), + 0x0f608 => branch_node( + metrics, + canvas, + BranchNode { + up: false, + right: true, + down: true, + left: true, + filled: true, + }, + Thickness::Light, + ), + 0x0f609 => branch_node( + metrics, + canvas, + BranchNode { + up: false, + right: true, + down: true, + left: true, + filled: false, + }, + Thickness::Light, + ), + 0x0f60a => branch_node( + metrics, + canvas, + BranchNode { + up: true, + right: true, + down: false, + left: true, + filled: true, + }, + Thickness::Light, + ), + 0x0f60b => branch_node( + metrics, + canvas, + BranchNode { + up: true, + right: true, + down: false, + left: true, + filled: false, + }, + Thickness::Light, + ), + 0x0f60c => branch_node( + metrics, + canvas, + BranchNode { + up: true, + right: true, + down: true, + left: true, + filled: true, + }, + Thickness::Light, + ), + 0x0f60d => branch_node( + metrics, + canvas, + BranchNode { + up: true, + right: true, + down: true, + left: true, + filled: false, + }, + Thickness::Light, + ), + + _ => unreachable!(), + } + Ok(()) +} + +fn branch_node(metrics: &Metrics, canvas: &mut Canvas, node: BranchNode, thickness: Thickness) { + let thick_px = thickness.height(metrics.box_thickness); + let float_width = f64::from(metrics.cell_width); + let float_height = f64::from(metrics.cell_height); + let float_thick = f64::from(thick_px); + + // Top of horizontal strokes + let h_top = metrics.cell_height.saturating_sub(thick_px) / 2; + // Bottom of horizontal strokes + let h_bottom = h_top.saturating_add(thick_px); + // Left of vertical strokes + let v_left = metrics.cell_width.saturating_sub(thick_px) / 2; + // Right of vertical strokes + let v_right = v_left.saturating_add(thick_px); + + // We calculate the center of the circle this way + // to ensure it aligns with box drawing characters + // since the lines are sometimes off center to + // make sure they aren't split between pixels. + let cx = f64::from(v_left) + float_thick / 2.0; + let cy = f64::from(h_top) + float_thick / 2.0; + // The radius needs to be the smallest distance from the center to an edge. + let r = cx.min(cy).min((float_width - cx).min(float_height - cy)); + + // These float->int casts shouldn't ever fail since r can never + // be greater than cx or cy, so when subtracting it from them the + // result can never be negative. + if node.up { + canvas.box_( + v_left as i32, + 0, + v_right as i32, + (cy - r + float_thick / 2.0).ceil() as i32, + Color::ON, + ); + } + if node.right { + canvas.box_( + (cx + r - float_thick / 2.0).floor() as i32, + h_top as i32, + metrics.cell_width as i32, + h_bottom as i32, + Color::ON, + ); + } + if node.down { + canvas.box_( + v_left as i32, + (cy + r - float_thick / 2.0).floor() as i32, + v_right as i32, + metrics.cell_height as i32, + Color::ON, + ); + } + if node.left { + canvas.box_( + 0, + h_top as i32, + (cx - r + float_thick / 2.0).ceil() as i32, + h_bottom as i32, + Color::ON, + ); + } + + let mut path = Path::new(); + if node.filled { + path.arc(cx, cy, r, 0.0, PI * 2.0); + path.close(); + let _ = canvas.fill_path(&path, Shade::ON.color()); + } else { + path.arc(cx, cy, r - float_thick / 2.0, 0.0, PI * 2.0); + path.close(); + let _ = canvas.stroke_path( + &path, + StrokeOptions { + line_width: float_thick, + line_cap_mode: CapMode::Butt, + }, + Shade::ON.color(), + ); + } +} + +fn fading_line(metrics: &Metrics, canvas: &mut Canvas, to: Edge, thickness: Thickness) { + let thick_px = thickness.height(metrics.box_thickness); + let float_width = f64::from(metrics.cell_width); + let float_height = f64::from(metrics.cell_height); + + // Top of horizontal strokes + let h_top = metrics.cell_height.saturating_sub(thick_px) / 2; + // Bottom of horizontal strokes + let h_bottom = h_top.saturating_add(thick_px); + // Left of vertical strokes + let v_left = metrics.cell_width.saturating_sub(thick_px) / 2; + // Right of vertical strokes + let v_right = v_left.saturating_add(thick_px); + + // If we're fading to the top or left, we start with 0.0 + // and increment up as we progress, otherwise we start + // at 255.0 and increment down (negative). + let mut color: f64 = match to { + Edge::Top | Edge::Left => 0.0, + Edge::Bottom | Edge::Right => 255.0, + }; + let inc: f64 = 255.0 + / match to { + Edge::Top => float_height, + Edge::Bottom => -float_height, + Edge::Left => float_width, + Edge::Right => -float_width, + }; + + match to { + Edge::Top | Edge::Bottom => { + for y in 0..metrics.cell_height { + for x in v_left..v_right { + canvas.pixel(x as i32, y as i32, Color(color.round() as u8)); + } + color += inc; + } + } + Edge::Left | Edge::Right => { + for x in 0..metrics.cell_width { + for y in h_top..h_bottom { + canvas.pixel(x as i32, y as i32, Color(color.round() as u8)); + } + color += inc; + } + } + } +} diff --git a/crates/ghostty-font/src/sprite/draw/common.rs b/crates/ghostty-font/src/sprite/draw/common.rs new file mode 100644 index 0000000..0a2f1ed --- /dev/null +++ b/crates/ghostty-font/src/sprite/draw/common.rs @@ -0,0 +1,397 @@ +//! This file contains a set of useful helper functions +//! and types for drawing our sprite font glyphs. These +//! are generally applicable to multiple sets of glyphs +//! rather than being single-use. Port of `sprite/draw/common.zig`. + +use crate::metrics::Metrics; +use crate::sprite::canvas::{Canvas, Color, DrawError}; + +/// A function that draws a glyph on the provided canvas. +pub type DrawFn = fn( + cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError>; + +/// A range of codepoints (inclusive) and the function that draws them. +/// (Zig discovers these at comptime from `draw_` function names.) +#[derive(Clone, Copy)] +pub struct Range { + pub min: u32, + pub max: u32, + pub draw: DrawFn, +} + +// Utility names for common fractions +pub const ONE_EIGHTH: f64 = 0.125; +pub const ONE_QUARTER: f64 = 0.25; +pub const ONE_THIRD: f64 = 1.0 / 3.0; +pub const THREE_EIGHTHS: f64 = 0.375; +pub const HALF: f64 = 0.5; +pub const FIVE_EIGHTHS: f64 = 0.625; +pub const TWO_THIRDS: f64 = 2.0 / 3.0; +pub const THREE_QUARTERS: f64 = 0.75; +pub const SEVEN_EIGHTHS: f64 = 0.875; + +/// The thickness of a line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Thickness { + SuperLight, + Light, + Heavy, +} + +impl Thickness { + /// Calculate the real height of a line based on its + /// thickness and a base thickness value. The base + /// thickness value is expected to be in pixels. + pub fn height(self, base: u32) -> u32 { + match self { + Thickness::SuperLight => (base / 2).max(1), + Thickness::Light => base, + Thickness::Heavy => base * 2, + } + } +} + +/// Shades. Zig's is a non-exhaustive `enum(u8)`, so any alpha value is valid. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Shade(pub u8); + +impl Shade { + pub const OFF: Shade = Shade(0x00); + pub const LIGHT: Shade = Shade(0x40); + pub const MEDIUM: Shade = Shade(0x80); + pub const DARK: Shade = Shade(0xc0); + pub const ON: Shade = Shade(0xff); + + /// The canvas color with the same alpha value. + pub fn color(self) -> Color { + Color(self.0) + } +} + +/// Applicable to any set of glyphs with features +/// that may be present or not in each quadrant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Quads { + pub tl: bool, + pub tr: bool, + pub bl: bool, + pub br: bool, +} + +/// A corner of a cell. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Corner { + Tl, + Tr, + Bl, + Br, +} + +/// An edge of a cell. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Edge { + Top, + Left, + Bottom, + Right, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HAlign { + Left, + Right, + Center, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VAlign { + Top, + Bottom, + Middle, +} + +/// Alignment of a figure within a cell. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Alignment { + pub horizontal: HAlign, + pub vertical: VAlign, +} + +impl Alignment { + pub const CENTER: Alignment = Alignment { + horizontal: HAlign::Center, + vertical: VAlign::Middle, + }; + pub const UPPER: Alignment = Alignment { + horizontal: HAlign::Center, + vertical: VAlign::Top, + }; + pub const LOWER: Alignment = Alignment { + horizontal: HAlign::Center, + vertical: VAlign::Bottom, + }; + pub const LEFT: Alignment = Alignment { + horizontal: HAlign::Left, + vertical: VAlign::Middle, + }; + pub const RIGHT: Alignment = Alignment { + horizontal: HAlign::Right, + vertical: VAlign::Middle, + }; + + pub const UPPER_LEFT: Alignment = Alignment { + horizontal: HAlign::Left, + vertical: VAlign::Top, + }; + pub const UPPER_RIGHT: Alignment = Alignment { + horizontal: HAlign::Right, + vertical: VAlign::Top, + }; + pub const LOWER_LEFT: Alignment = Alignment { + horizontal: HAlign::Left, + vertical: VAlign::Bottom, + }; + pub const LOWER_RIGHT: Alignment = Alignment { + horizontal: HAlign::Right, + vertical: VAlign::Bottom, + }; + + pub const UPPER_CENTER: Alignment = Self::UPPER; + pub const LOWER_CENTER: Alignment = Self::LOWER; + pub const MIDDLE_LEFT: Alignment = Self::LEFT; + pub const MIDDLE_RIGHT: Alignment = Self::RIGHT; + pub const MIDDLE_CENTER: Alignment = Self::CENTER; + + pub const TOP: Alignment = Self::UPPER; + pub const BOTTOM: Alignment = Self::LOWER; + pub const CENTER_TOP: Alignment = Self::TOP; + pub const CENTER_BOTTOM: Alignment = Self::BOTTOM; + + pub const TOP_LEFT: Alignment = Self::UPPER_LEFT; + pub const TOP_RIGHT: Alignment = Self::UPPER_RIGHT; + pub const BOTTOM_LEFT: Alignment = Self::LOWER_LEFT; + pub const BOTTOM_RIGHT: Alignment = Self::LOWER_RIGHT; +} + +impl Default for Alignment { + fn default() -> Self { + Alignment::CENTER + } +} + +/// A value that indicates some fraction across +/// the cell either horizontally or vertically. +/// +/// This has some redundant names in it so that you can +/// use whichever one feels most semantically appropriate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Fraction { + // Names for the min edge + Start, + Left, + Top, + Zero, + + // Names based on eighths + Eighth, + OneEighth, + TwoEighths, + ThreeEighths, + FourEighths, + FiveEighths, + SixEighths, + SevenEighths, + + // Names based on quarters + Quarter, + OneQuarter, + TwoQuarters, + ThreeQuarters, + + // Names based on thirds + Third, + OneThird, + TwoThirds, + + // Names based on halves + Half, + OneHalf, + + // Alternative names for 1/2 + Center, + Middle, + + // Names for the max edge + End, + Right, + Bottom, + One, + Full, +} + +impl Fraction { + /// This can be indexed to get the fraction for `i/8`. + pub const EIGHTHS: [Fraction; 9] = [ + Fraction::Zero, + Fraction::OneEighth, + Fraction::TwoEighths, + Fraction::ThreeEighths, + Fraction::FourEighths, + Fraction::FiveEighths, + Fraction::SixEighths, + Fraction::SevenEighths, + Fraction::One, + ]; + + /// This can be indexed to get the fraction for `i/4`. + pub const QUARTERS: [Fraction; 5] = [ + Fraction::Zero, + Fraction::OneQuarter, + Fraction::TwoQuarters, + Fraction::ThreeQuarters, + Fraction::One, + ]; + + /// This can be indexed to get the fraction for `i/3`. + pub const THIRDS: [Fraction; 4] = [ + Fraction::Zero, + Fraction::OneThird, + Fraction::TwoThirds, + Fraction::One, + ]; + + /// This can be indexed to get the fraction for `i/2`. + pub const HALVES: [Fraction; 3] = [Fraction::Zero, Fraction::OneHalf, Fraction::One]; + + /// Get the x position for this fraction across a particular + /// size (width or height), assuming it will be used as the + /// min (left/top) coordinate for a block. + pub fn min(self, size: u32) -> i32 { + let s = f64::from(size); + // For min coordinates, we want to align with the complementary + // fraction taken from the end, this ensures that rounding evens + // out, so that for example, if `size` is `7`, and we're looking + // at the `half` line, `size - round((1 - 0.5) * size)` => `3`; + // whereas the max coordinate directly rounds, which means that + // both `start` -> `half` and `half` -> `end` will be 4px, from + // `0` -> `4` and `3` -> `7`. + (s - ((1.0 - self.fraction()) * s).round()) as i32 + } + + /// Get the x position for this fraction across a particular + /// size (width or height), assuming it will be used as the + /// max (right/bottom) coordinate for a block. + pub fn max(self, size: u32) -> i32 { + let s = f64::from(size); + // See explanation of why these are different in `min`. + (self.fraction() * s).round() as i32 + } + + /// Get this fraction across a particular size (width/height). + /// If you need an integer, use `min` or `max` instead, since + /// they contain special logic for consistent alignment. This + /// is for when you're drawing with paths and don't care about + /// pixel alignment. + pub fn float(self, size: u32) -> f64 { + self.fraction() * f64::from(size) + } + + /// Get a float for the fraction this represents. + pub fn fraction(self) -> f64 { + #[allow(clippy::enum_glob_use)] + use Fraction::*; + match self { + Start | Left | Top | Zero => 0.0, + Eighth | OneEighth => 0.125, + Quarter | OneQuarter | TwoEighths => 0.25, + Third | OneThird => 1.0 / 3.0, + ThreeEighths => 0.375, + Half | OneHalf | TwoQuarters | FourEighths | Center | Middle => 0.5, + FiveEighths => 0.625, + TwoThirds => 2.0 / 3.0, + ThreeQuarters | SixEighths => 0.75, + SevenEighths => 0.875, + End | Right | Bottom | One | Full => 1.0, + } + } +} + +/// Fill a section of the cell, specified by a +/// horizontal and vertical pair of fraction lines. +pub fn fill( + metrics: &Metrics, + canvas: &mut Canvas, + x0: Fraction, + x1: Fraction, + y0: Fraction, + y1: Fraction, +) { + canvas.box_( + x0.min(metrics.cell_width), + y0.min(metrics.cell_height), + x1.max(metrics.cell_width), + y1.max(metrics.cell_height), + Color::ON, + ); +} + +/// Centered vertical line of the provided thickness. +pub fn vline_middle(metrics: &Metrics, canvas: &mut Canvas, thickness: Thickness) { + let thick_px = thickness.height(metrics.box_thickness); + vline( + canvas, + 0, + metrics.cell_height as i32, + (metrics.cell_width.saturating_sub(thick_px) / 2) as i32, + thick_px, + ); +} + +/// Centered horizontal line of the provided thickness. +pub fn hline_middle(metrics: &Metrics, canvas: &mut Canvas, thickness: Thickness) { + let thick_px = thickness.height(metrics.box_thickness); + hline( + canvas, + 0, + metrics.cell_width as i32, + (metrics.cell_height.saturating_sub(thick_px) / 2) as i32, + thick_px, + ); +} + +/// Vertical line with the left edge at `x`, between `y1` and `y2`. +pub fn vline(canvas: &mut Canvas, y1: i32, y2: i32, x: i32, thickness_px: u32) { + canvas.box_(x, y1, x + thickness_px as i32, y2, Color::ON); +} + +/// Horizontal line with the top edge at `y`, between `x1` and `x2`. +pub fn hline(canvas: &mut Canvas, x1: i32, x2: i32, y: i32, thickness_px: u32) { + canvas.box_(x1, y, x2, y + thickness_px as i32, Color::ON); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fraction_min_max_even_out() { + // size 7, half: min = 7 - round(3.5) = 3, max = round(3.5) = 4 + assert_eq!(3, Fraction::Half.min(7)); + assert_eq!(4, Fraction::Half.max(7)); + assert_eq!(0, Fraction::Zero.min(7)); + assert_eq!(7, Fraction::Full.max(7)); + } + + #[test] + fn thickness_heights() { + assert_eq!(1, Thickness::SuperLight.height(1)); + assert_eq!(2, Thickness::SuperLight.height(4)); + assert_eq!(3, Thickness::Light.height(3)); + assert_eq!(6, Thickness::Heavy.height(3)); + } +} diff --git a/crates/ghostty-font/src/sprite/draw/geometric_shapes.rs b/crates/ghostty-font/src/sprite/draw/geometric_shapes.rs new file mode 100644 index 0000000..60092c2 --- /dev/null +++ b/crates/ghostty-font/src/sprite/draw/geometric_shapes.rs @@ -0,0 +1,143 @@ +//! Geometric Shapes | U+25A0...U+25FF +//! +//! +//! ■ □ ▢ ▣ ▤ ▥ ▦ ▧ ▨ ▩ ▪ ▫ ▬ ▭ ▮ ▯ +//! ▰ ▱ ▲ △ ▴ ▵ ▶ ▷ ▸ ▹ ► ▻ ▼ ▽ ▾ ▿ +//! ◀ ◁ ◂ ◃ ◄ ◅ ◆ ◇ ◈ ◉ ◊ ○ ◌ ◍ ◎ ● +//! ◐ ◑ ◒ ◓ ◔ ◕ ◖ ◗ ◘ ◙ ◚ ◛ ◜ ◝ ◞ ◟ +//! ◠ ◡ ◢ ◣ ◤ ◥ ◦ ◧ ◨ ◩ ◪ ◫ ◬ ◭ ◮ ◯ +//! ◰ ◱ ◲ ◳ ◴ ◵ ◶ ◷ ◸ ◹ ◺ ◻ ◼ ◽︎◾︎◿ +//! +//! Only a subset of this block is viable for sprite drawing; filling +//! out this file to have full coverage of this block is not the goal. +//! +//! Port of `sprite/draw/geometric_shapes.zig`. + +use super::common::{Corner, DrawFn, Range, Shade, Thickness}; +use crate::metrics::Metrics; +use crate::sprite::canvas::{Canvas, CapMode, Color, DrawError, Path, StrokeOptions}; + +pub const RANGES: &[Range] = &[ + Range { + min: 0x25e2, + max: 0x25e5, + draw: draw25e2_25e5 as DrawFn, + }, + Range { + min: 0x25f8, + max: 0x25fa, + draw: draw25f8_25fa as DrawFn, + }, + Range { + min: 0x25ff, + max: 0x25ff, + draw: draw25ff as DrawFn, + }, +]; + +/// ◢ ◣ ◤ ◥ +pub fn draw25e2_25e5( + cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + match cp { + // ◢ + 0x25e2 => corner_triangle_shade(metrics, canvas, Corner::Br, Shade::ON), + // ◣ + 0x25e3 => corner_triangle_shade(metrics, canvas, Corner::Bl, Shade::ON), + // ◤ + 0x25e4 => corner_triangle_shade(metrics, canvas, Corner::Tl, Shade::ON), + // ◥ + 0x25e5 => corner_triangle_shade(metrics, canvas, Corner::Tr, Shade::ON), + + _ => unreachable!(), + } +} + +/// ◸ ◹ ◺ +pub fn draw25f8_25fa( + cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + match cp { + // ◸ + 0x25f8 => corner_triangle_outline(metrics, canvas, Corner::Tl), + // ◹ + 0x25f9 => corner_triangle_outline(metrics, canvas, Corner::Tr), + // ◺ + 0x25fa => corner_triangle_outline(metrics, canvas, Corner::Bl), + + _ => unreachable!(), + } +} + +/// ◿ +pub fn draw25ff( + _cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + corner_triangle_outline(metrics, canvas, Corner::Br) +} + +fn corner_path(metrics: &Metrics, corner: Corner) -> Path { + let float_width = f64::from(metrics.cell_width); + let float_height = f64::from(metrics.cell_height); + + let (x0, y0, x1, y1, x2, y2) = match corner { + Corner::Tl => (0.0, 0.0, 0.0, float_height, float_width, 0.0), + Corner::Tr => (0.0, 0.0, float_width, float_height, float_width, 0.0), + Corner::Bl => (0.0, 0.0, 0.0, float_height, float_width, float_height), + Corner::Br => ( + 0.0, + float_height, + float_width, + float_height, + float_width, + 0.0, + ), + }; + + let mut path = Path::new(); + path.move_to(x0, y0); + path.line_to(x1, y1); + path.line_to(x2, y2); + path.close(); + path +} + +pub fn corner_triangle_shade( + metrics: &Metrics, + canvas: &mut Canvas, + corner: Corner, + shade: Shade, +) -> Result<(), DrawError> { + let path = corner_path(metrics, corner); + canvas.fill_path(&path, Color(shade.0)) +} + +pub fn corner_triangle_outline( + metrics: &Metrics, + canvas: &mut Canvas, + corner: Corner, +) -> Result<(), DrawError> { + let float_thick = f64::from(Thickness::Light.height(metrics.box_thickness)); + let path = corner_path(metrics, corner); + + canvas.inner_stroke_path( + &path, + StrokeOptions { + line_cap_mode: CapMode::Butt, + line_width: float_thick, + }, + Color::ON, + ) +} diff --git a/crates/ghostty-font/src/sprite/draw/mod.rs b/crates/ghostty-font/src/sprite/draw/mod.rs new file mode 100644 index 0000000..4a1fb48 --- /dev/null +++ b/crates/ghostty-font/src/sprite/draw/mod.rs @@ -0,0 +1,16 @@ +//! Glyph drawing functions for the sprite font. Port of `font/sprite/draw/*.zig`. +//! +//! Each submodule exposes `RANGES`, a table of the codepoint ranges (inclusive) +//! it can draw. In Zig these are discovered at comptime from function names of +//! the form `draw` / `draw_`. + +pub mod block; +pub mod box_; +pub mod braille; +pub mod branch; +pub mod common; +pub mod geometric_shapes; +pub mod powerline; +pub mod special; +pub mod symbols_for_legacy_computing; +pub mod symbols_for_legacy_computing_supplement; diff --git a/crates/ghostty-font/src/sprite/draw/octants.txt b/crates/ghostty-font/src/sprite/draw/octants.txt new file mode 100644 index 0000000..db79aa2 --- /dev/null +++ b/crates/ghostty-font/src/sprite/draw/octants.txt @@ -0,0 +1,234 @@ +# This is the list of all the octants for the Symbols for Legacy +# Computing block. It is used at comptime to generate the lookup +# table for drawing them since we weren't able to discern a +# mathematical pattern for them. +BLOCK OCTANT-3 +BLOCK OCTANT-23 +BLOCK OCTANT-123 +BLOCK OCTANT-4 +BLOCK OCTANT-14 +BLOCK OCTANT-124 +BLOCK OCTANT-34 +BLOCK OCTANT-134 +BLOCK OCTANT-234 +BLOCK OCTANT-5 +BLOCK OCTANT-15 +BLOCK OCTANT-25 +BLOCK OCTANT-125 +BLOCK OCTANT-135 +BLOCK OCTANT-235 +BLOCK OCTANT-1235 +BLOCK OCTANT-45 +BLOCK OCTANT-145 +BLOCK OCTANT-245 +BLOCK OCTANT-1245 +BLOCK OCTANT-345 +BLOCK OCTANT-1345 +BLOCK OCTANT-2345 +BLOCK OCTANT-12345 +BLOCK OCTANT-6 +BLOCK OCTANT-16 +BLOCK OCTANT-26 +BLOCK OCTANT-126 +BLOCK OCTANT-36 +BLOCK OCTANT-136 +BLOCK OCTANT-236 +BLOCK OCTANT-1236 +BLOCK OCTANT-146 +BLOCK OCTANT-246 +BLOCK OCTANT-1246 +BLOCK OCTANT-346 +BLOCK OCTANT-1346 +BLOCK OCTANT-2346 +BLOCK OCTANT-12346 +BLOCK OCTANT-56 +BLOCK OCTANT-156 +BLOCK OCTANT-256 +BLOCK OCTANT-1256 +BLOCK OCTANT-356 +BLOCK OCTANT-1356 +BLOCK OCTANT-2356 +BLOCK OCTANT-12356 +BLOCK OCTANT-456 +BLOCK OCTANT-1456 +BLOCK OCTANT-2456 +BLOCK OCTANT-12456 +BLOCK OCTANT-3456 +BLOCK OCTANT-13456 +BLOCK OCTANT-23456 +BLOCK OCTANT-17 +BLOCK OCTANT-27 +BLOCK OCTANT-127 +BLOCK OCTANT-37 +BLOCK OCTANT-137 +BLOCK OCTANT-237 +BLOCK OCTANT-1237 +BLOCK OCTANT-47 +BLOCK OCTANT-147 +BLOCK OCTANT-247 +BLOCK OCTANT-1247 +BLOCK OCTANT-347 +BLOCK OCTANT-1347 +BLOCK OCTANT-2347 +BLOCK OCTANT-12347 +BLOCK OCTANT-157 +BLOCK OCTANT-257 +BLOCK OCTANT-1257 +BLOCK OCTANT-357 +BLOCK OCTANT-2357 +BLOCK OCTANT-12357 +BLOCK OCTANT-457 +BLOCK OCTANT-1457 +BLOCK OCTANT-12457 +BLOCK OCTANT-3457 +BLOCK OCTANT-13457 +BLOCK OCTANT-23457 +BLOCK OCTANT-67 +BLOCK OCTANT-167 +BLOCK OCTANT-267 +BLOCK OCTANT-1267 +BLOCK OCTANT-367 +BLOCK OCTANT-1367 +BLOCK OCTANT-2367 +BLOCK OCTANT-12367 +BLOCK OCTANT-467 +BLOCK OCTANT-1467 +BLOCK OCTANT-2467 +BLOCK OCTANT-12467 +BLOCK OCTANT-3467 +BLOCK OCTANT-13467 +BLOCK OCTANT-23467 +BLOCK OCTANT-123467 +BLOCK OCTANT-567 +BLOCK OCTANT-1567 +BLOCK OCTANT-2567 +BLOCK OCTANT-12567 +BLOCK OCTANT-3567 +BLOCK OCTANT-13567 +BLOCK OCTANT-23567 +BLOCK OCTANT-123567 +BLOCK OCTANT-4567 +BLOCK OCTANT-14567 +BLOCK OCTANT-24567 +BLOCK OCTANT-124567 +BLOCK OCTANT-34567 +BLOCK OCTANT-134567 +BLOCK OCTANT-234567 +BLOCK OCTANT-1234567 +BLOCK OCTANT-18 +BLOCK OCTANT-28 +BLOCK OCTANT-128 +BLOCK OCTANT-38 +BLOCK OCTANT-138 +BLOCK OCTANT-238 +BLOCK OCTANT-1238 +BLOCK OCTANT-48 +BLOCK OCTANT-148 +BLOCK OCTANT-248 +BLOCK OCTANT-1248 +BLOCK OCTANT-348 +BLOCK OCTANT-1348 +BLOCK OCTANT-2348 +BLOCK OCTANT-12348 +BLOCK OCTANT-58 +BLOCK OCTANT-158 +BLOCK OCTANT-258 +BLOCK OCTANT-1258 +BLOCK OCTANT-358 +BLOCK OCTANT-1358 +BLOCK OCTANT-2358 +BLOCK OCTANT-12358 +BLOCK OCTANT-458 +BLOCK OCTANT-1458 +BLOCK OCTANT-2458 +BLOCK OCTANT-12458 +BLOCK OCTANT-3458 +BLOCK OCTANT-13458 +BLOCK OCTANT-23458 +BLOCK OCTANT-123458 +BLOCK OCTANT-168 +BLOCK OCTANT-268 +BLOCK OCTANT-1268 +BLOCK OCTANT-368 +BLOCK OCTANT-2368 +BLOCK OCTANT-12368 +BLOCK OCTANT-468 +BLOCK OCTANT-1468 +BLOCK OCTANT-12468 +BLOCK OCTANT-3468 +BLOCK OCTANT-13468 +BLOCK OCTANT-23468 +BLOCK OCTANT-568 +BLOCK OCTANT-1568 +BLOCK OCTANT-2568 +BLOCK OCTANT-12568 +BLOCK OCTANT-3568 +BLOCK OCTANT-13568 +BLOCK OCTANT-23568 +BLOCK OCTANT-123568 +BLOCK OCTANT-4568 +BLOCK OCTANT-14568 +BLOCK OCTANT-24568 +BLOCK OCTANT-124568 +BLOCK OCTANT-34568 +BLOCK OCTANT-134568 +BLOCK OCTANT-234568 +BLOCK OCTANT-1234568 +BLOCK OCTANT-178 +BLOCK OCTANT-278 +BLOCK OCTANT-1278 +BLOCK OCTANT-378 +BLOCK OCTANT-1378 +BLOCK OCTANT-2378 +BLOCK OCTANT-12378 +BLOCK OCTANT-478 +BLOCK OCTANT-1478 +BLOCK OCTANT-2478 +BLOCK OCTANT-12478 +BLOCK OCTANT-3478 +BLOCK OCTANT-13478 +BLOCK OCTANT-23478 +BLOCK OCTANT-123478 +BLOCK OCTANT-578 +BLOCK OCTANT-1578 +BLOCK OCTANT-2578 +BLOCK OCTANT-12578 +BLOCK OCTANT-3578 +BLOCK OCTANT-13578 +BLOCK OCTANT-23578 +BLOCK OCTANT-123578 +BLOCK OCTANT-4578 +BLOCK OCTANT-14578 +BLOCK OCTANT-24578 +BLOCK OCTANT-124578 +BLOCK OCTANT-34578 +BLOCK OCTANT-134578 +BLOCK OCTANT-234578 +BLOCK OCTANT-1234578 +BLOCK OCTANT-678 +BLOCK OCTANT-1678 +BLOCK OCTANT-2678 +BLOCK OCTANT-12678 +BLOCK OCTANT-3678 +BLOCK OCTANT-13678 +BLOCK OCTANT-23678 +BLOCK OCTANT-123678 +BLOCK OCTANT-4678 +BLOCK OCTANT-14678 +BLOCK OCTANT-24678 +BLOCK OCTANT-124678 +BLOCK OCTANT-34678 +BLOCK OCTANT-134678 +BLOCK OCTANT-234678 +BLOCK OCTANT-1234678 +BLOCK OCTANT-15678 +BLOCK OCTANT-25678 +BLOCK OCTANT-125678 +BLOCK OCTANT-35678 +BLOCK OCTANT-235678 +BLOCK OCTANT-1235678 +BLOCK OCTANT-45678 +BLOCK OCTANT-145678 +BLOCK OCTANT-1245678 +BLOCK OCTANT-1345678 +BLOCK OCTANT-2345678 diff --git a/crates/ghostty-font/src/sprite/draw/powerline.rs b/crates/ghostty-font/src/sprite/draw/powerline.rs new file mode 100644 index 0000000..f40e1ad --- /dev/null +++ b/crates/ghostty-font/src/sprite/draw/powerline.rs @@ -0,0 +1,496 @@ +//! Powerline + Powerline Extra Symbols | U+E0B0...U+E0D4 +//! +//! +//! +//! +//! +//! +//! We implement the more geometric glyphs here, but not the stylized ones. +//! +//! Port of `sprite/draw/powerline.zig`. + +use std::f64::consts::SQRT_2; + +use super::box_; +use super::common::{DrawFn, Range, Thickness}; +use crate::metrics::Metrics; +use crate::sprite::canvas::{ + Canvas, CapMode, Color, DrawError, Path, Point, StrokeOptions, Triangle, +}; + +pub const RANGES: &[Range] = &[ + Range { + min: 0xe0b0, + max: 0xe0b0, + draw: drawe0b0 as DrawFn, + }, + Range { + min: 0xe0b1, + max: 0xe0b1, + draw: drawe0b1 as DrawFn, + }, + Range { + min: 0xe0b2, + max: 0xe0b2, + draw: drawe0b2 as DrawFn, + }, + Range { + min: 0xe0b3, + max: 0xe0b3, + draw: drawe0b3 as DrawFn, + }, + Range { + min: 0xe0b4, + max: 0xe0b4, + draw: drawe0b4 as DrawFn, + }, + Range { + min: 0xe0b5, + max: 0xe0b5, + draw: drawe0b5 as DrawFn, + }, + Range { + min: 0xe0b6, + max: 0xe0b6, + draw: drawe0b6 as DrawFn, + }, + Range { + min: 0xe0b7, + max: 0xe0b7, + draw: drawe0b7 as DrawFn, + }, + Range { + min: 0xe0b8, + max: 0xe0b8, + draw: drawe0b8 as DrawFn, + }, + Range { + min: 0xe0b9, + max: 0xe0b9, + draw: drawe0b9 as DrawFn, + }, + Range { + min: 0xe0ba, + max: 0xe0ba, + draw: drawe0ba as DrawFn, + }, + Range { + min: 0xe0bb, + max: 0xe0bb, + draw: drawe0bb as DrawFn, + }, + Range { + min: 0xe0bc, + max: 0xe0bc, + draw: drawe0bc as DrawFn, + }, + Range { + min: 0xe0bd, + max: 0xe0bd, + draw: drawe0bd as DrawFn, + }, + Range { + min: 0xe0be, + max: 0xe0be, + draw: drawe0be as DrawFn, + }, + Range { + min: 0xe0bf, + max: 0xe0bf, + draw: drawe0bf as DrawFn, + }, + Range { + min: 0xe0d2, + max: 0xe0d2, + draw: drawe0d2 as DrawFn, + }, + Range { + min: 0xe0d4, + max: 0xe0d4, + draw: drawe0d4 as DrawFn, + }, +]; + +/// E0B0 +pub fn drawe0b0( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + _metrics: &Metrics, +) -> Result<(), DrawError> { + let float_width = f64::from(width); + let float_height = f64::from(height); + canvas.triangle( + Triangle { + p0: Point { x: 0.0, y: 0.0 }, + p1: Point { + x: float_width, + y: float_height / 2.0, + }, + p2: Point { + x: 0.0, + y: float_height, + }, + }, + Color::ON, + ) +} + +/// E0B2 +pub fn drawe0b2( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + _metrics: &Metrics, +) -> Result<(), DrawError> { + let float_width = f64::from(width); + let float_height = f64::from(height); + canvas.triangle( + Triangle { + p0: Point { + x: float_width, + y: 0.0, + }, + p1: Point { + x: 0.0, + y: float_height / 2.0, + }, + p2: Point { + x: float_width, + y: float_height, + }, + }, + Color::ON, + ) +} + +/// E0B8 +pub fn drawe0b8( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + _metrics: &Metrics, +) -> Result<(), DrawError> { + let float_width = f64::from(width); + let float_height = f64::from(height); + canvas.triangle( + Triangle { + p0: Point { x: 0.0, y: 0.0 }, + p1: Point { + x: float_width, + y: float_height, + }, + p2: Point { + x: 0.0, + y: float_height, + }, + }, + Color::ON, + ) +} + +/// E0BA +pub fn drawe0ba( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + _metrics: &Metrics, +) -> Result<(), DrawError> { + let float_width = f64::from(width); + let float_height = f64::from(height); + canvas.triangle( + Triangle { + p0: Point { + x: float_width, + y: 0.0, + }, + p1: Point { + x: float_width, + y: float_height, + }, + p2: Point { + x: 0.0, + y: float_height, + }, + }, + Color::ON, + ) +} + +/// E0BC +pub fn drawe0bc( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + _metrics: &Metrics, +) -> Result<(), DrawError> { + let float_width = f64::from(width); + let float_height = f64::from(height); + canvas.triangle( + Triangle { + p0: Point { x: 0.0, y: 0.0 }, + p1: Point { + x: float_width, + y: 0.0, + }, + p2: Point { + x: 0.0, + y: float_height, + }, + }, + Color::ON, + ) +} + +/// E0BE +pub fn drawe0be( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + _metrics: &Metrics, +) -> Result<(), DrawError> { + let float_width = f64::from(width); + let float_height = f64::from(height); + canvas.triangle( + Triangle { + p0: Point { x: 0.0, y: 0.0 }, + p1: Point { + x: float_width, + y: 0.0, + }, + p2: Point { + x: float_width, + y: float_height, + }, + }, + Color::ON, + ) +} + +/// E0B9 +pub fn drawe0b9( + _cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + box_::light_diagonal_upper_left_to_lower_right(metrics, canvas); + Ok(()) +} + +/// E0BB +pub fn drawe0bb( + _cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + box_::light_diagonal_upper_right_to_lower_left(metrics, canvas); + Ok(()) +} + +/// E0BD +pub fn drawe0bd( + _cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + box_::light_diagonal_upper_right_to_lower_left(metrics, canvas); + Ok(()) +} + +/// E0BF +pub fn drawe0bf( + _cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + box_::light_diagonal_upper_left_to_lower_right(metrics, canvas); + Ok(()) +} + +/// E0B1 +pub fn drawe0b1( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + let float_width = f64::from(width); + let float_height = f64::from(height); + + let mut path = Path::new(); + path.move_to(0.0, 0.0); + path.line_to(float_width, float_height / 2.0); + path.line_to(0.0, float_height); + + canvas.stroke_path( + &path, + StrokeOptions { + line_cap_mode: CapMode::Butt, + line_width: f64::from(Thickness::Light.height(metrics.box_thickness)), + }, + Color::ON, + ) +} + +/// E0B3 +pub fn drawe0b3( + cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + drawe0b1(cp, canvas, width, height, metrics)?; + canvas.flip_horizontal(); + Ok(()) +} + +/// Path for the rounded "half circle" shapes E0B4 / E0B5 (open path; E0B4 +/// closes it itself). +fn rounded_path(width: u32, height: u32) -> Path { + let float_width = f64::from(width); + let float_height = f64::from(height); + + // Coefficient for approximating a circular arc. + let c: f64 = (SQRT_2 - 1.0) * 4.0 / 3.0; + + let radius: f64 = float_width.min(float_height / 2.0); + + let mut path = Path::new(); + path.move_to(0.0, 0.0); + path.curve_to(radius * c, 0.0, radius, radius - radius * c, radius, radius); + path.line_to(radius, float_height - radius); + path.curve_to( + radius, + float_height - radius + radius * c, + radius * c, + float_height, + 0.0, + float_height, + ); + path +} + +/// E0B4 +pub fn drawe0b4( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + _metrics: &Metrics, +) -> Result<(), DrawError> { + let mut path = rounded_path(width, height); + path.close(); + canvas.fill_path(&path, Color::ON) +} + +/// E0B5 +pub fn drawe0b5( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + let path = rounded_path(width, height); + canvas.inner_stroke_path( + &path, + StrokeOptions { + line_width: f64::from(metrics.box_thickness), + line_cap_mode: CapMode::Butt, + }, + Color::ON, + ) +} + +/// E0B6 +pub fn drawe0b6( + cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + drawe0b4(cp, canvas, width, height, metrics)?; + canvas.flip_horizontal(); + Ok(()) +} + +/// E0B7 +pub fn drawe0b7( + cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + drawe0b5(cp, canvas, width, height, metrics)?; + canvas.flip_horizontal(); + Ok(()) +} + +/// E0D2 +pub fn drawe0d2( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + let float_width = f64::from(width); + let float_height = f64::from(height); + let float_thick = f64::from(metrics.box_thickness); + + // Top piece + { + let mut path = Path::new(); + path.move_to(0.0, 0.0); + path.line_to(float_width, 0.0); + path.line_to(float_width / 2.0, float_height / 2.0 - float_thick / 2.0); + path.line_to(0.0, float_height / 2.0 - float_thick / 2.0); + path.close(); + + canvas.fill_path(&path, Color::ON)?; + } + + // Bottom piece + { + let mut path = Path::new(); + path.move_to(0.0, float_height); + path.line_to(float_width, float_height); + path.line_to(float_width / 2.0, float_height / 2.0 + float_thick / 2.0); + path.line_to(0.0, float_height / 2.0 + float_thick / 2.0); + path.close(); + + canvas.fill_path(&path, Color::ON)?; + } + Ok(()) +} + +/// E0D4 +pub fn drawe0d4( + cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + drawe0d2(cp, canvas, width, height, metrics)?; + canvas.flip_horizontal(); + Ok(()) +} diff --git a/crates/ghostty-font/src/sprite/draw/special.rs b/crates/ghostty-font/src/sprite/draw/special.rs new file mode 100644 index 0000000..dc86c0e --- /dev/null +++ b/crates/ghostty-font/src/sprite/draw/special.rs @@ -0,0 +1,383 @@ +//! This file contains glyph drawing functions for all of the +//! non-Unicode sprite glyphs, such as cursors and underlines. +//! Port of `sprite/draw/special.zig`. +//! +//! The function names match the variants of [`Sprite`](crate::sprite::Sprite). + +use std::f64::consts::{FRAC_1_SQRT_2, PI, TAU}; + +use crate::metrics::Metrics; +use crate::sprite::canvas::{Canvas, CapMode, Color, DrawError, Path, Rect, StrokeOptions}; + +pub fn underline( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + // We can go beyond the height of the cell a bit, but + // we want to be sure never to exceed the height of the + // canvas, which extends a quarter cell below the cell + // height. + let y = metrics.underline_position.min( + height + .saturating_add(canvas.padding_y) + .saturating_sub(metrics.underline_thickness), + ); + + canvas.rect( + Rect { + x: 0, + y: y as i32, + width: width as i32, + height: metrics.underline_thickness as i32, + }, + Color::ON, + ); + Ok(()) +} + +pub fn underline_double( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + // We can go beyond the height of the cell a bit, but + // we want to be sure never to exceed the height of the + // canvas, which extends a quarter cell below the cell + // height. + let y = metrics.underline_position.min( + height + .saturating_add(canvas.padding_y) + .saturating_sub(2 * metrics.underline_thickness), + ); + + // We place one underline above the underline position, and one below + // by one thickness, creating a "negative" underline where the single + // underline would be placed. + canvas.rect( + Rect { + x: 0, + y: y.saturating_sub(metrics.underline_thickness) as i32, + width: width as i32, + height: metrics.underline_thickness as i32, + }, + Color::ON, + ); + canvas.rect( + Rect { + x: 0, + y: y.saturating_add(metrics.underline_thickness) as i32, + width: width as i32, + height: metrics.underline_thickness as i32, + }, + Color::ON, + ); + Ok(()) +} + +pub fn underline_dotted( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + let float_width = f64::from(width); + let float_height = f64::from(height); + let float_pos = f64::from(metrics.underline_position); + let float_thick = f64::from(metrics.underline_thickness); + + // The diameter will be sqrt2 * the usual underline thickness + // since otherwise dotted underlines look somewhat anemic. + let radius = FRAC_1_SQRT_2 * float_thick; + + // We can go beyond the height of the cell a bit, but + // we want to be sure never to exceed the height of the + // canvas, which extends a quarter cell below the cell + // height. + let padding = f64::from(canvas.padding_y); + let y = ( + // The center of the underline stem. + float_pos + 0.5 * float_thick + ) + .min( + // The lowest we can go on the canvas and not get clipped. + float_height + padding - radius.ceil(), + ); + + let dot_count = ( + // We should try to have enough dots that the + // space between them matches their diameter. + (float_width / (4.0 * radius)) + .ceil() + // And not enough that the space between + // each dot is less than their radius. + .min((float_width / (3.0 * radius)).floor()) + // And definitely not enough that the space + // between them is less than a single pixel. + .min((float_width / (2.0 * radius + 1.0)).floor()) + ) + // And we must have at least one dot per cell. + .max(1.0); + + // What we essentially do is divide the cell in to + // dot_count areas with a dot centered in each one. + let mut path = Path::new(); + let mut x = (float_width / dot_count) / 2.0; + for _ in 0..(dot_count as usize) { + path.arc(x, y, radius, 0.0, TAU); + path.close(); + x += float_width / dot_count; + } + + canvas.fill_path(&path, Color::ON) +} + +pub fn underline_dashed( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + // We can go beyond the height of the cell a bit, but + // we want to be sure never to exceed the height of the + // canvas, which extends a quarter cell below the cell + // height. + let y = metrics.underline_position.min( + height + .saturating_add(canvas.padding_y) + .saturating_sub(metrics.underline_thickness), + ); + + let dash_width = width / 3 + 1; + let dash_count = (width / dash_width) + 1; + let mut i = 0; + while i < dash_count { + let x = i * dash_width; + canvas.rect( + Rect { + x: x as i32, + y: y as i32, + width: dash_width as i32, + height: metrics.underline_thickness as i32, + }, + Color::ON, + ); + i += 2; + } + Ok(()) +} + +pub fn underline_curly( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + let float_width = f64::from(width); + let float_height = f64::from(height); + let float_pos = f64::from(metrics.underline_position); + + // Because of we way we draw the undercurl, we end up making it around 1px + // thicker than it should be, to fix this we just reduce the thickness by 1. + // + // We use a minimum thickness of 0.414 because this empirically produces + // the nicest undercurls at 1px underline thickness; thinner tends to look + // too thin compared to straight underlines and has artefacting. + let line_width = f64::from(metrics.underline_thickness); + + // Empirically this looks good. + let amplitude = float_width / PI; + + // Make sure we don't exceed the drawable area. This can still be outside + // of the cell by some amount (one quarter of the height), but we don't + // want underlines to disappear for fonts with bad metadata or when users + // set their underline position way too low. + let padding = f64::from(canvas.padding_y); + let top = float_pos.min( + // The lowest we can draw this and not get clipped. + float_height + padding - amplitude - line_width, + ); + let bottom = top + amplitude; + + // Curvature multiplier. + // To my eye, 0.4 creates a nice smooth wiggle. + let r = 0.4; + + let center = 0.5 * float_width; + + // We create a single cycle of a wave that peaks at the center of the cell. + let mut path = Path::new(); + path.move_to(0.0, bottom); + path.curve_to(center * r, bottom, center - center * r, top, center, top); + path.curve_to( + center + center * r, + top, + float_width - center * r, + bottom, + float_width, + bottom, + ); + canvas.stroke_path( + &path, + StrokeOptions { + line_width, + // Rounded caps, adjacent underlines will have these overlap and so not be + // visible, but it makes the ends look cleaner. + line_cap_mode: CapMode::Round, + }, + Color::ON, + ) +} + +pub fn strikethrough( + _cp: u32, + canvas: &mut Canvas, + width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + canvas.rect( + Rect { + x: 0, + y: metrics.strikethrough_position as i32, + width: width as i32, + height: metrics.strikethrough_thickness as i32, + }, + Color::ON, + ); + Ok(()) +} + +pub fn overline( + _cp: u32, + canvas: &mut Canvas, + width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + // We can go beyond the top of the cell a bit, but we + // want to be sure never to exceed the height of the + // canvas, which extends a quarter cell above the top + // of the cell. + let y = metrics.overline_position.max(-(canvas.padding_y as i32)); + + canvas.rect( + Rect { + x: 0, + y, + width: width as i32, + height: metrics.overline_thickness as i32, + }, + Color::ON, + ); + Ok(()) +} + +pub fn cursor_rect( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + _metrics: &Metrics, +) -> Result<(), DrawError> { + canvas.rect( + Rect { + x: 0, + y: 0, + width: width as i32, + height: height as i32, + }, + Color::ON, + ); + Ok(()) +} + +pub fn cursor_hollow_rect( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + // We fill the entire rect and then hollow out the inside, this isn't very + // efficient but it doesn't need to be and it's the easiest way to write it. + canvas.rect( + Rect { + x: 0, + y: 0, + width: width as i32, + height: height as i32, + }, + Color::ON, + ); + canvas.rect( + Rect { + x: metrics.cursor_thickness as i32, + y: metrics.cursor_thickness as i32, + width: width.saturating_sub(metrics.cursor_thickness * 2) as i32, + height: height.saturating_sub(metrics.cursor_thickness * 2) as i32, + }, + Color::OFF, + ); + Ok(()) +} + +pub fn cursor_bar( + _cp: u32, + canvas: &mut Canvas, + _width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + // We place the bar cursor half of its thickness over the left edge of the + // cell, so that it sits centered between characters, not biased to a side. + // + // We round up (add 1 before dividing by 2) because, empirically, having a + // 1px cursor shifted left a pixel looks better than having it not shifted. + canvas.rect( + Rect { + x: -(metrics.cursor_thickness.div_ceil(2) as i32), + y: 0, + width: metrics.cursor_thickness as i32, + height: height as i32, + }, + Color::ON, + ); + Ok(()) +} + +pub fn cursor_underline( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + // We can go beyond the height of the cell a bit, but + // we want to be sure never to exceed the height of the + // canvas, which extends a quarter cell below the cell + // height. + let y = metrics.underline_position.min( + height + .saturating_add(canvas.padding_y) + .saturating_sub(metrics.underline_thickness), + ); + + canvas.rect( + Rect { + x: 0, + y: y as i32, + width: width as i32, + height: metrics.cursor_thickness as i32, + }, + Color::ON, + ); + Ok(()) +} diff --git a/crates/ghostty-font/src/sprite/draw/symbols_for_legacy_computing.rs b/crates/ghostty-font/src/sprite/draw/symbols_for_legacy_computing.rs new file mode 100644 index 0000000..8985115 --- /dev/null +++ b/crates/ghostty-font/src/sprite/draw/symbols_for_legacy_computing.rs @@ -0,0 +1,1074 @@ +//! Symbols for Legacy Computing | U+1FB00...U+1FBFF +//! +//! +//! Port of `sprite/draw/symbols_for_legacy_computing.zig`. + +use super::block; +use super::box_; +use super::common::{ + Alignment, Corner, DrawFn, Edge, FIVE_EIGHTHS, Fraction, HALF, HAlign, ONE_EIGHTH, ONE_QUARTER, + ONE_THIRD, Quads, Range, SEVEN_EIGHTHS, Shade, THREE_EIGHTHS, THREE_QUARTERS, TWO_THIRDS, + Thickness, VAlign, fill, +}; +use super::geometric_shapes as geo; +use crate::metrics::Metrics; +use crate::sprite::canvas::{Canvas, Color, DrawError, Line, Path, Point, Rect, StrokeOptions}; + +const _: (f64, f64) = (ONE_THIRD, TWO_THIRDS); + +pub const RANGES: &[Range] = &[ + Range { + min: 0x1FB00, + max: 0x1FB3B, + draw: draw1fb00_1fb3b as DrawFn, + }, + Range { + min: 0x1FB3C, + max: 0x1FB67, + draw: draw1fb3c_1fb67 as DrawFn, + }, + Range { + min: 0x1FB68, + max: 0x1FB6F, + draw: draw1fb68_1fb6f as DrawFn, + }, + Range { + min: 0x1FB70, + max: 0x1FB75, + draw: draw1fb70_1fb75 as DrawFn, + }, + Range { + min: 0x1FB76, + max: 0x1FB7B, + draw: draw1fb76_1fb7b as DrawFn, + }, + Range { + min: 0x1FB7C, + max: 0x1FB97, + draw: draw1fb7c_1fb97 as DrawFn, + }, + Range { + min: 0x1FB98, + max: 0x1FB98, + draw: draw1fb98 as DrawFn, + }, + Range { + min: 0x1FB99, + max: 0x1FB99, + draw: draw1fb99 as DrawFn, + }, + Range { + min: 0x1FB9A, + max: 0x1FB9F, + draw: draw1fb9a_1fb9f as DrawFn, + }, + Range { + min: 0x1FBA0, + max: 0x1FBAE, + draw: draw1fba0_1fbae as DrawFn, + }, + Range { + min: 0x1FBAF, + max: 0x1FBAF, + draw: draw1fbaf as DrawFn, + }, + Range { + min: 0x1FBBD, + max: 0x1FBBD, + draw: draw1fbbd as DrawFn, + }, + Range { + min: 0x1FBBE, + max: 0x1FBBE, + draw: draw1fbbe as DrawFn, + }, + Range { + min: 0x1FBBF, + max: 0x1FBBF, + draw: draw1fbbf as DrawFn, + }, + Range { + min: 0x1FBCE, + max: 0x1FBCE, + draw: draw1fbce as DrawFn, + }, + Range { + min: 0x1FBCF, + max: 0x1FBCF, + draw: draw1fbcf as DrawFn, + }, + Range { + min: 0x1FBD0, + max: 0x1FBDF, + draw: draw1fbd0_1fbdf as DrawFn, + }, + Range { + min: 0x1FBE0, + max: 0x1FBEF, + draw: draw1fbe0_1fbef as DrawFn, + }, +]; + +/// Set the clip so we don't include anything outside of the cell. +fn clip_to_cell(canvas: &mut Canvas) { + canvas.clip_left = canvas.padding_x; + canvas.clip_right = canvas.padding_x; + canvas.clip_top = canvas.padding_y; + canvas.clip_bottom = canvas.padding_y; +} + +#[derive(Debug, Clone, Copy, Default)] +struct SmoothMosaic { + tl: bool, + ul: bool, + ll: bool, + bl: bool, + bc: bool, + br: bool, + lr: bool, + ur: bool, + tr: bool, + tc: bool, +} + +impl SmoothMosaic { + /// `rows` are the four 3-character rows of the pattern; this mirrors + /// indexing the Zig 15-byte `"...\n...\n...\n..."` pattern. + fn from(rows: (&str, &str, &str, &str)) -> SmoothMosaic { + let mut p = [b' '; 15]; + for (i, r) in [rows.0, rows.1, rows.2, rows.3].iter().enumerate() { + p[i * 4..i * 4 + 3].copy_from_slice(r.as_bytes()); + if i < 3 { + p[i * 4 + 3] = b'\n'; + } + } + let h = |i: usize| p[i] == b'#'; + SmoothMosaic { + tl: h(0), + ul: h(4) && (!h(0) || !h(8)), + ll: h(8) && (!h(4) || !h(12)), + bl: h(12), + bc: h(13) && (!h(12) || !h(14)), + br: h(14), + lr: h(10) && (!h(14) || !h(6)), + ur: h(6) && (!h(10) || !h(2)), + tr: h(2), + tc: h(1) && (!h(2) || !h(0)), + } + } +} + +/// Sextants +pub fn draw1fb00_1fb3b( + cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + use Fraction::{End, Full, Half, OneThird, TwoThirds, Zero}; + assert!((0x1fb00..=0x1fb3b).contains(&cp)); + let idx = cp - 0x1fb00; + // Packed struct(u6): tl, tr, ml, mr, bl, br from the low bit up. + let sex = idx + (idx / 0x14) + 1; + let bit = |n: u32| sex & (1 << n) != 0; + if bit(0) { + fill(metrics, canvas, Zero, Half, Zero, OneThird); + } + if bit(1) { + fill(metrics, canvas, Half, Full, Zero, OneThird); + } + if bit(2) { + fill(metrics, canvas, Zero, Half, OneThird, TwoThirds); + } + if bit(3) { + fill(metrics, canvas, Half, Full, OneThird, TwoThirds); + } + if bit(4) { + fill(metrics, canvas, Zero, Half, TwoThirds, End); + } + if bit(5) { + fill(metrics, canvas, Half, Full, TwoThirds, End); + } + Ok(()) +} + +/// Smooth Mosaics +pub fn draw1fb3c_1fb67( + cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + // Hand written lookup table for these shapes since I couldn't + // determine any sort of mathematical pattern in the codepoints. + let pattern = match cp { + 0x1fb3c => ("...", "...", "#..", "##."), + 0x1fb3d => ("...", "...", "#\\.", "###"), + 0x1fb3e => ("...", "#..", "#\\.", "##."), + 0x1fb3f => ("...", "#..", "##.", "###"), + 0x1fb40 => ("#..", "#..", "##.", "##."), + 0x1fb41 => ("/##", "###", "###", "###"), + 0x1fb42 => ("./#", "###", "###", "###"), + 0x1fb43 => (".##", ".##", "###", "###"), + 0x1fb44 => ("..#", ".##", "###", "###"), + 0x1fb45 => (".##", ".##", ".##", "###"), + 0x1fb46 => ("...", "./#", "###", "###"), + 0x1fb47 => ("...", "...", "..#", ".##"), + 0x1fb48 => ("...", "...", "./#", "###"), + 0x1fb49 => ("...", "..#", "./#", ".##"), + 0x1fb4a => ("...", "..#", ".##", "###"), + 0x1fb4b => ("..#", "..#", ".##", ".##"), + 0x1fb4c => ("##\\", "###", "###", "###"), + 0x1fb4d => ("#\\.", "###", "###", "###"), + 0x1fb4e => ("##.", "##.", "###", "###"), + 0x1fb4f => ("#..", "##.", "###", "###"), + 0x1fb50 => ("##.", "##.", "##.", "###"), + 0x1fb51 => ("...", "#\\.", "###", "###"), + 0x1fb52 => ("###", "###", "###", "\\##"), + 0x1fb53 => ("###", "###", "###", ".\\#"), + 0x1fb54 => ("###", "###", ".##", ".##"), + 0x1fb55 => ("###", "###", ".##", "..#"), + 0x1fb56 => ("###", ".##", ".##", ".##"), + 0x1fb57 => ("##.", "#..", "...", "..."), + 0x1fb58 => ("###", "#/.", "...", "..."), + 0x1fb59 => ("##.", "#/.", "#..", "..."), + 0x1fb5a => ("###", "##.", "#..", "..."), + 0x1fb5b => ("##.", "##.", "#..", "#.."), + 0x1fb5c => ("###", "###", "#/.", "..."), + 0x1fb5d => ("###", "###", "###", "##/"), + 0x1fb5e => ("###", "###", "###", "#/."), + 0x1fb5f => ("###", "###", "##.", "##."), + 0x1fb60 => ("###", "###", "##.", "#.."), + 0x1fb61 => ("###", "##.", "##.", "##."), + 0x1fb62 => (".##", "..#", "...", "..."), + 0x1fb63 => ("###", ".\\#", "...", "..."), + 0x1fb64 => (".##", ".\\#", "..#", "..."), + 0x1fb65 => ("###", ".##", "..#", "..."), + 0x1fb66 => (".##", ".##", "..#", "..#"), + 0x1fb67 => ("###", "###", ".\\#", "..."), + _ => unreachable!(), + }; + let mosaic = SmoothMosaic::from(pattern); + + let top = 0.0; + let upper = Fraction::OneThird.float(metrics.cell_height); + let lower = Fraction::TwoThirds.float(metrics.cell_height); + let bottom = f64::from(metrics.cell_height); + let left = 0.0; + let center = Fraction::Half.float(metrics.cell_width); + let right = f64::from(metrics.cell_width); + + let mut path = Path::new(); + if mosaic.tl { + path.line_to(left, top); + } + if mosaic.ul { + path.line_to(left, upper); + } + if mosaic.ll { + path.line_to(left, lower); + } + if mosaic.bl { + path.line_to(left, bottom); + } + if mosaic.bc { + path.line_to(center, bottom); + } + if mosaic.br { + path.line_to(right, bottom); + } + if mosaic.lr { + path.line_to(right, lower); + } + if mosaic.ur { + path.line_to(right, upper); + } + if mosaic.tr { + path.line_to(right, top); + } + if mosaic.tc { + path.line_to(center, top); + } + path.close(); + + canvas.fill_path(&path, Color::ON) +} + +pub fn draw1fb68_1fb6f( + cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + match cp { + // '🭨' + 0x1fb68 => { + edge_triangle(metrics, canvas, Edge::Left)?; + canvas.invert(); + clip_to_cell(canvas); + } + // '🭩' + 0x1fb69 => { + edge_triangle(metrics, canvas, Edge::Top)?; + canvas.invert(); + clip_to_cell(canvas); + } + // '🭪' + 0x1fb6a => { + edge_triangle(metrics, canvas, Edge::Right)?; + canvas.invert(); + clip_to_cell(canvas); + } + // '🭫' + 0x1fb6b => { + edge_triangle(metrics, canvas, Edge::Bottom)?; + canvas.invert(); + clip_to_cell(canvas); + } + // '🭬' + 0x1fb6c => edge_triangle(metrics, canvas, Edge::Left)?, + // '🭭' + 0x1fb6d => edge_triangle(metrics, canvas, Edge::Top)?, + // '🭮' + 0x1fb6e => edge_triangle(metrics, canvas, Edge::Right)?, + // '🭯' + 0x1fb6f => edge_triangle(metrics, canvas, Edge::Bottom)?, + + _ => unreachable!(), + } + Ok(()) +} + +/// Vertical one eighth blocks +pub fn draw1fb70_1fb75( + cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + let n = (cp + 1 - 0x1fb70) as usize; + + fill( + metrics, + canvas, + Fraction::EIGHTHS[n], + Fraction::EIGHTHS[n + 1], + Fraction::Top, + Fraction::Bottom, + ); + Ok(()) +} + +/// Horizontal one eighth blocks +pub fn draw1fb76_1fb7b( + cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + let n = (cp + 1 - 0x1fb76) as usize; + + fill( + metrics, + canvas, + Fraction::Left, + Fraction::Right, + Fraction::EIGHTHS[n], + Fraction::EIGHTHS[n + 1], + ); + Ok(()) +} + +pub fn draw1fb7c_1fb97( + cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + let blk = block::block; + match cp { + // '🭼' LEFT AND LOWER ONE EIGHTH BLOCK + 0x1fb7c => { + blk(metrics, canvas, Alignment::LEFT, ONE_EIGHTH, 1.0); + blk(metrics, canvas, Alignment::LOWER, 1.0, ONE_EIGHTH); + } + // '🭽' LEFT AND UPPER ONE EIGHTH BLOCK + 0x1fb7d => { + blk(metrics, canvas, Alignment::LEFT, ONE_EIGHTH, 1.0); + blk(metrics, canvas, Alignment::UPPER, 1.0, ONE_EIGHTH); + } + // '🭾' RIGHT AND UPPER ONE EIGHTH BLOCK + 0x1fb7e => { + blk(metrics, canvas, Alignment::RIGHT, ONE_EIGHTH, 1.0); + blk(metrics, canvas, Alignment::UPPER, 1.0, ONE_EIGHTH); + } + // '🭿' RIGHT AND LOWER ONE EIGHTH BLOCK + 0x1fb7f => { + blk(metrics, canvas, Alignment::RIGHT, ONE_EIGHTH, 1.0); + blk(metrics, canvas, Alignment::LOWER, 1.0, ONE_EIGHTH); + } + // '🮀' UPPER AND LOWER ONE EIGHTH BLOCK + 0x1fb80 => { + blk(metrics, canvas, Alignment::UPPER, 1.0, ONE_EIGHTH); + blk(metrics, canvas, Alignment::LOWER, 1.0, ONE_EIGHTH); + } + // '🮁' Horizontal One Eighth Block 1358 + 0x1fb81 => { + // We just call the draw function for each of the relevant codepoints. + // The first codepoint is actually a lie, it's before the range, but + // we need it to get the first (0th) block position. This might be a + // bit brittle, oh well, if it breaks we can fix it. + draw1fb76_1fb7b(0x1fb74 + 1, canvas, width, height, metrics)?; + draw1fb76_1fb7b(0x1fb74 + 3, canvas, width, height, metrics)?; + draw1fb76_1fb7b(0x1fb74 + 5, canvas, width, height, metrics)?; + draw1fb76_1fb7b(0x1fb74 + 8, canvas, width, height, metrics)?; + } + + // '🮂' UPPER ONE QUARTER BLOCK + 0x1fb82 => blk(metrics, canvas, Alignment::UPPER, 1.0, ONE_QUARTER), + // '🮃' UPPER THREE EIGHTHS BLOCK + 0x1fb83 => blk(metrics, canvas, Alignment::UPPER, 1.0, THREE_EIGHTHS), + // '🮄' UPPER FIVE EIGHTHS BLOCK + 0x1fb84 => blk(metrics, canvas, Alignment::UPPER, 1.0, FIVE_EIGHTHS), + // '🮅' UPPER THREE QUARTERS BLOCK + 0x1fb85 => blk(metrics, canvas, Alignment::UPPER, 1.0, THREE_QUARTERS), + // '🮆' UPPER SEVEN EIGHTHS BLOCK + 0x1fb86 => blk(metrics, canvas, Alignment::UPPER, 1.0, SEVEN_EIGHTHS), + + // '🮇' RIGHT ONE QUARTER BLOCK + 0x1fb87 => blk(metrics, canvas, Alignment::RIGHT, ONE_QUARTER, 1.0), + // '🮈' RIGHT THREE EIGHTHS BLOCK + 0x1fb88 => blk(metrics, canvas, Alignment::RIGHT, THREE_EIGHTHS, 1.0), + // '🮉' RIGHT FIVE EIGHTHS BLOCK + 0x1fb89 => blk(metrics, canvas, Alignment::RIGHT, FIVE_EIGHTHS, 1.0), + // '🮊' RIGHT THREE QUARTERS BLOCK + 0x1fb8a => blk(metrics, canvas, Alignment::RIGHT, THREE_QUARTERS, 1.0), + // '🮋' RIGHT SEVEN EIGHTHS BLOCK/ + 0x1fb8b => blk(metrics, canvas, Alignment::RIGHT, SEVEN_EIGHTHS, 1.0), + + // '🮌' + 0x1fb8c => block::block_shade(metrics, canvas, Alignment::LEFT, HALF, 1.0, Shade::MEDIUM), + // '🮍' + 0x1fb8d => block::block_shade(metrics, canvas, Alignment::RIGHT, HALF, 1.0, Shade::MEDIUM), + // '🮎' + 0x1fb8e => block::block_shade(metrics, canvas, Alignment::UPPER, 1.0, HALF, Shade::MEDIUM), + // '🮏' + 0x1fb8f => block::block_shade(metrics, canvas, Alignment::LOWER, 1.0, HALF, Shade::MEDIUM), + + // '🮐' + 0x1fb90 => block::full_block_shade(metrics, canvas, Shade::MEDIUM), + // '🮑' + 0x1fb91 => { + block::full_block_shade(metrics, canvas, Shade::MEDIUM); + blk(metrics, canvas, Alignment::UPPER, 1.0, HALF); + } + // '🮒' + 0x1fb92 => { + block::full_block_shade(metrics, canvas, Shade::MEDIUM); + blk(metrics, canvas, Alignment::LOWER, 1.0, HALF); + } + 0x1fb93 => { + // NOTE: This codepoint is currently un-allocated, it's a hole + // in the unicode block, so it's safe to just render it + // as an empty glyph, probably. + } + // '🮔' + 0x1fb94 => { + block::full_block_shade(metrics, canvas, Shade::MEDIUM); + blk(metrics, canvas, Alignment::RIGHT, HALF, 1.0); + } + // '🮕' + 0x1fb95 => checkerboard_fill(metrics, canvas, 0), + // '🮖' + 0x1fb96 => checkerboard_fill(metrics, canvas, 1), + // '🮗' + 0x1fb97 => { + canvas.box_( + 0, + (height / 4) as i32, + width as i32, + (2 * height / 4) as i32, + Color::ON, + ); + canvas.box_( + 0, + (3 * height / 4) as i32, + width as i32, + height as i32, + Color::ON, + ); + } + + _ => unreachable!(), + } + Ok(()) +} + +/// Draws the diagonal hatch lines shared by U+1FB98 and U+1FB99. +/// `upper_left_to_lower_right` selects the direction. +fn diagonal_fill(metrics: &Metrics, canvas: &mut Canvas, upper_left_to_lower_right: bool) { + // Set the clip so we don't include anything outside of the cell. + clip_to_cell(canvas); + + // TODO: This doesn't align properly for most cell sizes, fix that. + + let thick_px = Thickness::Light.height(metrics.box_thickness); + let line_count = metrics.cell_width / (2 * thick_px); + + let float_width = f64::from(metrics.cell_width); + let float_height = f64::from(metrics.cell_height); + let float_thick = f64::from(thick_px); + let stride = (float_width / f64::from(line_count)).round(); + + for i in 0..=(line_count * 2) { + let i = i as i32 - line_count as i32; + let (top_x, bottom_x) = if upper_left_to_lower_right { + let top_x = f64::from(i) * stride; + (top_x, float_width + top_x) + } else { + let bottom_x = f64::from(i) * stride; + (float_width + bottom_x, bottom_x) + }; + let _ = canvas.line( + Line { + p0: Point { x: top_x, y: 0.0 }, + p1: Point { + x: bottom_x, + y: float_height, + }, + }, + float_thick, + Color::ON, + ); + } +} + +/// Upper Left to Lower Right Fill +/// 🮘 +pub fn draw1fb98( + _cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + diagonal_fill(metrics, canvas, true); + Ok(()) +} + +/// Upper Right to Lower Left Fill +/// 🮙 +pub fn draw1fb99( + _cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + diagonal_fill(metrics, canvas, false); + Ok(()) +} + +pub fn draw1fb9a_1fb9f( + cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + match cp { + // '🮚' + 0x1fb9a => { + edge_triangle(metrics, canvas, Edge::Top)?; + edge_triangle(metrics, canvas, Edge::Bottom)?; + } + // '🮛' + 0x1fb9b => { + edge_triangle(metrics, canvas, Edge::Left)?; + edge_triangle(metrics, canvas, Edge::Right)?; + } + // '🮜' + 0x1fb9c => geo::corner_triangle_shade(metrics, canvas, Corner::Tl, Shade::MEDIUM)?, + // '🮝' + 0x1fb9d => geo::corner_triangle_shade(metrics, canvas, Corner::Tr, Shade::MEDIUM)?, + // '🮞' + 0x1fb9e => geo::corner_triangle_shade(metrics, canvas, Corner::Br, Shade::MEDIUM)?, + // '🮟' + 0x1fb9f => geo::corner_triangle_shade(metrics, canvas, Corner::Bl, Shade::MEDIUM)?, + + _ => unreachable!(), + } + Ok(()) +} + +pub fn draw1fba0_1fbae( + cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + let q = |tl, tr, bl, br| Quads { tl, tr, bl, br }; + let c = match cp { + // '🮠' + 0x1fba0 => q(true, false, false, false), + // '🮡' + 0x1fba1 => q(false, true, false, false), + // '🮢' + 0x1fba2 => q(false, false, true, false), + // '🮣' + 0x1fba3 => q(false, false, false, true), + // '🮤' + 0x1fba4 => q(true, false, true, false), + // '🮥' + 0x1fba5 => q(false, true, false, true), + // '🮦' + 0x1fba6 => q(false, false, true, true), + // '🮧' + 0x1fba7 => q(true, true, false, false), + // '🮨' + 0x1fba8 => q(true, false, false, true), + // '🮩' + 0x1fba9 => q(false, true, true, false), + // '🮪' + 0x1fbaa => q(false, true, true, true), + // '🮫' + 0x1fbab => q(true, false, true, true), + // '🮬' + 0x1fbac => q(true, true, false, true), + // '🮭' + 0x1fbad => q(true, true, true, false), + // '🮮' + 0x1fbae => q(true, true, true, true), + + _ => unreachable!(), + }; + corner_diagonal_lines(metrics, canvas, c); + Ok(()) +} + +/// 🮯 +pub fn draw1fbaf( + _cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + box_::lines_char( + metrics, + canvas, + box_::Lines { + up: box_::Style::Heavy, + down: box_::Style::Heavy, + left: box_::Style::Light, + right: box_::Style::Light, + }, + ); + Ok(()) +} + +/// 🮽 +pub fn draw1fbbd( + _cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + box_::light_diagonal_cross(metrics, canvas); + canvas.invert(); + clip_to_cell(canvas); + Ok(()) +} + +/// 🮾 +pub fn draw1fbbe( + _cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + corner_diagonal_lines( + metrics, + canvas, + Quads { + br: true, + ..Quads::default() + }, + ); + canvas.invert(); + clip_to_cell(canvas); + Ok(()) +} + +/// 🮿 +pub fn draw1fbbf( + _cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + corner_diagonal_lines( + metrics, + canvas, + Quads { + tl: true, + tr: true, + bl: true, + br: true, + }, + ); + canvas.invert(); + clip_to_cell(canvas); + Ok(()) +} + +/// 🯎 +pub fn draw1fbce( + _cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + block::block(metrics, canvas, Alignment::LEFT, TWO_THIRDS, 1.0); + Ok(()) +} + +/// 🯏 +pub fn draw1fbcf( + _cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + block::block(metrics, canvas, Alignment::LEFT, ONE_THIRD, 1.0); + Ok(()) +} + +/// Cell diagonals. +pub fn draw1fbd0_1fbdf( + cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + use Alignment as A; + let mut d = |from: Alignment, to: Alignment| cell_diagonal(metrics, canvas, from, to); + match cp { + // '🯐' + 0x1fbd0 => d(A::MIDDLE_RIGHT, A::LOWER_LEFT), + // '🯑' + 0x1fbd1 => d(A::UPPER_RIGHT, A::MIDDLE_LEFT), + // '🯒' + 0x1fbd2 => d(A::UPPER_LEFT, A::MIDDLE_RIGHT), + // '🯓' + 0x1fbd3 => d(A::MIDDLE_LEFT, A::LOWER_RIGHT), + // '🯔' + 0x1fbd4 => d(A::UPPER_LEFT, A::LOWER_CENTER), + // '🯕' + 0x1fbd5 => d(A::UPPER_CENTER, A::LOWER_RIGHT), + // '🯖' + 0x1fbd6 => d(A::UPPER_RIGHT, A::LOWER_CENTER), + // '🯗' + 0x1fbd7 => d(A::UPPER_CENTER, A::LOWER_LEFT), + // '🯘' + 0x1fbd8 => { + d(A::UPPER_LEFT, A::MIDDLE_CENTER); + d(A::MIDDLE_CENTER, A::UPPER_RIGHT); + } + // '🯙' + 0x1fbd9 => { + d(A::UPPER_RIGHT, A::MIDDLE_CENTER); + d(A::MIDDLE_CENTER, A::LOWER_RIGHT); + } + // '🯚' + 0x1fbda => { + d(A::LOWER_LEFT, A::MIDDLE_CENTER); + d(A::MIDDLE_CENTER, A::LOWER_RIGHT); + } + // '🯛' + 0x1fbdb => { + d(A::UPPER_LEFT, A::MIDDLE_CENTER); + d(A::MIDDLE_CENTER, A::LOWER_LEFT); + } + // '🯜' + 0x1fbdc => { + d(A::UPPER_LEFT, A::LOWER_CENTER); + d(A::LOWER_CENTER, A::UPPER_RIGHT); + } + // '🯝' + 0x1fbdd => { + d(A::UPPER_RIGHT, A::MIDDLE_LEFT); + d(A::MIDDLE_LEFT, A::LOWER_RIGHT); + } + // '🯞' + 0x1fbde => { + d(A::LOWER_LEFT, A::UPPER_CENTER); + d(A::UPPER_CENTER, A::LOWER_RIGHT); + } + // '🯟' + 0x1fbdf => { + d(A::UPPER_LEFT, A::MIDDLE_RIGHT); + d(A::MIDDLE_RIGHT, A::LOWER_LEFT); + } + + _ => unreachable!(), + } + Ok(()) +} + +pub fn draw1fbe0_1fbef( + cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + use Alignment as A; + match cp { + // '🯠' + 0x1fbe0 => circle(metrics, canvas, A::TOP, false), + // '🯡' + 0x1fbe1 => circle(metrics, canvas, A::RIGHT, false), + // '🯢' + 0x1fbe2 => circle(metrics, canvas, A::BOTTOM, false), + // '🯣' + 0x1fbe3 => circle(metrics, canvas, A::LEFT, false), + // '🯤' + 0x1fbe4 => block::block(metrics, canvas, A::UPPER_CENTER, 0.5, 0.5), + // '🯥' + 0x1fbe5 => block::block(metrics, canvas, A::LOWER_CENTER, 0.5, 0.5), + // '🯦' + 0x1fbe6 => block::block(metrics, canvas, A::MIDDLE_LEFT, 0.5, 0.5), + // '🯧' + 0x1fbe7 => block::block(metrics, canvas, A::MIDDLE_RIGHT, 0.5, 0.5), + // '🯨' + 0x1fbe8 => circle(metrics, canvas, A::TOP, true), + // '🯩' + 0x1fbe9 => circle(metrics, canvas, A::RIGHT, true), + // '🯪' + 0x1fbea => circle(metrics, canvas, A::BOTTOM, true), + // '🯫' + 0x1fbeb => circle(metrics, canvas, A::LEFT, true), + // '🯬' + 0x1fbec => circle(metrics, canvas, A::TOP_RIGHT, true), + // '🯭' + 0x1fbed => circle(metrics, canvas, A::BOTTOM_LEFT, true), + // '🯮' + 0x1fbee => circle(metrics, canvas, A::BOTTOM_RIGHT, true), + // '🯯' + 0x1fbef => circle(metrics, canvas, A::TOP_LEFT, true), + + _ => unreachable!(), + } + Ok(()) +} + +fn edge_triangle(metrics: &Metrics, canvas: &mut Canvas, edge: Edge) -> Result<(), DrawError> { + let upper = 0.0; + let middle = (f64::from(metrics.cell_height) / 2.0).round(); + let lower = f64::from(metrics.cell_height); + let left = 0.0; + let center = (f64::from(metrics.cell_width) / 2.0).round(); + let right = f64::from(metrics.cell_width); + + let (x0, y0, x1, y1) = match edge { + Edge::Top => (right, upper, left, upper), + Edge::Left => (left, upper, left, lower), + Edge::Bottom => (left, lower, right, lower), + Edge::Right => (right, lower, right, upper), + }; + + let mut path = Path::new(); + path.move_to(center, middle); + path.line_to(x0, y0); + path.line_to(x1, y1); + path.close(); + + canvas.fill_path(&path, Color::ON) +} + +fn corner_diagonal_lines(metrics: &Metrics, canvas: &mut Canvas, corners: Quads) { + let thick_px = Thickness::Light.height(metrics.box_thickness); + + let float_width = f64::from(metrics.cell_width); + let float_height = f64::from(metrics.cell_height); + let float_thick = f64::from(thick_px); + let center_x = f64::from(metrics.cell_width / 2 + metrics.cell_width % 2); + let center_y = f64::from(metrics.cell_height / 2 + metrics.cell_height % 2); + + let mut line = |p0: (f64, f64), p1: (f64, f64)| { + let _ = canvas.line( + Line { + p0: Point { x: p0.0, y: p0.1 }, + p1: Point { x: p1.0, y: p1.1 }, + }, + float_thick, + Color::ON, + ); + }; + + if corners.tl { + line((center_x, 0.0), (0.0, center_y)); + } + if corners.tr { + line((center_x, 0.0), (float_width, center_y)); + } + if corners.bl { + line((center_x, float_height), (0.0, center_y)); + } + if corners.br { + line((center_x, float_height), (float_width, center_y)); + } +} + +fn cell_diagonal(metrics: &Metrics, canvas: &mut Canvas, from: Alignment, to: Alignment) { + let float_width = f64::from(metrics.cell_width); + let float_height = f64::from(metrics.cell_height); + + let hx = |a: HAlign| match a { + HAlign::Left => 0.0, + HAlign::Right => float_width, + HAlign::Center => float_width / 2.0, + }; + let vy = |a: VAlign| match a { + VAlign::Top => 0.0, + VAlign::Bottom => float_height, + VAlign::Middle => float_height / 2.0, + }; + + let _ = canvas.line( + Line { + p0: Point { + x: hx(from.horizontal), + y: vy(from.vertical), + }, + p1: Point { + x: hx(to.horizontal), + y: vy(to.vertical), + }, + }, + f64::from(Thickness::Light.height(metrics.box_thickness)), + Color::ON, + ); +} + +fn checkerboard_fill(metrics: &Metrics, canvas: &mut Canvas, parity: usize) { + let float_width = f64::from(metrics.cell_width); + let float_height = f64::from(metrics.cell_height); + let cell_width = metrics.cell_width as usize; + let cell_height = metrics.cell_height as usize; + let x_size: usize = 4; + let y_size: usize = (4.0 * (float_height / float_width)).round() as usize; + for x in 0..x_size { + let x0 = (cell_width * x) / x_size; + let x1 = (cell_width * (x + 1)) / x_size; + for y in 0..y_size { + let y0 = (cell_height * y) / y_size; + let y1 = (cell_height * (y + 1)) / y_size; + if (x + y) % 2 == parity { + canvas.rect( + Rect { + x: x0 as i32, + y: y0 as i32, + width: x1.saturating_sub(x0) as i32, + height: y1.saturating_sub(y0) as i32, + }, + Color::ON, + ); + } + } + } +} + +pub fn circle(metrics: &Metrics, canvas: &mut Canvas, position: Alignment, filled: bool) { + // Set the clip so we don't include anything outside of the cell. + clip_to_cell(canvas); + + let float_width = f64::from(metrics.cell_width); + let float_height = f64::from(metrics.cell_height); + + let x = match position.horizontal { + HAlign::Left => 0.0, + HAlign::Right => float_width, + HAlign::Center => float_width / 2.0, + }; + let y = match position.vertical { + VAlign::Top => 0.0, + VAlign::Bottom => float_height, + VAlign::Middle => float_height / 2.0, + }; + let r = 0.5 * float_width.min(float_height); + + let line_width = f64::from(Thickness::Light.height(metrics.box_thickness)); + + let mut path = Path::new(); + if filled { + path.arc(x, y, r, 0.0, std::f64::consts::PI * 2.0); + path.close(); + let _ = canvas.fill_path(&path, Color::ON); + } else { + path.arc(x, y, r - line_width / 2.0, 0.0, std::f64::consts::PI * 2.0); + path.close(); + let _ = canvas.stroke_path( + &path, + StrokeOptions { + line_width, + ..StrokeOptions::default() + }, + Color::ON, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn metrics() -> Metrics { + let mut m = Metrics::calc(crate::metrics::FaceMetrics { + px_per_em: 16.0, + cell_width: 12.0, + ascent: 20.0, + descent: -4.0, + line_gap: 0.0, + underline_thickness: Some(2.0), + strikethrough_thickness: Some(2.0), + ..Default::default() + }); + m.box_thickness = 2; + m + } + + #[test] + fn mosaic_table_complete() { + let m = metrics(); + for cp in 0x1fb3c..=0x1fb67 { + let mut c = Canvas::new(m.cell_width, m.cell_height, 3, 6); + draw1fb3c_1fb67(cp, &mut c, m.cell_width, m.cell_height, &m).expect("draw"); + assert!(c.sfc.iter().any(|v| *v != 0), "cp {cp:x} drew nothing"); + } + } + + #[test] + fn sextants_draw() { + let m = metrics(); + for cp in 0x1fb00..=0x1fb3b { + let mut c = Canvas::new(m.cell_width, m.cell_height, 3, 6); + draw1fb00_1fb3b(cp, &mut c, m.cell_width, m.cell_height, &m).expect("draw"); + assert!(c.sfc.iter().any(|v| *v != 0)); + } + } +} diff --git a/crates/ghostty-font/src/sprite/draw/symbols_for_legacy_computing_supplement.rs b/crates/ghostty-font/src/sprite/draw/symbols_for_legacy_computing_supplement.rs new file mode 100644 index 0000000..86df137 --- /dev/null +++ b/crates/ghostty-font/src/sprite/draw/symbols_for_legacy_computing_supplement.rs @@ -0,0 +1,800 @@ +//! Symbols for Legacy Computing Supplement | U+1CC00...U+1CEBF +//! +//! +//! Port of `sprite/draw/symbols_for_legacy_computing_supplement.zig`. + +use std::sync::OnceLock; + +use super::box_::{self, Lines, Style}; +use super::common::{Alignment, Corner, DrawFn, Fraction, Range, fill}; +use super::symbols_for_legacy_computing as sflc; +use crate::metrics::Metrics; +use crate::sprite::canvas::{Canvas, CapMode, Color, DrawError, Path, StrokeOptions}; + +pub const RANGES: &[Range] = &[ + Range { + min: 0x1cc1b, + max: 0x1cc1e, + draw: draw1cc1b_1cc1e as DrawFn, + }, + Range { + min: 0x1cc21, + max: 0x1cc2f, + draw: draw1cc21_1cc2f as DrawFn, + }, + Range { + min: 0x1cc30, + max: 0x1cc3f, + draw: draw1cc30_1cc3f as DrawFn, + }, + Range { + min: 0x1cd00, + max: 0x1cde5, + draw: draw1cd00_1cde5 as DrawFn, + }, + Range { + min: 0x1ce00, + max: 0x1ce00, + draw: draw1ce00 as DrawFn, + }, + Range { + min: 0x1ce01, + max: 0x1ce01, + draw: draw1ce01 as DrawFn, + }, + Range { + min: 0x1ce0b, + max: 0x1ce0b, + draw: draw1ce0b as DrawFn, + }, + Range { + min: 0x1ce0c, + max: 0x1ce0c, + draw: draw1ce0c as DrawFn, + }, + Range { + min: 0x1ce16, + max: 0x1ce19, + draw: draw1ce16_1ce19 as DrawFn, + }, + Range { + min: 0x1ce51, + max: 0x1ce8f, + draw: draw1ce51_1ce8f as DrawFn, + }, + Range { + min: 0x1ce90, + max: 0x1ceaf, + draw: draw1ce90_1ceaf as DrawFn, + }, +]; + +const OCTANT_MIN: u32 = 0x1cd00; +const OCTANT_MAX: u32 = 0x1cde5; + +/// Parse the octant data. Each entry is a bitmask where bit `n-1` is set for +/// octant `n` (1..=8). Lines are in the format "BLOCK OCTANT-1235". +fn octants() -> &'static [u8] { + static OCTANTS: OnceLock> = OnceLock::new(); + OCTANTS.get_or_init(|| { + let mut result = Vec::new(); + for line in include_str!("octants.txt").split('\n') { + // Skip comments + if line.is_empty() || line.starts_with('#') { + continue; + } + let idx = line.find('-').expect("octant line has '-'"); + let mut bits = 0u8; + for c in line[idx + 1..].bytes() { + bits |= 1 << (c - b'1'); + } + result.push(bits); + } + assert_eq!(result.len(), (OCTANT_MAX - OCTANT_MIN + 1) as usize); + result + }) +} + +/// Octants +pub fn draw1cd00_1cde5( + cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + use Fraction::{End, Full, Half, OneQuarter, ThreeQuarters, TwoQuarters, Zero}; + + let oct = octants()[(cp - OCTANT_MIN) as usize]; + let has = |n: u8| oct & (1 << (n - 1)) != 0; + if has(1) { + fill(metrics, canvas, Zero, Half, Zero, OneQuarter); + } + if has(2) { + fill(metrics, canvas, Half, Full, Zero, OneQuarter); + } + if has(3) { + fill(metrics, canvas, Zero, Half, OneQuarter, TwoQuarters); + } + if has(4) { + fill(metrics, canvas, Half, Full, OneQuarter, TwoQuarters); + } + if has(5) { + fill(metrics, canvas, Zero, Half, TwoQuarters, ThreeQuarters); + } + if has(6) { + fill(metrics, canvas, Half, Full, TwoQuarters, ThreeQuarters); + } + if has(7) { + fill(metrics, canvas, Zero, Half, ThreeQuarters, End); + } + if has(8) { + fill(metrics, canvas, Half, Full, ThreeQuarters, End); + } + Ok(()) +} + +/// Separated Block Quadrants +/// 𜰡 𜰢 𜰣 𜰤 𜰥 𜰦 𜰧 𜰨 𜰩 𜰪 𜰫 𜰬 𜰭 𜰮 𜰯 +pub fn draw1cc21_1cc2f( + cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + _metrics: &Metrics, +) -> Result<(), DrawError> { + // Bits laid out to match the codepoint order. + let bits = (cp - 0x1CC20) & 0xF; + let (tl, tr, bl, br) = (bits & 1 != 0, bits & 2 != 0, bits & 4 != 0, bits & 8 != 0); + + let gap = (width / 12).max(1) as i32; + + let mid_gap_x = gap * 2 + (width % 2) as i32; + let mid_gap_y = gap * 2 + (height % 2) as i32; + + let w = (width as i32 - gap * 2 - mid_gap_x) / 2; + let h = (height as i32 - gap * 2 - mid_gap_y) / 2; + + if tl { + canvas.box_(gap, gap, gap + w, gap + h, Color::ON); + } + if tr { + canvas.box_( + gap + w + mid_gap_x, + gap, + gap + w + mid_gap_x + w, + gap + h, + Color::ON, + ); + } + if bl { + canvas.box_( + gap, + gap + h + mid_gap_y, + gap + w, + gap + h + mid_gap_y + h, + Color::ON, + ); + } + if br { + canvas.box_( + gap + w + mid_gap_x, + gap + h + mid_gap_y, + gap + w + mid_gap_x + w, + gap + h + mid_gap_y + h, + Color::ON, + ); + } + Ok(()) +} + +/// Twelfth and Quarter circle pieces. +/// 𜰰 𜰱 𜰲 𜰳 𜰴 𜰵 𜰶 𜰷 𜰸 𜰹 𜰺 𜰻 𜰼 𜰽 𜰾 𜰿 +/// +/// 𜰰𜰱𜰲𜰳 +/// 𜰴𜰵𜰶𜰷 +/// 𜰸𜰹𜰺𜰻 +/// 𜰼𜰽𜰾𜰿 +/// +/// These are actually ellipses, sized to touch +/// the edge of their enclosing set of cells. +pub fn draw1cc30_1cc3f( + cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + match cp { + 0x1CC30 => circle_piece( + canvas, + width, + height, + metrics, + 0.0, + 0.0, + 2.0, + 2.0, + Corner::Tl, + )?, + 0x1CC31 => circle_piece( + canvas, + width, + height, + metrics, + 1.0, + 0.0, + 2.0, + 2.0, + Corner::Tl, + )?, + 0x1CC32 => circle_piece( + canvas, + width, + height, + metrics, + 2.0, + 0.0, + 2.0, + 2.0, + Corner::Tr, + )?, + 0x1CC33 => circle_piece( + canvas, + width, + height, + metrics, + 3.0, + 0.0, + 2.0, + 2.0, + Corner::Tr, + )?, + 0x1CC34 => circle_piece( + canvas, + width, + height, + metrics, + 0.0, + 1.0, + 2.0, + 2.0, + Corner::Tl, + )?, + 0x1CC35 => circle_piece( + canvas, + width, + height, + metrics, + 0.0, + 0.0, + 1.0, + 1.0, + Corner::Tl, + )?, + 0x1CC36 => circle_piece( + canvas, + width, + height, + metrics, + 1.0, + 0.0, + 1.0, + 1.0, + Corner::Tr, + )?, + 0x1CC37 => circle_piece( + canvas, + width, + height, + metrics, + 3.0, + 1.0, + 2.0, + 2.0, + Corner::Tr, + )?, + 0x1CC38 => circle_piece( + canvas, + width, + height, + metrics, + 0.0, + 2.0, + 2.0, + 2.0, + Corner::Bl, + )?, + 0x1CC39 => circle_piece( + canvas, + width, + height, + metrics, + 0.0, + 1.0, + 1.0, + 1.0, + Corner::Bl, + )?, + 0x1CC3A => circle_piece( + canvas, + width, + height, + metrics, + 1.0, + 1.0, + 1.0, + 1.0, + Corner::Br, + )?, + 0x1CC3B => circle_piece( + canvas, + width, + height, + metrics, + 3.0, + 2.0, + 2.0, + 2.0, + Corner::Br, + )?, + 0x1CC3C => circle_piece( + canvas, + width, + height, + metrics, + 0.0, + 3.0, + 2.0, + 2.0, + Corner::Bl, + )?, + 0x1CC3D => circle_piece( + canvas, + width, + height, + metrics, + 1.0, + 3.0, + 2.0, + 2.0, + Corner::Bl, + )?, + 0x1CC3E => circle_piece( + canvas, + width, + height, + metrics, + 2.0, + 3.0, + 2.0, + 2.0, + Corner::Br, + )?, + 0x1CC3F => circle_piece( + canvas, + width, + height, + metrics, + 3.0, + 3.0, + 2.0, + 2.0, + Corner::Br, + )?, + _ => unreachable!(), + } + Ok(()) +} + +/// TODO: These two characters should be easy, but it's not clear how they're +/// meant to align with adjacent cells, what characters they're meant to +/// be used with: +/// - 1CC1F 𜰟 BOX DRAWINGS DOUBLE DIAGONAL UPPER RIGHT TO LOWER LEFT +/// - 1CC20 𜰠 BOX DRAWINGS DOUBLE DIAGONAL UPPER LEFT TO LOWER RIGHT +pub fn draw1cc1b_1cc1e( + cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + let w = width as i32; + let h = height as i32; + let t = metrics.box_thickness as i32; + let horiz = Lines { + left: Style::Light, + right: Style::Light, + ..Lines::NONE + }; + match cp { + // 𜰛 BOX DRAWINGS LIGHT HORIZONTAL AND UPPER RIGHT + 0x1CC1B => { + box_::lines_char(metrics, canvas, horiz); + canvas.box_(w - t, 0, w, h.div_euclid(2), Color::ON); + } + // 𜰜 BOX DRAWINGS LIGHT HORIZONTAL AND LOWER RIGHT + 0x1CC1C => { + box_::lines_char(metrics, canvas, horiz); + canvas.box_(w - t, h.div_euclid(2), w, h, Color::ON); + } + // 𜰝 BOX DRAWINGS LIGHT TOP AND UPPER LEFT + 0x1CC1D => { + canvas.box_(0, 0, w, t, Color::ON); + canvas.box_(0, 0, t, h.div_euclid(2), Color::ON); + } + // 𜰞 BOX DRAWINGS LIGHT BOTTOM AND LOWER LEFT + 0x1CC1E => { + canvas.box_(0, h - t, w, h, Color::ON); + canvas.box_(0, h.div_euclid(2), t, h, Color::ON); + } + _ => unreachable!(), + } + Ok(()) +} + +/// 𜸀 RIGHT HALF AND LEFT HALF WHITE CIRCLE +pub fn draw1ce00( + _cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + sflc::circle(metrics, canvas, Alignment::LEFT, false); + sflc::circle(metrics, canvas, Alignment::RIGHT, false); + Ok(()) +} + +/// 𜸁 LOWER HALF AND UPPER HALF WHITE CIRCLE +pub fn draw1ce01( + _cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + sflc::circle(metrics, canvas, Alignment::TOP, false); + sflc::circle(metrics, canvas, Alignment::BOTTOM, false); + Ok(()) +} + +/// 𜸋 LEFT HALF WHITE ELLIPSE +pub fn draw1ce0b( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + circle_piece( + canvas, + width, + height, + metrics, + 0.0, + 0.0, + 1.0, + 0.5, + Corner::Tl, + )?; + circle_piece( + canvas, + width, + height, + metrics, + 0.0, + 0.0, + 1.0, + 0.5, + Corner::Bl, + ) +} + +/// 𜸌 RIGHT HALF WHITE ELLIPSE +pub fn draw1ce0c( + _cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + circle_piece( + canvas, + width, + height, + metrics, + 1.0, + 0.0, + 1.0, + 0.5, + Corner::Tr, + )?; + circle_piece( + canvas, + width, + height, + metrics, + 1.0, + 0.0, + 1.0, + 0.5, + Corner::Br, + ) +} + +pub fn draw1ce16_1ce19( + cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + let w = width as i32; + let h = height as i32; + let t = metrics.box_thickness as i32; + let vert = Lines { + up: Style::Light, + down: Style::Light, + ..Lines::NONE + }; + match cp { + // 𜸖 BOX DRAWINGS LIGHT VERTICAL AND TOP RIGHT + 0x1CE16 => { + box_::lines_char(metrics, canvas, vert); + canvas.box_(w.div_euclid(2), 0, w, t, Color::ON); + } + // 𜸗 BOX DRAWINGS LIGHT VERTICAL AND BOTTOM RIGHT + 0x1CE17 => { + box_::lines_char(metrics, canvas, vert); + canvas.box_(w.div_euclid(2), h - t, w, h, Color::ON); + } + // 𜸘 BOX DRAWINGS LIGHT VERTICAL AND TOP LEFT + 0x1CE18 => { + box_::lines_char(metrics, canvas, vert); + canvas.box_(0, 0, w.div_euclid(2), t, Color::ON); + } + // 𜸙 BOX DRAWINGS LIGHT VERTICAL AND BOTTOM LEFT + 0x1CE19 => { + box_::lines_char(metrics, canvas, vert); + canvas.box_(0, h - t, w.div_euclid(2), h, Color::ON); + } + _ => unreachable!(), + } + Ok(()) +} + +/// Separated Block Sextants +pub fn draw1ce51_1ce8f( + cp: u32, + canvas: &mut Canvas, + width: u32, + height: u32, + _metrics: &Metrics, +) -> Result<(), DrawError> { + // Bits laid out to match the codepoint order: tl, tr, ml, mr, bl, br. + let bits = (cp - 0x1CE50) & 0x3F; + let (tl, tr, ml, mr, bl, br) = ( + bits & 1 != 0, + bits & 2 != 0, + bits & 4 != 0, + bits & 8 != 0, + bits & 16 != 0, + bits & 32 != 0, + ); + + let gap = (width / 12).max(1) as i32; + + let mid_gap_x = gap * 2 + (width % 2) as i32; + let y_extra = (height % 3) as i32; + let mid_gap_y = gap * 2 + y_extra.div_euclid(2); + + let w = (width as i32 - gap * 2 - mid_gap_x) / 2; + let h = (height as i32 - gap * 2 - mid_gap_y * 2).div_euclid(3); + // Distribute any leftover height in to the middle row of blocks. + let h_m = height as i32 - gap * 2 - mid_gap_y * 2 - h * 2; + + if tl { + canvas.box_(gap, gap, gap + w, gap + h, Color::ON); + } + if tr { + canvas.box_( + gap + w + mid_gap_x, + gap, + gap + w + mid_gap_x + w, + gap + h, + Color::ON, + ); + } + if ml { + canvas.box_( + gap, + gap + h + mid_gap_y, + gap + w, + gap + h + mid_gap_y + h_m, + Color::ON, + ); + } + if mr { + canvas.box_( + gap + w + mid_gap_x, + gap + h + mid_gap_y, + gap + w + mid_gap_x + w, + gap + h + mid_gap_y + h_m, + Color::ON, + ); + } + if bl { + canvas.box_( + gap, + gap + h + mid_gap_y + h_m + mid_gap_y, + gap + w, + gap + h + mid_gap_y + h_m + mid_gap_y + h, + Color::ON, + ); + } + if br { + canvas.box_( + gap + w + mid_gap_x, + gap + h + mid_gap_y + h_m + mid_gap_y, + gap + w + mid_gap_x + w, + gap + h + mid_gap_y + h_m + mid_gap_y + h, + Color::ON, + ); + } + Ok(()) +} + +/// Sixteenth Blocks +pub fn draw1ce90_1ceaf( + cp: u32, + canvas: &mut Canvas, + _width: u32, + _height: u32, + metrics: &Metrics, +) -> Result<(), DrawError> { + let q = Fraction::QUARTERS; + match cp { + 0x1CE90 => fill(metrics, canvas, q[0], q[1], q[0], q[1]), + 0x1CE91 => fill(metrics, canvas, q[1], q[2], q[0], q[1]), + 0x1CE92 => fill(metrics, canvas, q[2], q[3], q[0], q[1]), + 0x1CE93 => fill(metrics, canvas, q[3], q[4], q[0], q[1]), + 0x1CE94 => fill(metrics, canvas, q[0], q[1], q[1], q[2]), + 0x1CE95 => fill(metrics, canvas, q[1], q[2], q[1], q[2]), + 0x1CE96 => fill(metrics, canvas, q[2], q[3], q[1], q[2]), + 0x1CE97 => fill(metrics, canvas, q[3], q[4], q[1], q[2]), + 0x1CE98 => fill(metrics, canvas, q[0], q[1], q[2], q[3]), + 0x1CE99 => fill(metrics, canvas, q[1], q[2], q[2], q[3]), + 0x1CE9A => fill(metrics, canvas, q[2], q[3], q[2], q[3]), + 0x1CE9B => fill(metrics, canvas, q[3], q[4], q[2], q[3]), + 0x1CE9C => fill(metrics, canvas, q[0], q[1], q[3], q[4]), + 0x1CE9D => fill(metrics, canvas, q[1], q[2], q[3], q[4]), + 0x1CE9E => fill(metrics, canvas, q[2], q[3], q[3], q[4]), + 0x1CE9F => fill(metrics, canvas, q[3], q[4], q[3], q[4]), + 0x1CEA0 => fill(metrics, canvas, q[2], q[4], q[3], q[4]), + 0x1CEA1 => fill(metrics, canvas, q[1], q[4], q[3], q[4]), + 0x1CEA2 => fill(metrics, canvas, q[0], q[3], q[3], q[4]), + 0x1CEA3 => fill(metrics, canvas, q[0], q[2], q[3], q[4]), + 0x1CEA4 => fill(metrics, canvas, q[0], q[1], q[2], q[4]), + 0x1CEA5 => fill(metrics, canvas, q[0], q[1], q[1], q[4]), + 0x1CEA6 => fill(metrics, canvas, q[0], q[1], q[0], q[3]), + 0x1CEA7 => fill(metrics, canvas, q[0], q[1], q[0], q[2]), + 0x1CEA8 => fill(metrics, canvas, q[0], q[2], q[0], q[1]), + 0x1CEA9 => fill(metrics, canvas, q[0], q[3], q[0], q[1]), + 0x1CEAA => fill(metrics, canvas, q[1], q[4], q[0], q[1]), + 0x1CEAB => fill(metrics, canvas, q[2], q[4], q[0], q[1]), + 0x1CEAC => fill(metrics, canvas, q[3], q[4], q[0], q[2]), + 0x1CEAD => fill(metrics, canvas, q[3], q[4], q[0], q[3]), + 0x1CEAE => fill(metrics, canvas, q[3], q[4], q[1], q[4]), + 0x1CEAF => fill(metrics, canvas, q[3], q[4], q[2], q[4]), + _ => unreachable!(), + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn circle_piece( + canvas: &mut Canvas, + width: u32, + height: u32, + metrics: &Metrics, + x: f64, + y: f64, + w: f64, + h: f64, + corner: Corner, +) -> Result<(), DrawError> { + // Radius in pixels of the arc we need to draw. + let wdth = f64::from(width) * w; + let hght = f64::from(height) * h; + + // Position in pixels (rather than cells) for x/y + let xp = f64::from(width) * x; + let yp = f64::from(height) * y; + + // Set the clip so we don't include anything outside of the cell. + canvas.clip_left = canvas.padding_x; + canvas.clip_right = canvas.padding_x; + canvas.clip_top = canvas.padding_y; + canvas.clip_bottom = canvas.padding_y; + + // Coefficient for approximating a circular arc. + let c = (std::f64::consts::SQRT_2 - 1.0) * 4.0 / 3.0; + let cw = c * wdth; + let ch = c * hght; + + let thick = f64::from(metrics.box_thickness); + let ht = thick * 0.5; + + let mut path = Path::new(); + + match corner { + Corner::Tl => { + path.move_to(wdth - xp, ht - yp); + path.curve_to( + wdth - cw - xp, + ht - yp, + ht - xp, + hght - ch - yp, + ht - xp, + hght - yp, + ); + } + Corner::Tr => { + path.move_to(wdth - xp, ht - yp); + path.curve_to( + wdth + cw - xp, + ht - yp, + wdth * 2.0 - ht - xp, + hght - ch - yp, + wdth * 2.0 - ht - xp, + hght - yp, + ); + } + Corner::Bl => { + path.move_to(ht - xp, hght - yp); + path.curve_to( + ht - xp, + hght + ch - yp, + wdth - cw - xp, + hght * 2.0 - ht - yp, + wdth - xp, + hght * 2.0 - ht - yp, + ); + } + Corner::Br => { + path.move_to(wdth * 2.0 - ht - xp, hght - yp); + path.curve_to( + wdth * 2.0 - ht - xp, + hght + ch - yp, + wdth + cw - xp, + hght * 2.0 - ht - yp, + wdth - xp, + hght * 2.0 - ht - yp, + ); + } + } + + canvas.stroke_path( + &path, + StrokeOptions { + line_cap_mode: CapMode::Butt, + line_width: f64::from(metrics.box_thickness), + }, + Color::ON, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn octant_table_parses() { + assert_eq!(octants().len(), 230); + // BLOCK OCTANT-3 + assert_eq!(0b100, octants()[0]); + // BLOCK OCTANT-23 + assert_eq!(0b110, octants()[1]); + } +} diff --git a/crates/ghostty-font/src/sprite/face.rs b/crates/ghostty-font/src/sprite/face.rs new file mode 100644 index 0000000..8b89b84 --- /dev/null +++ b/crates/ghostty-font/src/sprite/face.rs @@ -0,0 +1,551 @@ +//! This implements the built-in "sprite face". This font renders +//! the built-in glyphs for the terminal, such as box drawing fonts, as well +//! as specific sprites that are part of our rendering model such as +//! text decorations (underlines). +//! +//! This isn't really a "font face" so much as it is quacks like a font +//! face with regards to how it works with font.Group. We don't use any +//! dynamic dispatch so it isn't truly an interface but the functions +//! and behaviors are close enough to a system face that it makes it easy +//! to integrate with font.Group. This is desirable so that higher level +//! processes such as GroupCache, Shaper, etc. don't need to be aware of +//! special sprite handling and just treat it like a normal font face. +//! +//! Port of `font/sprite/Face.zig`. + +use std::sync::OnceLock; + +use super::Sprite; +use super::canvas::{Canvas, DrawError}; +use super::draw::common::{DrawFn, Range}; +use super::draw::{ + block, box_, braille, branch, geometric_shapes, powerline, special, + symbols_for_legacy_computing, symbols_for_legacy_computing_supplement, +}; +use crate::atlas::{self, Atlas}; +use crate::glyph::Glyph; +use crate::metrics::Metrics; + +/// Text/emoji presentation. +// TODO(integration): replace with the real `font::Presentation` once the +// rest of the font engine (face.zig / main.zig) is ported. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Presentation { + Text, + Emoji, +} + +/// The subset of `font.face.RenderOptions` that the sprite face uses. +// TODO(integration): replace with the real `font::face::RenderOptions`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct RenderOptions { + /// The number of grid cells this glyph will take up (0..=3, a `u2` in Zig). + pub cell_width: Option, +} + +/// Errors from [`Face::render_glyph`]. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum RenderError { + #[error(transparent)] + Draw(#[from] DrawError), + #[error(transparent)] + Atlas(#[from] atlas::Error), +} + +/// All codepoint ranges drawn by the draw modules, sorted ascending by `min`. +/// Panics (at first use) if any ranges overlap, mirroring Zig's +/// compile error. +pub fn ranges() -> &'static [Range] { + static RANGES: OnceLock> = OnceLock::new(); + RANGES.get_or_init(|| { + let mut r: Vec = [ + block::RANGES, + box_::RANGES, + braille::RANGES, + branch::RANGES, + geometric_shapes::RANGES, + powerline::RANGES, + symbols_for_legacy_computing::RANGES, + symbols_for_legacy_computing_supplement::RANGES, + ] + .concat(); + + // Sort ranges in ascending order + r.sort_by_key(|r| r.min); + + // Ensure there's no overlapping ranges + let mut i = 0; + for (k, n) in r.iter().enumerate() { + assert!( + n.min > i || k == 0, + "Codepoint range {:X}..{:X} overlaps previous range ending at {:X}", + n.min, + n.max, + i + ); + i = n.max; + } + + r + }) +} + +fn get_draw_fn(cp: u32) -> Option { + // For special sprites (cursors, underlines, etc.) all sprites are drawn + // by functions from `special` that share the name of the enum field. + if cp >= Sprite::START { + return Sprite::from_u32(cp).map(|s| match s { + Sprite::Underline => special::underline as DrawFn, + Sprite::UnderlineDouble => special::underline_double, + Sprite::UnderlineDotted => special::underline_dotted, + Sprite::UnderlineDashed => special::underline_dashed, + Sprite::UnderlineCurly => special::underline_curly, + Sprite::Strikethrough => special::strikethrough, + Sprite::Overline => special::overline, + Sprite::CursorRect => special::cursor_rect, + Sprite::CursorHollowRect => special::cursor_hollow_rect, + Sprite::CursorBar => special::cursor_bar, + Sprite::CursorUnderline => special::cursor_underline, + }); + } + + let r = ranges(); + // Ranges are sorted and non-overlapping: binary search. + let idx = r.partition_point(|range| range.max < cp); + r.get(idx) + .filter(|range| cp >= range.min && cp <= range.max) + .map(|range| range.draw) +} + +/// The sprite face. +#[derive(Debug, Clone, Copy)] +pub struct Face { + /// Grid metrics for rendering sprites. + pub metrics: Metrics, +} + +impl Face { + pub fn new(metrics: Metrics) -> Face { + Face { metrics } + } + + /// Returns true if the codepoint exists in our sprite font. + pub fn has_codepoint(&self, cp: u32, _p: Option) -> bool { + // We ignore presentation. No matter what presentation is + // requested we always provide glyphs for our codepoints. + get_draw_fn(cp).is_some() + } + + /// Render the glyph. + pub fn render_glyph( + &self, + atlas: &mut Atlas, + cp: u32, + opts: RenderOptions, + ) -> Result { + if cfg!(debug_assertions) && !self.has_codepoint(cp, None) { + log::error!("invalid codepoint cp={cp:x}"); + unreachable!("invalid sprite codepoint"); + } + + // It should be impossible for this to be null and we assert that + // in runtime safety modes but in case it is its not worth memory + // corruption so we return a valid, blank glyph. + let Some(draw) = get_draw_fn(cp) else { + return Ok(Glyph::default()); + }; + + let metrics = &self.metrics; + + // We adjust our sprite width based on the cell width. + let width = match opts.cell_width.unwrap_or(1) { + 0 | 1 => metrics.cell_width, + w => metrics.cell_width * u32::from(w), + }; + + let height = metrics.cell_height; + + let padding_x = width / 4; + let padding_y = height / 4; + + // Make a canvas of the desired size + let mut canvas = Canvas::new(width, height, padding_x, padding_y); + + draw(cp, &mut canvas, width, height, metrics)?; + + // Write the drawing to the atlas + let region = canvas.write_atlas(atlas)?; + + Ok(Glyph { + width: region.width, + height: region.height, + offset_x: canvas.clip_left as i32 - padding_x as i32, + offset_y: region.height.saturating_add(canvas.clip_bottom) as i32 - padding_y as i32, + atlas_x: region.x, + atlas_y: region.y, + }) + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use super::*; + use crate::metrics::FaceMetrics; + + fn manifest_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + } + + /// Directory where review sheets / diffs are written (`/target/sprite_sheets`). + fn out_dir() -> PathBuf { + let d = manifest_dir().join("../../target/sprite_sheets"); + std::fs::create_dir_all(&d).expect("create sprite_sheets dir"); + d + } + + fn write_png(path: &Path, w: u32, h: u32, data: &[u8]) { + let file = std::fs::File::create(path).expect("create png"); + let mut enc = png::Encoder::new(std::io::BufWriter::new(file), w, h); + enc.set_color(png::ColorType::Grayscale); + enc.set_depth(png::BitDepth::Eight); + let mut writer = enc.write_header().expect("png header"); + writer.write_image_data(data).expect("png data"); + } + + fn read_png(path: &Path) -> (u32, u32, Vec) { + let file = std::fs::File::open(path).expect("open reference png"); + let mut reader = png::Decoder::new(std::io::BufReader::new(file)) + .read_info() + .expect("png info"); + let mut buf = vec![0; reader.output_buffer_size().expect("png size")]; + let info = reader.next_frame(&mut buf).expect("png frame"); + buf.truncate(info.buffer_size()); + // The reference files are 8-bit grayscale; if not, take the first channel. + let ch = info.color_type.samples(); + let gray = if ch == 1 { + buf + } else { + buf.chunks_exact(ch).map(|c| c[0]).collect() + }; + (info.width, info.height, gray) + } + + fn test_metrics(width: u32, ascent: u32, descent: u32, thickness: u32) -> Metrics { + Metrics::calc(FaceMetrics { + // Fudged number, not used in anything we care about here. + px_per_em: 16.0, + cell_width: f64::from(width), + ascent: f64::from(ascent), + descent: -f64::from(descent), + line_gap: 0.0, + underline_thickness: Some(f64::from(thickness)), + strikethrough_thickness: Some(f64::from(thickness)), + ..Default::default() + }) + } + + /// Stats of the comparison of one atlas sheet against its reference. + #[derive(Debug, Default)] + struct Diff { + /// number of pixels that differ at all + differing: usize, + /// number of pixels differing by more than `BIG` + big: usize, + /// number of pixels that are non-zero in either image + ink: usize, + max: u8, + } + + /// Per-pixel difference above which a pixel counts as a "big" difference. + /// z2d and tiny-skia antialias differently, so small differences are expected + /// along diagonals and curves. + const BIG: u8 = 96; + + fn diff_sheet(test: &[u8], reference: &[u8]) -> Diff { + let mut d = Diff::default(); + for (t, r) in test.iter().zip(reference) { + if *t != 0 || *r != 0 { + d.ink += 1; + } + let delta = t.abs_diff(*r); + if delta != 0 { + d.differing += 1; + d.max = d.max.max(delta); + } + if delta > BIG { + d.big += 1; + } + } + d + } + + /// Draws all ranges in to a set of 16x16 glyph sheets and compares them + /// with the reference files (with tolerance). Returns the failure + /// messages (empty if all ok). Mirrors `testDrawRanges` in Face.zig. + fn test_draw_ranges(width: u32, ascent: u32, descent: u32, thickness: u32) -> Vec { + let metrics = test_metrics(width, ascent, descent, thickness); + let height = ascent + descent; + let padding_x = width / 4; + let padding_y = height / 4; + + let mut canvas = Canvas::new(width, height, padding_x, padding_y); + + // We render glyphs in batches of 256, which we copy (including padding) to + // a 16 by 16 surface to be compared with the reference file for that range. + let stride_x = width + 2 * padding_x; + let stride_y = height + 2 * padding_y; + let (aw, ah) = (stride_x * 16, stride_y * 16); + let mut atlas = vec![0u8; (aw * ah) as usize]; + + let rs = ranges(); + let mut i = rs[0].min & !0xff; + let mut failures = Vec::new(); + + let flush = |i: u32, atlas: &[u8], failures: &mut Vec| { + let name = format!( + "U+{i:X}...U+{:X}-{width}x{height}+{thickness}.png", + i + 0xff + ); + write_png(&out_dir().join(format!("test-{name}")), aw, ah, atlas); + + let ref_path = manifest_dir().join("testdata").join(&name); + if !ref_path.exists() { + failures.push(format!("missing reference file {name}")); + return; + } + let (rw, rh, reference) = read_png(&ref_path); + if (rw, rh) != (aw, ah) { + failures.push(format!("{name}: size {aw}x{ah} != reference {rw}x{rh}")); + return; + } + let d = diff_sheet(atlas, &reference); + + // Tolerances: antialiasing differences between z2d and tiny-skia + // are fine, but the geometry must match: very few pixels may be + // off by a lot, relative to the amount of ink on the sheet. + let big_ratio = d.big as f64 / d.ink.max(1) as f64; + let ok = big_ratio <= 0.04; + if !ok || d.differing > 0 { + // Write a visual diff (red = reference, green = ours). + let mut rgb = Vec::with_capacity(atlas.len() * 3); + for (t, r) in atlas.iter().zip(&reference) { + if t == r { + rgb.extend_from_slice(&[t / 3, t / 3, t / 3]); + } else { + rgb.extend_from_slice(&[*r, *t, 0]); + } + } + let path = out_dir().join(format!("diff-{name}")); + let file = std::fs::File::create(&path).expect("create diff png"); + let mut enc = png::Encoder::new(std::io::BufWriter::new(file), aw, ah); + enc.set_color(png::ColorType::Rgb); + enc.set_depth(png::BitDepth::Eight); + enc.write_header() + .and_then(|mut w| w.write_image_data(&rgb)) + .expect("write diff"); + } + if !ok { + failures.push(format!( + "{name}: {} big diffs out of {} ink pixels ({:.2}%), {} differing, max delta {}", + d.big, + d.ink, + big_ratio * 100.0, + d.differing, + d.max + )); + } + }; + + for range in rs { + for cp in range.min..=range.max { + // If we've moved to a new batch of 256, check the + // current one and clear the surface for the next one. + if cp - i >= 0x100 { + flush(i, &atlas, &mut failures); + i = cp & !0xff; + atlas.fill(0); + } + + (range.draw)(cp, &mut canvas, width, height, &metrics).expect("draw"); + canvas.clear_clipping_regions(); + + // Composite (src) the canvas into its slot of the sheet. + let ox = stride_x * ((cp - i) % 16); + let oy = stride_y * ((cp - i) / 16); + for y in 0..canvas.sfc_height { + let src = + &canvas.sfc[(y * canvas.sfc_width) as usize..][..canvas.sfc_width as usize]; + let dst = ((oy + y) * aw + ox) as usize; + atlas[dst..dst + src.len()].copy_from_slice(src); + } + canvas.reset(); + } + } + flush(i, &atlas, &mut failures); + + failures + } + + #[test] + fn sprite_face_render_all_sprites() { + // Renders all sprites to sheets and compares them to the reference + // images from upstream (rendered with z2d), with a tolerance for + // antialiasing differences. + // + // testDrawRanges(width, ascent, descent, thickness): + // + // We compare 4 different sets of metrics; + // - even cell size / even thickness + // - even cell size / odd thickness + // - odd cell size / even thickness + // - odd cell size / odd thickness + // (Also a decreasing range of sizes.) + let mut failures = Vec::new(); + failures.extend(test_draw_ranges(18, 30, 6, 4)); + failures.extend(test_draw_ranges(12, 20, 4, 3)); + failures.extend(test_draw_ranges(11, 19, 2, 2)); + failures.extend(test_draw_ranges(9, 15, 2, 1)); + assert!( + failures.is_empty(), + "sprite sheets differ from reference (see {}):\n{}", + out_dir().display(), + failures.join("\n") + ); + } + + #[test] + fn ranges_sorted_and_non_overlapping() { + let rs = ranges(); + assert!(!rs.is_empty()); + for w in rs.windows(2) { + assert!(w[0].min <= w[0].max); + assert!( + w[0].max < w[1].min, + "{:X?} overlaps {:X?}", + w[0].max, + w[1].min + ); + } + } + + #[test] + fn has_codepoint() { + let face = Face::new(test_metrics(9, 15, 2, 1)); + assert!(face.has_codepoint(0x2500, None)); + assert!(face.has_codepoint(0x2588, Some(Presentation::Emoji))); + assert!(face.has_codepoint(0x28ff, None)); + assert!(face.has_codepoint(0xe0b0, None)); + assert!(face.has_codepoint(0x1fb00, None)); + assert!(face.has_codepoint(Sprite::Underline as u32, None)); + assert!(face.has_codepoint(Sprite::CursorUnderline as u32, None)); + assert!(!face.has_codepoint(u32::from('a'), None)); + assert!(!face.has_codepoint(Sprite::START + 11, None)); + } + + /// Render every supported codepoint (and special sprite) through the + /// full `render_glyph` path at several cell sizes / cell widths and + /// check basic invariants. Also writes a review sheet to + /// `target/sprite_sheets/review-x.png`. + #[test] + fn render_every_sprite_at_several_sizes() { + for (w, asc, desc, th) in [ + (9, 15, 2, 1), + (12, 20, 4, 3), + (18, 30, 6, 4), + (7, 11, 2, 1), + (25, 40, 8, 5), + ] { + let metrics = test_metrics(w, asc, desc, th); + let face = Face::new(metrics); + let mut atlas = Atlas::new(4096, crate::atlas::Format::Grayscale); + + let mut cps: Vec = ranges().iter().flat_map(|r| r.min..=r.max).collect(); + cps.extend(Sprite::ALL.iter().map(|s| *s as u32)); + + let mut inked = 0usize; + let mut count = 0usize; + for cp in cps { + for cell_width in [None, Some(1), Some(2)] { + let g = face + .render_glyph(&mut atlas, cp, RenderOptions { cell_width }) + .unwrap_or_else(|e| panic!("cp {cp:X} {w}x{}: {e}", asc + desc)); + // The glyph must stay within the padded canvas. + let cw = metrics.cell_width * u32::from(cell_width.unwrap_or(1).max(1)); + assert!(g.width <= cw + 2 * (cw / 4), "cp {cp:X} too wide"); + assert!( + g.height <= metrics.cell_height + 2 * (metrics.cell_height / 4), + "cp {cp:X} too tall" + ); + if g.width > 0 && g.height > 0 { + // Trimmed regions must contain ink in every edge row/col + // (that is the definition of trim). + let row = |y: u32| { + (0..g.width).any(|x| { + atlas.data[((g.atlas_y + y) * atlas.size + g.atlas_x + x) as usize] + != 0 + }) + }; + assert!(row(0) && row(g.height - 1), "cp {cp:X} not trimmed"); + inked += 1; + } + count += 1; + } + } + // Most sprites have ink; only blank braille/space-like ones don't. + assert!(inked * 10 > count * 9, "{inked} of {count} have ink"); + } + + // A review sheet of the most common blocks. + for (w, asc, desc, th) in [(9, 15, 2, 1), (18, 30, 6, 4)] { + let metrics = test_metrics(w, asc, desc, th); + let height = asc + desc; + let (px, py) = (w / 4, height / 4); + let (sx, sy) = (w + 2 * px, height + 2 * py); + let cps: Vec = ranges().iter().flat_map(|r| r.min..=r.max).collect(); + let cols = 64u32; + let rows = (cps.len() as u32).div_ceil(cols); + let (aw, ah) = (sx * cols, sy * rows); + let mut sheet = vec![0u8; (aw * ah) as usize]; + let mut canvas = Canvas::new(w, height, px, py); + for (n, cp) in cps.iter().enumerate() { + let draw = get_draw_fn(*cp).expect("draw fn"); + draw(*cp, &mut canvas, w, height, &metrics).expect("draw"); + let (ox, oy) = (sx * (n as u32 % cols), sy * (n as u32 / cols)); + for y in 0..canvas.sfc_height { + let src = + &canvas.sfc[(y * canvas.sfc_width) as usize..][..canvas.sfc_width as usize]; + let dst = ((oy + y) * aw + ox) as usize; + sheet[dst..dst + src.len()].copy_from_slice(src); + } + canvas.reset(); + } + write_png( + &out_dir().join(format!("review-all-{w}x{height}.png")), + aw, + ah, + &sheet, + ); + } + } + + #[test] + fn render_glyph_offsets_for_cursor_bar() { + let face = Face::new(test_metrics(9, 15, 2, 1)); + let mut atlas = Atlas::new(256, crate::atlas::Format::Grayscale); + let g = face + .render_glyph( + &mut atlas, + Sprite::CursorBar as u32, + RenderOptions::default(), + ) + .unwrap(); + // 1px bar placed at x = -1 (half its thickness rounded up). + assert_eq!(1, g.width); + assert_eq!(17, g.height); + assert_eq!(-1, g.offset_x); + // offset_y = region.height + clip_bottom - padding_y = 17 + 4 - 4 + assert_eq!(17, g.offset_y); + } +} diff --git a/crates/ghostty-font/src/sprite/mod.rs b/crates/ghostty-font/src/sprite/mod.rs new file mode 100644 index 0000000..e411c0f --- /dev/null +++ b/crates/ghostty-font/src/sprite/mod.rs @@ -0,0 +1,72 @@ +//! The built-in "sprite font": box drawing, block elements, braille, powerline +//! and other glyphs rasterized by code, plus special sprites (underlines, +//! cursors). Port of `font/sprite.zig`. + +pub mod canvas; +pub mod draw; +pub mod face; + +pub use canvas::{Box, Canvas, Color, Point}; +pub use face::Face; + +/// Sprites are represented as special codepoints outside of the Unicode +/// codepoint range. Unicode maxes out at U+10FFFF (21 bits), and we use the +/// high 11 bits to hide our special characters. +/// +/// These characters are ONLY used for rendering and NEVER used written to +/// text files or any other exported format, so we don't use the Private Use +/// Area of Unicode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u32)] +pub enum Sprite { + Underline = Sprite::START, + UnderlineDouble, + UnderlineDotted, + UnderlineDashed, + UnderlineCurly, + + Strikethrough, + + Overline, + + CursorRect, + CursorHollowRect, + CursorBar, + CursorUnderline, +} + +impl Sprite { + /// Start 1 above the maximum Unicode codepoint. + pub const START: u32 = 0x1F_FFFF + 1; + pub const END: u32 = u32::MAX; + + pub const ALL: [Sprite; 11] = [ + Sprite::Underline, + Sprite::UnderlineDouble, + Sprite::UnderlineDotted, + Sprite::UnderlineDashed, + Sprite::UnderlineCurly, + Sprite::Strikethrough, + Sprite::Overline, + Sprite::CursorRect, + Sprite::CursorHollowRect, + Sprite::CursorBar, + Sprite::CursorUnderline, + ]; + + /// Convert a codepoint in the sprite range to a `Sprite`. + pub fn from_u32(cp: u32) -> Option { + Sprite::ALL.iter().copied().find(|s| *s as u32 == cp) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sprite_start() { + assert_eq!(Sprite::START, Sprite::Underline as u32); + assert_eq!(Sprite::START + 10, Sprite::CursorUnderline as u32); + } +} diff --git a/crates/ghostty-font/testdata/U+1CC00...U+1CCFF-11x21+2.png b/crates/ghostty-font/testdata/U+1CC00...U+1CCFF-11x21+2.png new file mode 100644 index 0000000000000000000000000000000000000000..e04e7726b965fab41da5ed0c4f7e3501709db29f GIT binary patch literal 1032 zcmeAS@N?(olHy`uVBq!ia0y~yVEDkm!1#fKfq{Wxujk%h3=GWYJzX3_D(1Yoq0M*L zK%&L*{{Ksw+ADUu)~#4AIW6?S|Aiis%pm0;ELJ^l{o~uMlfMbc92aVO9N|K#OMd;^-$%bI|M2D0PiB8XWa(s1dk42l1BL^3`V0&V|Np<4oxOIcfxz*?1{M_s zMote0CLyhdV3Lv3V@1nNo%P=7%hEH|e>dLwufOwT<2Hl$ex7>&-mH*)QCj?e;;W$h zJHAbIa(>veb-jobSNfXTr56G$lHY2#nrZSJ$Zu11ySDTxr;hGsJzs}JjXC?a*f4TA zCu;DtGJ<@-@I!vf;`r^?CWqN?i&x__>8?JTe)GECd^etRCU5K`vS+_45xy+N_5YHh z+Jq@j7k&P?Klq29<=097*~{Z!|9FwAIQ!65r`jk+Pk-;CHS;o|o zUZE@>8^2nLOH*Cr%F7Nprb@$W6gI*+cJ`BX|N&@*e^(Zuc>f{R(xHHik2J5>r`f({6Bfpu41mhkdHo7uLV5x?<$-2_^E64tW15= zHxI>C_d2J(i1N8RX`%P;g$u0L@YJqcDR1`WVa%d6|LzxkGMHPzqps_Bjq`lbwk>lr zPoKZkw8nVP!`F?z4?nF}<&uogPx$nZdwtzyqx0?P0hnw*@sc5b`msu2JtJEK-@~g2X&s{an^L HB{Ts5rQoPc literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+1CC00...U+1CCFF-12x24+3.png b/crates/ghostty-font/testdata/U+1CC00...U+1CCFF-12x24+3.png new file mode 100644 index 0000000000000000000000000000000000000000..ce1b1c422e0f413a6084e0ed9ede00def5a9eff9 GIT binary patch literal 1295 zcmeAS@N?(olHy`uVBq!ia0y~yU{qjWU~=GKU|?Y2`0`{n0|U!7PZ!6KiaBqt8FH~G z3b+KmuYY+jXL9D6&M3)y+@cGAOw48ksfObDVWG2E@4p#nE~p`(af5x<-6GXSCOE7* zZ|_%KRPs2Gi8Y^T>v}eGzaNYY4FCVXx#8HxoG9UX@$dUx?6#b$m4CWoRu*SO@yasKCHbvC5tS?5vs56So)$v|eWva$^#j(7>Y7!=j>~2xhrBsm$3wqp{%S zl9JPd`|`aNpYt4DYR?~tA@UjAZcgO0Ss>keEdvPxHGK1--v^V?Y7yE;O&{iut|8nq+qo)o$tTf6>Oji?Zt z;k&bGipTYtOx0(<63SHW_piEfxla4`Rl)-eIjv zwq;5w6!2Fc517ScTFbM&=#=n#KGt1mC4<{JAc=xNCbboc;sk0 zQ*XCYm-*wTyJY|B7Hz**>-g$S&G*w9a~9fifB9wWqrN+&PT0fZz{IbL57wu7Y${yv zv)%RcUgND@F59Mke08h()HBgGWx)rB1(whCEqr}gp(s$7Icavs#5k+Rd38q?24!xa z^!~}M-9N9kh%qlp%&sh$TiO=($G~$Z^XV6V{>fE#I4`{<@ze8RiH_R-;`#t8=_7C<;H#J1fbXu9? zR1tRUqL$7}(Oo56`90~a4ofdBiCY=6yu`aQ#4AiI?=|O=;6MH!9wfJ1dM;?&SYjuy z`txV;v_y$EY-nOVBq1L*;ryuC9G(s{5{Knt^`sZ9A@MMyZ8Tk=}Aqf&V9oIF$AvSw;&g&0E2i2M1Bwk vd+6=qMji$Rh64)queD_sY-0gQ!SFnW_a%=Gr&g^8S;OGz>gTe~DWM4fdu|#f literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+1CC00...U+1CCFF-18x36+4.png b/crates/ghostty-font/testdata/U+1CC00...U+1CCFF-18x36+4.png new file mode 100644 index 0000000000000000000000000000000000000000..1c21f688a5037887283040e11b02b9024be5702d GIT binary patch literal 2193 zcmeAS@N?(olHy`uVBq!ia0y~yU|hh!z?{Irz`(%Zuk~1hfq^5!)5S5QV$PcbhKvjh z42Kqc_0Q$>*8nL%!aF1x7#RNle{)fh>i`1}%fbKqt*$Or)5=L?d!)p74WtpGc)zLf z=HFGjZ?rkoG9Bd<+0I|Gv5mj+5lfe_hTj4U7-Pq42Y!foHx6<(7zi*N{I`1>Q?uYv z)0vHXn2sO(cW$;yz#Iq5=k4b|o=@j~@3vrvb>p$iad8#qOu9}8bKnL-8SknV++^17 z7XaG@anO%OX0XF=8gdGjoSiHkLOhrGfx^1`chmjD4!!Qbv{~JlMJPi{#AAUj6Q_wN zm&ygPMwVGztU?*x4otqSOq^R>6&ja0HL@`6`^*G(VpQP7#|AvD0vxRZ9Bly{Z2}yP z5K@4nErDZK=j<(INw%kR&E?IjjLkRSjq39EzchL7)q1X0r-cC;BIwkk`Uc76Pya5O zlQGl%^1D>EMU478c_)AW&HjG*!?x@EE+)(OKZt+f9sl*T?Hd01Dtb?6yi&9}yko+D z)x)2I3)af44KsLPUS8LCSnT+MSkHj}zwO^g*nZ1ZOH*4UE}eoukl2am_wRjgjj_krbXdsK2l-zv76 z^_=pr^qz9`s{XuT6?yU5zOQxydp}?PDY9+iJK;uWAC`TmzqU)ThCSb>_M&XrV$%hp zTZG&c$pKMMSSE}Uj_Inew|Heq$g((`BQYtPL~4vwmL_dMM!hv`x#$`y0&Dr+sglON3}av`GW^7~^f>L(UNdo;;rnyTyocl(r@xIr^tr~A#1w`Te3S_!Y|IdQ=D61=ejD_8_hzeW$*u#X$(Gf z{M$Eo)sn;PpPtY9@b>S|S!aDF&;Ie8dmYEYttoM{95P*s&FoW)Up=W0cJebwJ|8c? z+v;Bc=ef>xc5!w;G@eWmd6!)FU~>As z*YIcQf|lz7HP@%iFuwidjQGup4nuB{7y0rfp%2!1wcL63Ug&r4e%-65qt3go{jy`b z|B{FDkn-fV@2bNFJgyJ_zW>$O8~>{1hTaP~D+_rMtC##00^YlJ#dW(MR{g!?AcwtnI3KPr*){&0)OL@;uS9@(7GXul*{EP|UYOY>}#?k2^%Umnkz z;S>B#?MBMGN4dp$JO_GA-xVd#(zC00#Flv`;ls~|AD2~KxOvol`yGv0l56dcEMoGt z`*Wbxaz??a-LE$IPVVO~WIu9bcUacrEML0@b~dhw4Nrn{A58At&@?edC27yk%~L04 zsIN z^sF#!0<{7{#X(JBo zL8uaVm~hK1L=BS$VW<+gYteoDK?Gd|x{nV8qpQI1u|G6S;NHRTG29xslH7-^sNVU& pffj5SiC_a*#oNPh&mcKfhUKs3#kQ#8;Ax;p@^tlcS?83{1OT$=XSM(U literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+1CC00...U+1CCFF-9x17+1.png b/crates/ghostty-font/testdata/U+1CC00...U+1CCFF-9x17+1.png new file mode 100644 index 0000000000000000000000000000000000000000..459822e6359f512fa3654227681071c1847c59e0 GIT binary patch literal 794 zcmeAS@N?(olHy`uVBq!ia0y~yV7S1*z&L?}fq{YH*Mp4(3=B*UJzX3_D(1YoevysY zP@wH%?f>a{nmapuJ2O@5-&mS&J#8Sr_TpS}IU#Ujc+ot1IJ5ord7|Nq}iug^WIAaMNo0-*^3oc>O#3JZm_ z7J}Fw4opH5Hl!}ybG*=_413y)P(cHe>X? zjc1!ppZ$L#GkyL4rh<8k)5=ZzpS($an-y#2qB?oSo*x^Y#z%kf6`1sLmiI)HYZtyQ zP~4)ZC;wsDv|e%1x2{K2cl1AC`ubwiojWQib&8H!uhxiIO>xR-zkHAHt>=#?o>%9* z_KyGUsk7&!{=c)+*DqaEXR%K6VT9)0wBu#0f6|oq$(Qp~xe9$yy|jGZ{-WHPd27%8 zx^1njI(f6oVYj@;s!yvY)JQPhA*2PdB5`C$+<3D zlQqkZweOUT*nEBUIgz6`LuXhin=aq@>PmQtc*{jSx6Aq~e;K*{zwTpyEj`OBD)jl9 z7Z;-2@|b2>x4+XesBcf=xqB6yE^rVH`yaD|BlR#NCj$dR!-D_or6)B(k`heIfj|Z{ zCBFr^xL~pn+MN+i$!$Rm4Tuzs Mp00i_>zopr0J|(xO8@`> literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+1CD00...U+1CDFF-11x21+2.png b/crates/ghostty-font/testdata/U+1CD00...U+1CDFF-11x21+2.png new file mode 100644 index 0000000000000000000000000000000000000000..8d7de36aceef89fbd945cf8a56477f1936953b56 GIT binary patch literal 1280 zcmeAS@N?(olHy`uVBq!ia0y~yVEDkm!1#fKfq{Wxujk%h3=AyCJzX3_D(1Yo?$~ui zLBJvKef>+GGmBUQH<&EA`uARzmW5|+c`TPh-TD2$uBXe)(|&mPaJq2&Z2ne0ZZRE; z%7XHjC#D_VxI65{BlV*~EZkx`KUyDLkg|TP%fc@uEB|V#mv1d2yYHf_DiF3=E%s zGcz#!|NrK?Bi|tf0f&pf=kL;+E#PphjOX&c`8#H4op{|RZTC6*RpGY@TUGgad3bE@ zCt8?Qw;t18J8!*t*?dN3W@d58_4^Md>2pg+{NPORoTTD;>GtcpBH{XzwX;8${ma!g zFeoT2$d~IoYk&55z-sjf)!Il#9-bI686$@KZ2#g995}G`V5R97-(yG21eum^?G%WZ zq~hr*n|?Uo{rs%03+4OI3)zMCF!1a#{sD39wSz^i3Oo)M|Nqb0U2=*;h0Cts++A1p zGnwZ67H%&jo03qe&Pq2F6`i+5& zjjgXy)pL@`)>r4(uNHs4`Ekzd%?GS?czNzHZ0BFDmFL;(ucyKBR!~CXNAm-7yFaVn z_xK^U|8W&983X+AMoMZ>NcYL4nAeRXbEo?RG9u z*y8@+|6fLMC||qFb;y9HA@KhHrO%%#SY%4q&w5i+?if`hvgE*ls|Vj6zp_2FkmX*o zZo-4qf(_f{%WpDENK^>wcurc9UpmKDPcG@><<;AxdN&^soyNu{$L25E=i57zJMN!H zv$BDK!H3!ds?6`?3clO@cpjMyG3Shi=cFa_U;ZtvoMUPkU6h%S@Zjhn)8OQ1O$jrk z8XFt`bJj6I+-A+!Y#`up@b~;#Q>N)S|4HwCR_fjGrsJ6K0cG$0{$RUXo)7*ePqA%i zY_vW&aoWe3jg5`34@^?=j0$*pVd}crmH+iC_U){=v3k$XZ&B=w5)wah3WAR=jo!9L zcJtPG$7>mxnUAxqkIp_h+x^(KtxY`#4jed;Fa51A5EO*(SiC2xWY#VK_;$(9@~>-|4Gap{K;g*SD;Mj3KUMbpwiqc12@9JV z`*ZU-zR!>{j?HIeV`IC=7x(Ango2s_2M%1kvDmBN-}%kgD^AHBIB=jFl(gSweR)y! zOYe2Xwx^d4960ds;qStKE<282nenoU+rZ#M`+=EDFF%;Yzqjga{qa-3`4mMP>!B{S z6lyl$ak%*VzEx>Tgvj_GK`7-*F(|SSSczz)6 zndgIo^5x2B`s>fOKU|%=Yf;>R=JnO~%*^6C1uyRwzF^lAGbr#bxG+Jgn~i6Vq&?Vu zZ_jT8m01TA>R)HR(P(a|Z)t69Z9Sg|5(MFAOgk>LEK;c{ode=|y85}Sb4q9e04OF* AaR2}S literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+1CD00...U+1CDFF-12x24+3.png b/crates/ghostty-font/testdata/U+1CD00...U+1CDFF-12x24+3.png new file mode 100644 index 0000000000000000000000000000000000000000..ab6bec96d48174c49b4822c5bfe87c766207399a GIT binary patch literal 1870 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 S$~>$=Dm-2NT-G@yGywptj=R$U literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+1CD00...U+1CDFF-9x17+1.png b/crates/ghostty-font/testdata/U+1CD00...U+1CDFF-9x17+1.png new file mode 100644 index 0000000000000000000000000000000000000000..fc111e2d77136c5735c7665615301ff09dd5f436 GIT binary patch literal 1103 zcmeAS@N?(olHy`uVBq!ia0y~yV7S1*z&L?}fq{YH*Mp4(3=Aw{o-U3d6?5KPKik)$ zAmEyK@Bh-dXAXIGScqL^yYTPas{Ho()mf@b8FtKmJAJ==b%y@Sh--fyCqYJPUnxuDLMbU~NhA`6q67T{|u$fHc`m|Ezx^_jzFTG(Go> zuSJ>*4<9RA;m6U&)2Q`-wUE*mrG=V`w$nn}9Xc-XC^9fK1fAk%U|{(F|IIWEdkpm*sB=vbU&QFbKwek0jCu)FBW!hdbji6(_O4ByW01*nsM-IDSSzHVqmy1 z-5lblsg8V1iUKTu|1bY|BvI+e$&6`CtAzOXW~;}(X_T&8Yg}hsCvIhuk?~t}J$z11zn2!a{C?K#!;Fivl zBhx;A&1_%gWWR^giDRuuRCMCq+=&m?7N{sJ36x%VU$l#@k&8oBn2CX5!hxRUn+oo} zu@T-WrPxx&9eVnlfMxn@J9hQ2bu9u`iYA|5bt|q8aP~jWxbQvq)MysxAN~SG4jSzO z3=9V}&$tww(SB-t>8U`FzPbJte&*=g>#dwyei=A%JQb6wT^t$0*&=Z3%$|TBr$u?0 zL#Hml&=cvOG z;_$)(R1!5~xv t_Qc!6hP(_63=IqZt>t~lEzJZf9HJkT%x&bU`mhzm@pScbS?83{1OQvX+cE$E literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+1CE00...U+1CEFF-11x21+2.png b/crates/ghostty-font/testdata/U+1CE00...U+1CEFF-11x21+2.png new file mode 100644 index 0000000000000000000000000000000000000000..b3cda82d1391bf8a59367dae98436e1ed243ef7f GIT binary patch literal 1006 zcmeAS@N?(olHy`uVBq!ia0y~yVEDkm!1#fKfq{Wxujk%h3=GU$JzX3_D(1YoaWVI> zf&g2BSptjO35MDe96b{RjhK`xSn>n<4{*pe3Q9a&|7uy_+gn}^CXcHAe}5#?=eLuA zEtyAfV#a}Q&h~za&z^=W9xuq6QqmT`POf-y&)PZ4b~Be6GaME?gQxebDj1sn-b`RN*m5H0c3%cH!*xkR|rE|_M09)`P1cf`?rK#K6*{vC(Y?oE$^eLh5s2v4X&jW zOXi>0%ll|*$6eDGJrPs$j#S@`|7zrZd2Nvt+Yz_yTk)Ojwv|`@HEfd$Gi$$UyY%YX z!>`JsqW{G(eTAN%QuG7kTyLBY!v90 ztyBk5{o&r@&qCLae?I??v+G#S(+gbeH%~1T<=a%Sea_?z%bSUoOD`6=B({D(eUgu> zK2!o^qkYyMy~6DxDcbE}7AL~^Y){BBfE{$xkc-8TgZ0Ai`&GAZwz9-He=vT3P(-Xt zKFHE;-=25j-3OS~pFYRZn9r)__&ic=TJSf4hzHJ3r`Wz@Wn*CIyAeO{+xLY>?wwf5 zs`tfBPq$#9_`F}+m;P7#v*%LKO{EP7UT-pXd=TAu@?zzj&ZkpsS;T$>Sf=(@H!?Cb zEc^R3vtHioO>a!p1E>5amzcz>90z-U;3X8J8pp5f_p;{7bF zJUnxF-J69DWH2 z3!XiHi`RW)W?s&}r|REc_IXDCA(3;D)@R+RI~094JSY;lv#;6`6bq2pV_;~QT?_Fdp5$O41Xl7E bhZ1E*f%QHf{aSSzAVWP}{an^LB{Ts5LlLf@ literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+1CE00...U+1CEFF-12x24+3.png b/crates/ghostty-font/testdata/U+1CE00...U+1CEFF-12x24+3.png new file mode 100644 index 0000000000000000000000000000000000000000..e076da7c52f1c09e553d278e5025d464d17985db GIT binary patch literal 1255 zcmeAS@N?(olHy`uVBq!ia0y~yU{qjWU~=GKU|?Y2`0`{n0|U!CPZ!6KiaBp?<`=dY z2(T8&C@2a|XkbxMc&OsSG)ak(bK(Y}ICWMl-KRJFrq4Z7`2PFSxsTH<<$k(nNGT~T zs%G5q`*|u)@T9lLy=Sq_-+bmk#J|1+Z;ILEb44EfzRRkzjZeL+p+~WY$)){M+##Mf z%uLS;{?5F$N6h`(iTj=!XVs7Y)1GkQc#-0x2?5*HrtE&5y<79`^Ns&M1m2o(I5sa? z$W!m{b#In}0(DO7cVz1=0|lM#D#IJO^sxLWAJ$+x`E95bSWBUgu% ze)_Gy!-TD*FZPWHdnW7hN>=eMF1xHh%<^I93wJ-3F+U@6rzo`Ga@@h|b}nG>C9UCa z_Kx}IkL>4@uX%n=VnP$6=D(tkJTIF3^ST)|!F*)mkjMpg1_p-z|KHp&GCcbbEv0w|WCxFL`otp6}0Cj$dR z!-D_oZwqQ@KqaA+fiT35%NN;>IEXM``2YXaZvNjEw+z~rmGYh5rKJ>Q4_4VAK69q$ z_y7M_tM8v19KJ$#`MQ8;zu1M_=InCHwJdGDRrEmcb@tA3%c%2h7DoB43(!>$Uonk2{KucvgawU_Y-~~z83_tMUR;S!eytk5qU@yTs@S5|tm!ce zZ-Kas3=9ln6`w>tKdDT~hUuQH9di1l=&GkrxULpG=-PZDmscY;-S(Btgyo;K%Jo5r{ zPx7u(-RZcMg@J*gVym^kVfj3FzqcoOSABiLcD1UgIm>pZ&j$lGAR!+P=W_GSDX&Y1;EzKeahUMgpje5Q~8-%PCwCss#;ljNau zSDv1Yjyw1}{l^cDXID3Z1;Od`O9d!FKyZV&IKbe`fu&uvkcNq5LqZ$zzB{U uL>iW!)P$KM zg$#doeqfk0PxsUehMF<~fu32Rg}>*Q{1sk*Rp(+F+b2GzG~L~gqM7q|pIfG_YrVyC zQS^+plK0Bllb=668Z{;8;*QulwHt1-+E+aP70Unrdr_7D@4nE?-@X4lKjxloE8m>u zw)5ire;@t7o}bt0tl0L|(bQgAyy1b|YW{CY$-2*G)QRdk{)+ zd9^V6kC&it*7E30SFY6i1*yk~99YP?q->XRt<8k>FUx!UJw4xUce6H~eZg&e$|`St z!$994vpuUWgvWZtohcRC^=adO)vtx?=E@d5cztQVXx^4La|6CV$@Alj4}T=_aP#eS zPRAhadsAmjYk%L*YuM|3>tp`vX)Ve!Om_EI~4 zr-#=wFfjc8|K|2VF4q7Fw~OEFv$|gJXehqj6n6JwYLfr{|5M{D%2Idic)=&`TOWS) z<@%3*)U4)}*KNw4H{arclnDP0HT4OL?{TVt(gy@tDxnjuG;vnL3@b~;GbAdB#qV4@Q zs}39qoV6@a9Aq5>1B1$6zJ58&{5utq=XTGDSs3NFEpZn73WxcUbpBq$5sA9QUyt^D6U?oQK$&-eln7RJmnDSzL+vz^Hm zQ$OX~ydJ{|jc&}mDGMUmtm+rYLn8UgM$Q8UBF+c@Za>GS=`=yL`1-03?d)P63+^;C zaZXX4;Bb+BJvYmA@4t@I`s=^{6xLzl)n`?YV3CUB^4q{+vPWc2f?!64rp3WeQs5$$ zfq~)QCy3FrHm+)M5McTLf4%;0m$D0`ndx0w6(?Ue&suiDHGRIb)WtpZ|;|W z@}{t$IYZ`-f)u-X$FYOFi?DH%eHO2oe|NJl*N!^Pxw%P!6Pgw|uxe^ZvubK^g-mGL z1Qj?T@!4*r`_;71qTl~=nsG_pF|j!4=GZ)&P5p*OM#1F=EYhsJc~Wx{g1NLlL4y9~ zMotC=9@dM0w~NUf;K*E8!LPGc@9uXpwVbx9`^(aadl5Wm^Tkb^-N(ISz%8 za$%0cW{7#0HgYl;2pm4}cRSw>zyDhA4hWxEF0A3Fa7++dSilTeV9_Yy-hon%fTSQP z*iH@-iZ=~cF&m0FU;MkiD#$l+LwtvzN=ogrH4*3W}h&UrJ(duh?8V!f%Sun7vK*{^@wfBN*l>yO>n z>AS-0eJ9Uc3vvBLM^I*MG5EhuJ?UhtyEY?Fm4U{&1s2VZc)EmHpg|0x*!aNN08zY2 zPile$1W1;Ff#HJy#6*M&i~`UfQUW8&HjMIbg9y|Rc=6Z!kQudT`@jKl2wa8lLv{ix z-ag<(Rj~nD5I6ELFfbfYsDI5Zvv3=-Zy6r2f^2zrU2ueJ3JHa8>%6p1Ku2E2;Q831G?W$u7gH8s{y!5}iuxHBUxt2LI z)EIPg+h1Guw3M7q@OJFc3Mtv%-JNSx-FG_f&2N8gyKc2>bG~_)o$W2EZtI`Ee*5$n zrWaM(RbR+;O*-N{d1l0PKfd23XB6xI+|~8FzRgPQ_Tvd>LpdcqjXZ<&_NA?BW&3=6 zmxS5gCGE$Hrd_q|+qu=Kqds))?qyzYQ;Sk3q{c=)T^u&2=f9nE^V)?E?-d=cZGXqR zA$W)Jow&RmcfS=pS7c!LwSiu zpXF!Un?zYYonkCF#tQb)Ro|||20RTHf6tG)dy8}7%l=lS{p_0!n8WP!wd$9%N`JQW z`+x4|DtV4o>tZjM-?D95@QS69#qlRg>6LeaETPx+I2i1Vb+;c*jA#+e@87N$>wn?t z$7@SpmvTheTkrG>ILLYO@hP8*b34lRJ2w5xIKLp#l;6Ah*8TR-y3$M|$EHbBe+qzu zts!3Hd3)H97p&yp+OrS%r9o;Ah|OFSy7;=c Q5+g{hr>mdKI;Vst01+8VB>(^b literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+1FB00...U+1FBFF-11x21+2.png b/crates/ghostty-font/testdata/U+1FB00...U+1FBFF-11x21+2.png new file mode 100644 index 0000000000000000000000000000000000000000..023396feb4c6f9c643c7874fcaa14f51f5fe8420 GIT binary patch literal 5427 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 literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+1FB00...U+1FBFF-18x36+4.png b/crates/ghostty-font/testdata/U+1FB00...U+1FBFF-18x36+4.png new file mode 100644 index 0000000000000000000000000000000000000000..73ead485dacbb6d9ea2e3fd5d527d069730ecfe2 GIT binary patch literal 9975 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 Yf7lO^TR#m``atQ&)78&qol`;+0FBlpV*mgE literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+2500...U+25FF-12x24+3.png b/crates/ghostty-font/testdata/U+2500...U+25FF-12x24+3.png new file mode 100644 index 0000000000000000000000000000000000000000..89011df49d34f3da3b765d0df7097c5914331991 GIT binary patch literal 2635 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 Rjwn!S^mO%eS?83{1OVf4besSH literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+2500...U+25FF-18x36+4.png b/crates/ghostty-font/testdata/U+2500...U+25FF-18x36+4.png new file mode 100644 index 0000000000000000000000000000000000000000..05ff2a5f2571c2fa689cc839e6e4b2957dc31eef GIT binary patch literal 4570 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 literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+2800...U+28FF-12x24+3.png b/crates/ghostty-font/testdata/U+2800...U+28FF-12x24+3.png new file mode 100644 index 0000000000000000000000000000000000000000..aa3e2f99bd14432f15e1e31a46c6a27290654b61 GIT binary patch literal 1541 zcmeAS@N?(olHy`uVBq!ia0y~yU{qjWU~=GKU|?Y2`0`{n0|VCgSD1k?d&le@FRB7o&IEipIr9@U)3x8f=luR;yK}*#Fl+x+QIARrdKy?W!FBnSZ(KmTPorqvq8Q;lYaON z*S()J*S@Hqr&+J?VBg{CJ3r5JzdM zbDuC>^#i&0-1bjuMsdZ(pAF+Si~Kq(#k^~UCO=2Ty>Ih=f3Vx>uoa9Cta%&y?Nn{u ztNoApD~lSlDvKK3KGiTll0+caVFdx#i+|tmvd+*w`NH+w&l;Xj5-0kfoos55WS;*& z)N@^c?n%*AsvzsWNADDtod5Il)8v}8m0!+Da{n?6juY5$GkW{~?eivUhghqJuPEay zh3NU8>vFclLIM|4tPFqBJ7U(`>{m{z7iz4yHko_XSBA}O zd5xiyV*+v~aj)_P*(GNE(>SH(`SI!fALlN9G27bi!t}YH*qQEq|1r1vr{&HCx4`(o zn&bI5)b~H%{;tpd%af)oP&(x|$bKLeVS1AFDkvGUFf`1zgG9_tL$1RH0xlPS-?u9D zIFP;JNq+_ZrH({N7Qc<3m>bTjou2S?=4^fW)5eQ0SsUxUPzv6skg$3AwSUXcofKVl z7K9n^g;%qz|9Vn%6&N%A`+c{;;^!~zRidlrKH-9dU{uJoe$;sWl>FbmS_u#EWpUsjdVAB6tHptb<-q^{Rq`9yg*~>&u0G(+;cR?C?g}#l z!=CV`$tQmKeLjEum!IaBxz>6wlGJ}THB_EW3;R`b#j>#R(z#CVDP?>cR^Pn$-*f5t z;P-v(pN_D4ofa0?(BgTe~DWM4f D`^@NH literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+2800...U+28FF-18x36+4.png b/crates/ghostty-font/testdata/U+2800...U+28FF-18x36+4.png new file mode 100644 index 0000000000000000000000000000000000000000..49f431a43466a4f62637845b74b3a69f496ca6fc GIT binary patch literal 2501 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 literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+E000...U+E0FF-12x24+3.png b/crates/ghostty-font/testdata/U+E000...U+E0FF-12x24+3.png new file mode 100644 index 0000000000000000000000000000000000000000..70ae810217ded522a7157b5be800e6eab98b584c GIT binary patch literal 1255 zcmeAS@N?(olHy`uVBq!ia0y~yU{qjWU~=GKU|?Y2`0`{n0|U!CPZ!6KiaBo%7&0<2 zFdSO&)jyZhUjw873IC8}U|{(F|IJ}TUIqq+h6VrDo_)YC&4i?afq|ib1x-yYFC)4d z14f7%MozFf|JO@TYCh3=fS4>7D-!wRtq?&RKw%;?Tp!vt0n z`L=hB0nZ-3Z7g1%oY76*3tF^Wg)|yLXd8?D!MB3-M~>Wi=K1sCL_aG@{c}8%9p?Pt zW?^b{P!Qk%QyZ9F`?vwxyY!`m=c|6MbKcP+T`!);H1$GrrnKX(5Pmddf0dAi?| zI9BPZJt@WU$XU5Qum86M`VEtgB|X(qIk{%(l^GwL6dkmGt}qI8EO~OkU@o(ktB+ag zi7%6tALeDvYjHRl71hh#%5c%^ujc2fOV+1P^gZWpb+Adgtf|<^K26h#=ZH?qHLVqi zS}cD~Za#9~=?SMwgv*oXVlfMpHyv277hYo`rWla+!a!j2gh=kcT#YlNbl$ExA~V@c zu5yt`Mt@GrVO7OW6&YWSDjpZzlxt4gofMX_`CDo4y|L7xlW9-)SLdi0d3wK}*!${p=wg?NN2VW|S=&DJsHLr#yAS zO6B^(r81HpL45_bujW`-8y=fH_q4+ir?j=7io=;s91Z34*wNp0%-Tq+(M`3zSwz{L zdCHvg4mX|Bbd%FKI2{Bo2Tv%NFS@rpC5-9pQO);TE-0}i-Kk+aC1#ZUR54zIr70q@ zQ|V54o$B0)p`2T^xa^{4N^u%w`7zHGGs>Q5IGd|cA*|};!V?dtubGpy>9SMzilZ|v zc}t>Bthu_>fwyC;(5~~E*AlWSO4s>b)f0ay*1h_+!_=euPG7I-zP|ak!_@Pwa`qy& zVyo&NZ2k4%P2uW?8v{zaFJzs*^f2ve^1@$go3Ae0ez>gtxYJiN*VWrR>v@g)TV;CJ zh4pU#7qw1~+cIkD+NkY!P2U=SJwNO1+FAd9-C%zw_j}LmC%t{O9N+uMGfv+ zTkM(oS29ob$6wXw?_VMk87Qsk{b2^D*gI#}HXHCTJlO8&R-tU<Y@?U!OMb@R z*Sx-d_q)+a} z@bzYXRgtpKC9s>xHTm%E1qC0Od^cZyzpPg$HAT1X#``at%Gn%i2k2vIQweYNZjfR?xsSw}lPNV(Sd5{cxb0a5%0|(2&f4l#^F|`n2 z*(|xC!Tb7K&a~$JF-2#yc4f$KeS6Oi>_QlEry8vF?O}NSM=B!bu!LR7@KN%*Bmq+H M>FVdQ&MBb@08>`&B>(^b literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+E000...U+E0FF-18x36+4.png b/crates/ghostty-font/testdata/U+E000...U+E0FF-18x36+4.png new file mode 100644 index 0000000000000000000000000000000000000000..32a42097d21a3d0a3d27d68360edb8f3a126a9a1 GIT binary patch literal 2217 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 literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+F500...U+F5FF-18x36+4.png b/crates/ghostty-font/testdata/U+F500...U+F5FF-18x36+4.png new file mode 100644 index 0000000000000000000000000000000000000000..e7415b7aef7801266881cf7c865815fcc67c41c3 GIT binary patch literal 2473 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 literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+F500...U+F5FF-9x17+1.png b/crates/ghostty-font/testdata/U+F500...U+F5FF-9x17+1.png new file mode 100644 index 0000000000000000000000000000000000000000..730a4c42c2bf293d6cdc02ba42eac97f8dd0b63c GIT binary patch literal 872 zcmeAS@N?(olHy`uVBq!ia0y~yV7S1*z&L?}fq{YH*Mp4(3=GWro-U3d6?5JkFl1z4 zU^ukktA8%1zXnJF61I?LU|{(F|IJ}TUIqq+h6VrDo_)YC&4i?afq`KID?|+=Cq&Kq zcO6Y=N-jW+fhzgG{(@VP#u3mV8Hz=~? zrN-rldTUpkT-oWUa%<|PpamVw6S~Tjdk+4V?l)H9Tl4V$5)Gc+#;@<6KKG9AKZjzA zfD@QF@x5Se+2q=~iy0E3&kma>&Go;~bg%Yt|2g#x$NYUC!=DRpSrC5z$MVmdQ5U-N z|6KnW5nN{`#%8)t@mGhGfz%?AFK?vFt>)`!+N(?Q%=*#xLYeDOMn}+v$@zC)*PJoZ zytmzR+P^t2?=A$@GJOzaaWvWfe8v3ev+0*V7F;;J_FjU;ymQZ1*S=mX|C@2X_2x=} zbmNLwZDQN=EsYjkOe+4Mxw~OwWRH^MQ=Ue)0QK|DMFBJPg6+!_2! zS0Eo3x7Osa@JpYhnrs*(ozA76a#Va8sri5Aw9PUpStlOe`s?_-cv~&Ux1bxT)><5X z)&+d8Rw!8XAM)I~xaq+$hVsq}EP_rPiit(*q>kU*cK&qsf(2=M>mPQp9$i^#rt5d& z;}I+CK(QO%A7*ijzvT^KKI*+$#$itrx826G+M#CmBxl|fo8lMR(#oOHsg&vt7ggMLpZw_6?w^cOX&(D6{KP{d13p>o$eD9x zEzgBLZBt%)bE}xS9_w1wdDippDTerzon_^l7``p!Yi{+H=&kHo*a$~GxV6d*vnO|_+nFo|) NJzf1=);T3K0RSH(aa#ZY literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+F600...U+F6FF-11x21+2.png b/crates/ghostty-font/testdata/U+F600...U+F6FF-11x21+2.png new file mode 100644 index 0000000000000000000000000000000000000000..b216174e42ec037c12671a7cc87a0452fff178fd GIT binary patch literal 495 zcmeAS@N?(olHy`uVBq!ia0y~yVEDkm!1#fKfq{Wxujk%h3=E9hJY5_^D(1Yoc5!X9 z0Z;40oC6s>4+JGT10@s}r?AA|HS@hJE^w%g<=^|aCk`cWb~rOM{96$x{CD1|ji$`A zZN->pm)6}nuxW)}(4!yn$F)~!N|wf%zdl_ve}|vRWSPq*Ji%HU_rGfUvD4z2#(TMq zw_maS<+^%2J}Gw1s^zh}_n$hj|5lBzZ2U{JdYeA`wPEKbUG7_Ym%HI+)r0NRc4QTQ zEID!@?c<4BS;GyHHbwF7{n8R3$HQ@fdOZUJ!~g$p4jb|^FfcSM__ucLLq=&PxB@UE zh80as?n71#HB4w~bRTk}tMP%VY2;yGU^t*q|5{sS!8R7K6>wq)R1MrcwLKW_(Sf>| skrV9Z|LdhEHK93Kni1rjx7fY)hgFQNf_Lq41|N{Mp00i_>zopr0Ladxe*gdg literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+F600...U+F6FF-12x24+3.png b/crates/ghostty-font/testdata/U+F600...U+F6FF-12x24+3.png new file mode 100644 index 0000000000000000000000000000000000000000..2243248b07668a08bcd7ba0e5d146238fb1b7b57 GIT binary patch literal 637 zcmeAS@N?(olHy`uVBq!ia0y~yU{qjWU~=GKU|?Y2`0`{n0|S$br;B4q#hf?SALh0h z@U%S4ImFR(fTQO?hNR}=3YKtDfh$2*r)BgyzW@9=OXy`tDTY_gozV zrw0ZEJaTko<1%8oXBrZ^)_IXj$Uary+xzc4HSDaLv{L=_G@bSHKT2F%9+H}0cIEDm zPmih7#Dt9$?u1*KbbM%(&SvXe?Mew=Puqqr#Sc6)8oy*;{A-D zniRjcoSqi*gWo8nT7OA3d+P5^OMZ7Pz3sc{t>eA3rg^)2?`&(f{rEjfgUcvjLSqw; zNXh~QW>!X!AHn#*TRR2@hX4QH95&=-U|?ui@NaGILsn@fuq2#V!-S?r_aP@iHD@32 zqpOi&gqXv~3AXG1dg)0`XjT=YEBP(R1&SlMTNoJju%elATTnv-NuGg$;e!~uuhu?f zM0ZU-)GD~odml2Rt67h34i=w7)VxK6;9nnvT?d%tD?A^)t!3^9xzN+q&t;ucLK6V+ C{LU)? literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+F600...U+F6FF-18x36+4.png b/crates/ghostty-font/testdata/U+F600...U+F6FF-18x36+4.png new file mode 100644 index 0000000000000000000000000000000000000000..f3a887d20c1f9b7646b2e588ac2110d5089c3005 GIT binary patch literal 1210 zcmeAS@N?(olHy`uVBq!ia0y~yU|hh!z?{Irz`(%Zuk~1hfq|vb)5S5QV$PeH4|5MI zh_vjT=$^phc1ok?5J%4>!MM<^y$shU?zpx`c;EZ*)%xjid?!86Y~xgH5pd#AY!O&8 zW9Hfvff?z_E3WKkd3KXS>#KfarCF2LU+;u@y$gc=3*nLKNaud}xkz(Oa#y6JX>l6! zjm@=_%V%cu9{RUM{Cw;)ujYXGwR`8Csh94^;oWI$nHPO#zhr8gF~>nGr)0VKdiy@N znGbnfUWt2ct$bVeuGi?XWXC1_l&zii|Bu{~QCe!|yX;Kd^9dU-pZRGeo~m$9|H?kU znfp@}X57D!CCPF3(TV)RGxDoerz*_YKW*K#g9-lfl$-uzS zu;Bmt--2AA*aAsH@fudJ9S9}21vNCFa!~4n7}OX;Ua&F$)~E4K)YuE$K;3sLnYMi>?II=fzML!%e~T`59>7z&(ZO z@dMFNo8W4CA2Opk_<#<&8cdI4s=@U50TFa__P%96w+rfIxUVq6W`i))6u1&hkC#Kk z2JR_Lj~9qTje)Dd^mqX`TB7iM$d2Y-3?;k|xzUttfO@Hshk=3NfI|Ii<(LKASfJ?+ kLM5<(EP8tw?iPe8!D|`1jvMu^Rs*T@boFyt=akR{05R5p7ytkO literal 0 HcmV?d00001 diff --git a/crates/ghostty-font/testdata/U+F600...U+F6FF-9x17+1.png b/crates/ghostty-font/testdata/U+F600...U+F6FF-9x17+1.png new file mode 100644 index 0000000000000000000000000000000000000000..82ce82468166189d95acac312068a368f06f667b GIT binary patch literal 393 zcmeAS@N?(olHy`uVBq!ia0y~yV7S1*z&L?}fq{YH*Mp4(3=E6`o-U3d6?5KPJKK3w zL4+;9dP7UNla~+Yv;@y-lZ0eG9Y1r*b%#$&`8lgXv!*mK{GIRghO24$<$xbt zP0MF*Ul Rust, all under `crates/ghostty-font/src/`) +- `font/Metrics.zig` -> `metrics.rs` (`Metrics`, `FaceMetrics`, `Modifier`, `ModifierSet = HashMap`, `Key`, `Modifier::parse/parse_cli/format_entry/apply_{u32,i32,f64}`). +- `font/Atlas.zig` -> `atlas.rs`; `font/Glyph.zig` -> `glyph.rs`. +- `font/sprite.zig` -> `sprite/mod.rs` (`Sprite` enum, re-exports). +- `font/sprite/Face.zig` -> `sprite/face.rs`; `sprite/canvas.zig` -> `sprite/canvas.rs`. +- `sprite/draw/{common,special,block,box,braille,branch,geometric_shapes,powerline,symbols_for_legacy_computing,symbols_for_legacy_computing_supplement}.zig` -> `sprite/draw/*.rs` (`box.zig` -> `box_.rs` since `box` is a keyword-ish name). +- `sprite/draw/octants.txt` is copied next to the supplement module (loaded with `include_str!`, parsed lazily). + +## Design notes / deviations +- z2d replaced by `tiny-skia` (coverage rasterizer only). `Canvas` owns an alpha8 `Vec`; fills/strokes are rasterized into a scratch pixmap and composited `src_over` with the colour alpha, as z2d does for an opaque alpha8 pattern. `Canvas::inner_stroke_path` reproduces the Zig fill-mask x double-width-stroke trick. Paths are built with the local `Path` type (move_to/line_to/curve_to/close/arc); the padding translation is applied at raster time (like z2d's `staticPath` transform). z2d `Context` is not needed (only used for arc/fill/stroke) and was folded into `Path::arc` + `fill_path`/`stroke_path`. +- Stroke miter limit 10 (z2d default) and miter joins. +- Comptime `draw_` function discovery is replaced by explicit `pub const RANGES: &[Range]` tables in each draw module; `face::ranges()` sorts them and asserts non-overlap (equivalent of the Zig `@compileError`; covered by `ranges_sorted_and_non_overlapping`). +- Zig `assert` (debug only) -> `debug_assert!` in braille (Zig asserts only in safe builds). NB: some odd cell sizes (e.g. 14x23) trip that assert in debug builds exactly as in Zig. The workspace `release` profile has debug-assertions on, so they apply there too; `production` has them off. +- `Canvas::pixel` ignores out-of-bounds writes (z2d `putPixel` does as well). `Box` in `sprite::canvas` shadows `std::boxed::Box` where imported. +- `Color` is `struct Color(pub u8)` (Zig non-exhaustive enum); `Shade` likewise. +- `Modifier::format_entry()` returns just the value text (e.g. `"24%"`); the `a = ...\n` config-entry formatting lives in ghostty-config. +- `ModifierSet` iteration order in `Metrics::apply` is the `Key` declaration order (Zig: hash-map order, unspecified). +- Cargo.toml: removed deps on `ghostty-config`, `ghostty-unicode`, `ghostty-terminal` (not used yet); added `tiny-skia`, `png`, `thiserror`, `log`. +- Atlas: `Atlas::new` is infallible (no allocator); `grow` is infallible; `modified`/`resized` are `AtomicUsize`. + +## TODO(integration) +- `sprite::face::Presentation` and `sprite::face::RenderOptions` (only `cell_width`) are local stand-ins for `font.Presentation` / `font.face.RenderOptions`; replace when `face.rs` / `main.rs` of the font engine are ported. +- `ghostty-config` should use `Modifier::parse_cli` for the `adjust-*` options (and `Modifier::format_entry`). + +## Tests +- Ported: Metrics 11/11 (the 2 formatConfig tests check the value text), Atlas 8/13, Sprite `start` 1/1, Face "render all sprites" 1/1. +- Skipped (Zig-only): Atlas `grow OOM`, `init error`, `reserve error`, `grow error` (allocator failure injection / tripwire), Atlas.Wasm `happy path` (wasm). `refAllDecls` tests. +- Added: canvas primitives, common helpers, braille layout, box/legacy smoke tests, `has_codepoint`, `ranges_sorted_and_non_overlapping`, `render_every_sprite_at_several_sizes` (every codepoint x 5 cell sizes x cell_width None/1/2 through `render_glyph`, checks trimmed bounds/ink), cursor-bar offsets. +- Reference comparison: `sprite_face_render_all_sprites` renders all 256-codepoint sheets at 18x36+4, 12x24+3, 11x21+2, 9x17+1 and compares with the upstream z2d reference PNGs (copied to `crates/ghostty-font/testdata/`) with a tolerance: <= 4% of ink pixels may differ by more than 96/255. Measured: braille and U+1CD00 sheets are pixel-identical; other sheets differ only by antialiasing (typical max delta <= 64; a handful of thin diagonal-hatch pixels in U+1FB98/99 and branch/curve glyphs at 9x17 differ by up to ~200). +- Review output (written by the tests): `target/sprite_sheets/` — `test-*.png` (our sheets), `diff-*.png` (red = reference, green = ours), `review-all-9x17.png`, `review-all-18x36.png` (every sprite on one sheet). + +## Known gaps +- Pixel identity with z2d is not attempted (see above). +- Sprite `Face` is not yet wired into `Collection`/`SharedGrid` (those modules come later).