From f68b11d46903f4984cafde1b58f26f3477104a17 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 14:20:49 +0200 Subject: [PATCH] fix: pixel-identical rendering (z2d stroker port) and last P3 items Resolves the 3 P3 items in docs/rewrite/notes/review-rv12-codex.md (final-Z15.md): - Sprite strokes: tiny-skia is replaced by a port of z2d 0.10's stroke plotter (sprite/stroke.rs) plus z2d's arc segmentation and truncating integer compositing, feeding the ported 4x4 multisample rasterizer. All 36 reference sprite sheets now match Ghostty's byte for byte (test tolerance 0), and `just visual-parity` with a production build shows 0 differing pixels (max delta 0) against the installed Ghostty 1.3.1 for the test page (block and bar cursor) and the tabs+splits scenario, terminal area and chrome alike. tiny-skia dependency removed. - zig_error maps errno values per target via libc (Linux, Darwin/BSD) and Win32 codes the way Zig std does on Windows. - theme=. / theme=.. report "it is a directory" via a port of std.fs.path.basename, matching Ghostty. Test Plan: - cargo test --workspace --no-fail-fast (3498 passed, exit 0) - cargo build --profile production && just visual-parity - just check-macos (both), just check-windows, just check-wasm --- Cargo.lock | 39 - crates/ghostty-config/src/theme.rs | 65 +- crates/ghostty-font/Cargo.toml | 1 - crates/ghostty-font/src/sprite/canvas.rs | 254 ++++--- crates/ghostty-font/src/sprite/face.rs | 13 +- crates/ghostty-font/src/sprite/mod.rs | 1 + crates/ghostty-font/src/sprite/stroke.rs | 874 +++++++++++++++++++++++ crates/ghostty-os/src/zig_error.rs | 232 +++++- docs/rewrite/notes/final-Z15.md | 29 + docs/rewrite/notes/visual-parity-VP.md | 22 +- 10 files changed, 1339 insertions(+), 191 deletions(-) create mode 100644 crates/ghostty-font/src/sprite/stroke.rs create mode 100644 docs/rewrite/notes/final-Z15.md diff --git a/Cargo.lock b/Cargo.lock index f4ab1ff..d377506 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,12 +88,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "arrayvec" version = "0.7.8" @@ -1549,7 +1543,6 @@ dependencies = [ "objc2-foundation", "png", "thiserror 2.0.21", - "tiny-skia", "yeslogic-fontconfig-sys", ] @@ -3861,12 +3854,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "strict-num" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" - [[package]] name = "strsim" version = "0.11.1" @@ -4148,32 +4135,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tiny-skia" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" -dependencies = [ - "arrayref", - "arrayvec", - "bytemuck", - "cfg-if", - "log", - "png", - "tiny-skia-path", -] - -[[package]] -name = "tiny-skia-path" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" -dependencies = [ - "arrayref", - "bytemuck", - "strict-num", -] - [[package]] name = "toml" version = "1.1.8+spec-1.1.0" diff --git a/crates/ghostty-config/src/theme.rs b/crates/ghostty-config/src/theme.rs index caf5bcd..714134d 100644 --- a/crates/ghostty-config/src/theme.rs +++ b/crates/ghostty-config/src/theme.rs @@ -151,6 +151,38 @@ fn check_regular(file: &File, theme: &str, diags: &mut DiagnosticList) -> bool { } } +/// `std.fs.path.basename` (last `ComponentIterator` component; `.` and `..` +/// are ordinary names). The caller only passes non-absolute paths, so the root +/// is empty on POSIX and an optional `X:` drive designator on Windows. +fn zig_basename(path: &str) -> &str { + let is_sep = |b: u8| b == b'/' || (cfg!(windows) && b == b'\\'); + let bytes = path.as_bytes(); + let root_end = if cfg!(windows) + && bytes.len() >= 2 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + { + 2 + } else { + 0 + }; + let mut end = bytes.len(); + loop { + if end == root_end { + return ""; + } + if !is_sep(bytes[end - 1]) { + break; + } + end -= 1; + } + let mut start = end; + while start != root_end && !is_sep(bytes[start - 1]) { + start -= 1; + } + &path[start..end] +} + /// Open the given named theme. If there are any errors then messages /// will be appended to the given error list and None is returned. If /// a Some return value is returned, there are never any errors added. @@ -184,11 +216,7 @@ pub fn open( }); } - let basename = theme_path - .file_name() - .map(EncodedOs::from_os) - .unwrap_or_default(); - if *theme != basename { + if theme_text != zig_basename(theme_text) { diag( diags, format!( @@ -335,6 +363,33 @@ mod tests { ); } + #[test] + fn zig_basename_matches_std() { + assert_eq!(zig_basename(""), ""); + assert_eq!(zig_basename("."), "."); + assert_eq!(zig_basename(".."), ".."); + assert_eq!(zig_basename("a/"), "a"); + assert_eq!(zig_basename("a/b"), "b"); + assert_eq!(zig_basename("a//"), "a"); + assert_eq!(zig_basename("./"), "."); + } + + #[test] + fn dot_theme_names_are_directories() { + let dir = std::env::temp_dir().join(format!("ghostty-theme-dot-{}", std::process::id())); + std::fs::create_dir_all(dir.join("themes")).unwrap(); + for name in [".", ".."] { + let mut d = DiagnosticList::default(); + assert!(open(&e(name), Some(&EncodedOs::from_path(&dir)), &mut d).is_none()); + assert!( + d.items()[0].message.ends_with("it is a directory"), + "{}", + d.items()[0].message + ); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn theme_found_in_resources() { let dir = std::env::temp_dir().join(format!("ghostty-theme-test-{}", std::process::id())); diff --git a/crates/ghostty-font/Cargo.toml b/crates/ghostty-font/Cargo.toml index abe73a5..9dd11e1 100644 --- a/crates/ghostty-font/Cargo.toml +++ b/crates/ghostty-font/Cargo.toml @@ -6,7 +6,6 @@ license.workspace = true [dependencies] ghostty-os.workspace = true -tiny-skia = "0.12.0" png = "0.18.1" thiserror = "2.0.21" log = "0.4.34" diff --git a/crates/ghostty-font/src/sprite/canvas.rs b/crates/ghostty-font/src/sprite/canvas.rs index 82a1e68..e063135 100644 --- a/crates/ghostty-font/src/sprite/canvas.rs +++ b/crates/ghostty-font/src/sprite/canvas.rs @@ -4,12 +4,17 @@ //! Ghostty uses the `z2d` library for antialiased path drawing. This port //! reproduces z2d's default `multisample_4x` rasterizer (4x4 samples per //! pixel, scanline spans, alpha `coverage * 16 - 1`) in [`Canvas::rasterize`]; -//! stroke outlines come from `tiny-skia`'s stroker. +//! stroke outlines come from [`super::stroke`], a port of z2d's stroke plotter. + +#![allow( + clippy::float_cmp, + clippy::excessive_precision, + clippy::neg_cmp_op_on_partial_ord +)] use std::ops::Sub; -use tiny_skia::{LineCap, LineJoin, PathBuilder, PathSegment, Stroke}; - +use super::stroke; use crate::atlas::{self, Atlas, Format, Region}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -148,64 +153,129 @@ impl Path { return; } self.nodes.push(PathNode::ClosePath); + // z2d adds an explicit `move_to` to the initial point after every + // `close_path` node. + if let Some((x, y)) = self.start { + self.nodes.push(PathNode::MoveTo(x, y)); + } 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. + /// (radians, increasing angles, clockwise in canvas space with +y down), + /// like z2d's `Path.arc` (`internal/arc.zig`, Cairo's arc algorithm). 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; + let mut a1 = a1; + while a1 < a0 { + a1 += std::f64::consts::TAU; } + self.arc_in_direction(x, y, radius, a0, a1, 0.1); } - 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(), + /// z2d `arc_line_to`: a no-op when already at the destination. + fn arc_line_to(&mut self, x: f64, y: f64) { + if self.current == Some((x, y)) { + return; + } + self.line_to(x, y); + } + + fn arc_in_direction(&mut self, xc: f64, yc: f64, radius: f64, amin: f64, amax: f64, tol: f64) { + use std::f64::consts::PI; + let (mut amin, amax) = (amin, amax); + if !(amax * amax >= 0.0) || !(amin * amin >= 0.0) { + return; + } + // (The `max_full_circles` guard of z2d is irrelevant for sprites.) + if amax - amin > PI { + let amid = amin + (amax - amin) / 2.0; + self.arc_in_direction(xc, yc, radius, amin, amid, tol); + self.arc_in_direction(xc, yc, radius, amid, amax, tol); + } else if amax != amin { + // Identity CTM: the transformed circle's major axis is `radius`. + let max_angle = arc_max_angle_for_tolerance_normalized(tol / radius); + let mut segments = ((amax - amin).abs() / max_angle).ceil() as i32; + let step = (amax - amin) / f64::from(segments); + segments -= 1; + self.arc_line_to(xc + radius * amin.cos(), yc + radius * amin.sin()); + for _ in 0..segments.max(0) { + self.arc_segment(xc, yc, radius, amin, amin + step); + amin += step; } + self.arc_segment(xc, yc, radius, amin, amax); + } else { + self.arc_line_to(xc + radius * amin.cos(), yc + radius * amin.sin()); } - pb.finish() } + + fn arc_segment(&mut self, xc: f64, yc: f64, radius: f64, a: f64, b: f64) { + let (r_sin_a, r_cos_a) = (radius * a.sin(), radius * a.cos()); + let (r_sin_b, r_cos_b) = (radius * b.sin(), radius * b.cos()); + let h = 4.0 / 3.0 * ((b - a) / 4.0).tan(); + self.curve_to( + xc + r_cos_a - h * r_sin_a, + yc + r_sin_a + h * r_cos_a, + xc + r_cos_b + h * r_sin_b, + yc + r_sin_b - h * r_cos_b, + xc + r_cos_b, + yc + r_sin_b, + ); + } + + /// The path nodes translated by `(dx, dy)` (z2d applies the canvas + /// transformation when nodes are added to the path). + fn to_nodes(&self, dx: f64, dy: f64) -> Vec { + let t = |x: f64, y: f64| (x + dx, y + dy); + self.nodes + .iter() + .map(|n| match *n { + PathNode::MoveTo(x, y) => stroke::Node::MoveTo(t(x, y)), + PathNode::LineTo(x, y) => stroke::Node::LineTo(t(x, y)), + PathNode::CurveTo(x1, y1, x2, y2, x3, y3) => { + stroke::Node::CurveTo(t(x1, y1), t(x2, y2), t(x3, y3)) + } + PathNode::ClosePath => stroke::Node::ClosePath, + }) + .collect() + } +} + +/// z2d `arc_error_normalized`. +fn arc_error_normalized(angle: f64) -> f64 { + 2.0 / 27.0 * (angle / 4.0).sin().powf(6.0) / (angle / 4.0).cos().powf(2.0) +} + +/// z2d `arc_max_angle_for_tolerance_normalized`. +fn arc_max_angle_for_tolerance_normalized(tolerance: f64) -> f64 { + use std::f64::consts::PI; + const TABLE: [(f64, f64); 11] = [ + (PI / 1.0, 0.0185185185185185036127), + (PI / 2.0, 0.000272567143730179811158), + (PI / 3.0, 2.38647043651461047433e-05), + (PI / 4.0, 4.2455377443222443279e-06), + (PI / 5.0, 1.11281001494389081528e-06), + (PI / 6.0, 3.72662000942734705475e-07), + (PI / 7.0, 1.47783685574284411325e-07), + (PI / 8.0, 6.63240432022601149057e-08), + (PI / 9.0, 3.2715520137536980553e-08), + (PI / 10.0, 1.73863223499021216974e-08), + (PI / 11.0, 9.81410988043554039085e-09), + ]; + for (angle, err) in TABLE { + if err < tolerance { + return angle; + } + } + let mut angle = 0.0; + for i in TABLE.len()..1000 { + angle = PI / i as f64; + if arc_error_normalized(angle) <= tolerance { + break; + } + } + angle } /// Line cap modes (subset of z2d's). @@ -469,30 +539,35 @@ impl Canvas { /// with z2d's default multisample anti-aliasing. fn rasterize(&mut self, path: &Path, stroke: Option) -> bool { self.cov.fill(0); - let Some(p) = path.to_skia(f64::from(self.padding_x), f64::from(self.padding_y)) else { + let nodes = path.to_nodes(f64::from(self.padding_x), f64::from(self.padding_y)); + if nodes.is_empty() { return false; - }; - let p = match stroke { - None => p, + } + let edges = match stroke { + None => flatten_to_edges(&nodes), 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, + // z2d `painter.stroke`: thin lines revert to butt caps, and + // there is a minimum line width. + let params = stroke::StrokeParams { + thickness: opts.line_width.max(0.00390625), + cap_mode: if opts.line_width >= 2.0 { + opts.line_cap_mode + } else { + CapMode::Butt }, - line_join: LineJoin::Miter, - dash: None, + miter_limit: 10.0, + tolerance: 0.1, }; - match p.stroke(&s, 1.0) { - Some(o) => o, - None => return false, + let mut edges = Vec::new(); + for (p0, p1) in stroke::plot(&nodes, params) { + add_edge(&mut edges, p0, p1); } + edges } }; - let edges = flatten_to_edges(&p); + if edges.is_empty() { + return false; + } multisample_fill( &edges, self.sfc_width as usize, @@ -511,9 +586,11 @@ impl Canvas { if cov == 0 { continue; } - let src = (cov * a + 127) / 255; + // z2d integer compositing truncates: `dst_in` of the pattern with + // the coverage, then `src_over`: `s + d - mul(s, d)`. + let src = cov * a / 255; let d = u32::from(*dst); - *dst = (src + (d * (255 - src) + 127) / 255).min(255) as u8; + *dst = (src + d - src * d / 255).min(255) as u8; } } @@ -558,7 +635,7 @@ impl Canvas { if self.rasterize(path, Some(mut_opts)) { let a = u32::from(color.0); for (d, px) in stroke.iter_mut().zip(self.cov.iter()) { - *d = ((u32::from(*px) * a + 127) / 255) as u8; + *d = (u32::from(*px) * a / 255) as u8; } } @@ -566,7 +643,7 @@ impl 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; + *dst = (m + d - m * d / 255).min(255) as u8; } Ok(()) } @@ -728,9 +805,9 @@ fn flatten_cubic( out.push(d); } -/// Flatten a tiny-skia path into scaled polygon edges. Every subpath is +/// Flatten path nodes into scaled polygon edges. Every subpath is /// implicitly closed (fill semantics). -fn flatten_to_edges(p: &tiny_skia::Path) -> Vec { +fn flatten_to_edges(nodes: &[stroke::Node]) -> Vec { let mut edges = Vec::new(); let mut pts: Vec<(f64, f64)> = Vec::new(); let flush = |pts: &mut Vec<(f64, f64)>, edges: &mut Vec| { @@ -742,38 +819,23 @@ fn flatten_to_edges(p: &tiny_skia::Path) -> Vec { } pts.clear(); }; - let f = |p: tiny_skia::Point| (f64::from(p.x), f64::from(p.y)); let mut cur = (0.0, 0.0); - for seg in p.segments() { - match seg { - PathSegment::MoveTo(q) => { + for node in nodes { + match *node { + stroke::Node::MoveTo(q) => { flush(&mut pts, &mut edges); - cur = f(q); + cur = q; pts.push(cur); } - PathSegment::LineTo(q) => { - cur = f(q); + stroke::Node::LineTo(q) => { + cur = q; pts.push(cur); } - PathSegment::QuadTo(c, q) => { - let (c, q) = (f(c), f(q)); - // Elevate to a cubic. - let c1 = ( - cur.0 + 2.0 / 3.0 * (c.0 - cur.0), - cur.1 + 2.0 / 3.0 * (c.1 - cur.1), - ); - let c2 = (q.0 + 2.0 / 3.0 * (c.0 - q.0), q.1 + 2.0 / 3.0 * (c.1 - q.1)); + stroke::Node::CurveTo(c1, c2, q) => { flatten_cubic(cur, c1, c2, q, &mut pts); cur = q; } - PathSegment::CubicTo(c1, c2, q) => { - let q = f(q); - flatten_cubic(cur, f(c1), f(c2), q, &mut pts); - cur = q; - } - PathSegment::Close => { - flush(&mut pts, &mut edges); - } + stroke::Node::ClosePath => flush(&mut pts, &mut edges), } } flush(&mut pts, &mut edges); diff --git a/crates/ghostty-font/src/sprite/face.rs b/crates/ghostty-font/src/sprite/face.rs index 688ef99..b701683 100644 --- a/crates/ghostty-font/src/sprite/face.rs +++ b/crates/ghostty-font/src/sprite/face.rs @@ -254,8 +254,7 @@ mod tests { } /// 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. + /// Kept for the diagnostics only: the sheets are expected to match exactly. const BIG: u8 = 96; fn diff_sheet(test: &[u8], reference: &[u8]) -> Diff { @@ -321,13 +320,11 @@ mod tests { d.ink, d.differing, d.big, d.max ); - // Tolerances: the multisample rasterizer matches z2d's coverage; - // remaining differences come from stroke geometry (tiny-skia's - // stroker) and are small. Practically no pixel may be off by a - // lot, relative to the amount of ink on the sheet. + // The multisample rasterizer, the stroker and the compositing are + // ports of z2d's, so every sheet must match the reference exactly. let big_ratio = d.big as f64 / d.ink.max(1) as f64; - let ok = big_ratio <= 0.001; - if !ok || d.differing > 0 { + let ok = d.differing == 0; + if !ok { // 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) { diff --git a/crates/ghostty-font/src/sprite/mod.rs b/crates/ghostty-font/src/sprite/mod.rs index e411c0f..95a681c 100644 --- a/crates/ghostty-font/src/sprite/mod.rs +++ b/crates/ghostty-font/src/sprite/mod.rs @@ -5,6 +5,7 @@ pub mod canvas; pub mod draw; pub mod face; +pub mod stroke; pub use canvas::{Box, Canvas, Color, Point}; pub use face::Face; diff --git a/crates/ghostty-font/src/sprite/stroke.rs b/crates/ghostty-font/src/sprite/stroke.rs new file mode 100644 index 0000000..cd505b7 --- /dev/null +++ b/crates/ghostty-font/src/sprite/stroke.rs @@ -0,0 +1,874 @@ +//! Port of z2d 0.10's stroke geometry (`internal/tess/stroke_plotter.zig`, +//! `Face.zig`, `Pen.zig`, `Slope.zig`, `Spline.zig`, `point_buffer.zig`, +//! `Polygon.zig` contours) for the identity transformation and no dashes, +//! which is all the sprite canvas uses. +//! +//! The result is a list of polygon edges (unscaled; the multisample +//! rasterizer scales them) to be filled with the non-zero rule. + +#![allow(clippy::float_cmp)] // exact comparisons, like z2d + +use std::collections::VecDeque; + +pub type P = (f64, f64); + +pub use super::canvas::CapMode; + +/// A path node, as in z2d `path_nodes.zig`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Node { + MoveTo(P), + LineTo(P), + CurveTo(P, P, P), + ClosePath, +} + +#[derive(Clone, Copy, PartialEq)] +enum JoinMode { + Miter, + Round, +} + +#[derive(Clone, Copy)] +struct Slope { + dx: f64, + dy: f64, +} + +fn sign(x: f64) -> f64 { + if x > 0.0 { + 1.0 + } else if x < 0.0 { + -1.0 + } else { + 0.0 + } +} + +impl Slope { + fn new(a: P, b: P) -> Slope { + Slope { + dx: b.0 - a.0, + dy: b.1 - a.1, + } + } + + fn normalize(&mut self) -> f64 { + let (rdx, rdy, mag); + if self.dx == 0.0 { + rdx = 0.0; + if self.dy > 0.0 { + mag = self.dy; + rdy = 1.0; + } else { + mag = -self.dy; + rdy = -1.0; + } + } else if self.dy == 0.0 { + rdy = 0.0; + if self.dx > 0.0 { + mag = self.dx; + rdx = 1.0; + } else { + mag = -self.dx; + rdx = -1.0; + } + } else { + mag = self.dx.hypot(self.dy); + rdx = self.dx / mag; + rdy = self.dy / mag; + } + self.dx = rdx; + self.dy = rdy; + mag + } + + fn compare(a: Slope, b: Slope) -> i32 { + let bdy = if (b.dy - a.dy).abs() > f64::EPSILON { + b.dy + } else { + a.dy + }; + let bdx = if (b.dx - a.dx).abs() > f64::EPSILON { + b.dx + } else { + a.dx + }; + let cmp = sign(a.dy * bdx - bdy * a.dx); + if cmp != 0.0 { + return cmp as i32; + } + if a.dx == 0.0 && a.dy == 0.0 && bdx == 0.0 && bdy == 0.0 { + return 0; + } + if a.dx == 0.0 && a.dy == 0.0 { + return 1; + } + if bdx == 0.0 && bdy == 0.0 { + return -1; + } + if sign(a.dx) != sign(bdx) || sign(a.dy) != sign(bdy) { + return if a.dx > 0.0 || (a.dx == 0.0 && a.dy > 0.0) { + -1 + } else { + 1 + }; + } + 0 + } + + fn compare_for_miter_limit(in_slope: Slope, out_slope: Slope, miter_limit: f64) -> bool { + let mut i = in_slope; + i.normalize(); + let mut o = out_slope; + o.normalize(); + let in_dot_out = i.dx * o.dx + i.dy * o.dy; + 2.0 <= miter_limit * miter_limit * (1.0 + in_dot_out) + } +} + +/// z2d `Face` for the identity CTM. +#[derive(Clone, Copy)] +struct Face { + p1: P, + dev_slope: Slope, + half_width: f64, + p0_cw: P, + p0_ccw: P, + p1_cw: P, + p1_ccw: P, +} + +impl Face { + fn new(p0: P, p1: P, thickness: f64) -> Face { + let mut dev_slope = Slope::new(p0, p1); + dev_slope.normalize(); + let half_width = thickness / 2.0; + let ox = -dev_slope.dy * half_width; + let oy = dev_slope.dx * half_width; + Face { + p1, + dev_slope, + half_width, + p0_cw: (p0.0 + ox, p0.1 + oy), + p0_ccw: (p0.0 + -ox, p0.1 + -oy), + p1_cw: (p1.0 + ox, p1.1 + oy), + p1_ccw: (p1.0 + -ox, p1.1 + -oy), + } + } + + fn intersect(&self, out: &Face, clockwise: bool) -> P { + let in_point = if clockwise { self.p1_ccw } else { self.p1_cw }; + let out_point = if clockwise { out.p0_ccw } else { out.p0_cw }; + let mut in_slope = self.dev_slope; + in_slope.normalize(); + let mut out_slope = out.dev_slope; + out_slope.normalize(); + + let result_y = ((out_point.0 - in_point.0) * in_slope.dy * out_slope.dy + - out_point.1 * out_slope.dx * in_slope.dy + + in_point.1 * in_slope.dx * out_slope.dy) + / (in_slope.dx * out_slope.dy - out_slope.dx * in_slope.dy); + let result_x = if in_slope.dy.abs() >= out_slope.dy.abs() { + (result_y - in_point.1) * in_slope.dx / in_slope.dy + in_point.0 + } else { + (result_y - out_point.1) * out_slope.dx / out_slope.dy + out_point.0 + }; + (result_x, result_y) + } + + fn cap(&self, out: &mut dyn FnMut(P), mode: CapMode, clockwise: bool, pen: Option<&Pen>) { + match mode { + CapMode::Butt => { + if clockwise { + out(self.p1_ccw); + out(self.p1_cw); + } else { + out(self.p1_cw); + out(self.p1_ccw); + } + } + CapMode::Square => { + let ox = self.dev_slope.dx * self.half_width; + let oy = self.dev_slope.dy * self.half_width; + if clockwise { + out(self.p1_ccw); + out((self.p1_ccw.0 + ox, self.p1_ccw.1 + oy)); + out((self.p1_cw.0 + ox, self.p1_cw.1 + oy)); + out(self.p1_cw); + } else { + out(self.p1_cw); + out((self.p1_cw.0 + ox, self.p1_cw.1 + oy)); + out((self.p1_ccw.0 + ox, self.p1_ccw.1 + oy)); + out(self.p1_ccw); + } + } + CapMode::Round => { + let pen = pen.expect("round caps need a pen"); + let mut vit = pen.vertex_iterator_for( + self.dev_slope, + Slope { + dx: -self.dev_slope.dx, + dy: -self.dev_slope.dy, + }, + clockwise, + ); + out(if clockwise { self.p1_ccw } else { self.p1_cw }); + while let Some(v) = vit.next() { + out((self.p1.0 + v.point.0, self.p1.1 + v.point.1)); + } + out(if clockwise { self.p1_cw } else { self.p1_ccw }); + } + } + } +} + +struct PenVertex { + point: P, + slope_cw: Slope, + slope_ccw: Slope, +} + +struct Pen { + vertices: Vec, +} + +struct VertexIterator<'a> { + pen: &'a Pen, + end: usize, + idx: usize, + clockwise: bool, +} + +impl<'a> VertexIterator<'a> { + fn next(&mut self) -> Option<&'a PenVertex> { + if self.idx == self.end { + return None; + } + let result = &self.pen.vertices[self.idx]; + if self.clockwise { + self.idx += 1; + if self.idx == self.pen.vertices.len() { + self.idx = 0; + } + } else { + if self.idx == 0 { + self.idx = self.pen.vertices.len(); + } + self.idx -= 1; + } + Some(result) + } +} + +impl Pen { + fn new(thickness: f64, tolerance: f64) -> Pen { + let radius = thickness / 2.0; + // Identity CTM: the transformed major axis is the radius itself. + let major_axis = radius; + let num: i32 = if tolerance >= major_axis * 4.0 { + 1 + } else if tolerance >= major_axis { + 4 + } else { + let delta = (1.0 - tolerance / major_axis).acos(); + if delta == 0.0 { + 4 + } else { + let n = (2.0 * std::f64::consts::PI / delta).ceil() as i32; + if n < 4 { + 4 + } else if n % 2 != 0 { + n + 1 + } else { + n + } + } + }; + let num = num.max(0) as usize; + let mut pts = Vec::with_capacity(num); + for i in 0..num { + let theta = 2.0 * std::f64::consts::PI * i as f64 / num as f64; + pts.push((radius * theta.cos(), radius * theta.sin())); + } + let mut vertices = Vec::with_capacity(num); + for i in 0..num { + let next = if i + 1 >= num { 0 } else { i + 1 }; + let prev = if i == 0 { num - 1 } else { i - 1 }; + vertices.push(PenVertex { + point: pts[i], + slope_cw: Slope::new(pts[prev], pts[i]), + slope_ccw: Slope::new(pts[i], pts[next]), + }); + } + Pen { vertices } + } + + fn vertex_iterator_for(&self, from: Slope, to: Slope, clockwise: bool) -> VertexIterator<'_> { + let n = self.vertices.len() as i32; + let v = |i: i32| &self.vertices[i as usize]; + let (start, end); + if clockwise { + let (mut low, mut high) = (0i32, n); + let mut i = (low + high) >> 1; + while high - low > 1 { + if Slope::compare(v(i).slope_cw, from) < 0 { + low = i; + } else { + high = i; + } + i = (low + high) >> 1; + } + if Slope::compare(v(i).slope_cw, from) < 0 { + i += 1; + if i == n { + i = 0; + } + } + start = i; + if Slope::compare(to, v(i).slope_ccw) >= 0 { + low = i; + high = i + n; + i = (low + high) >> 1; + while high - low > 1 { + let j = if i >= n { i - n } else { i }; + if Slope::compare(v(j).slope_cw, to) > 0 { + high = i; + } else { + low = i; + } + i = (low + high) >> 1; + } + if i >= n { + i -= n; + } + } + end = i; + } else { + let (mut low, mut high) = (0i32, n); + let mut i = (low + high) >> 1; + while high - low > 1 { + if Slope::compare(from, v(i).slope_ccw) < 0 { + low = i; + } else { + high = i; + } + i = (low + high) >> 1; + } + if Slope::compare(from, v(i).slope_ccw) < 0 { + i += 1; + if i == n { + i = 0; + } + } + start = i; + if Slope::compare(v(i).slope_cw, to) <= 0 { + low = i; + high = i + n; + i = (low + high) >> 1; + while high - low > 1 { + let j = if i >= n { i - n } else { i }; + if Slope::compare(to, v(j).slope_ccw) > 0 { + high = i; + } else { + low = i; + } + i = (low + high) >> 1; + } + if i >= n { + i -= n; + } + } + end = i; + } + VertexIterator { + pen: self, + end: end.max(0) as usize, + idx: start.max(0) as usize, + clockwise, + } + } +} + +/// `PointBuffer(2, 5)`. +#[derive(Default)] +struct PointBuffer { + items: [P; 5], + len: usize, +} + +impl PointBuffer { + fn add(&mut self, p: P) { + if self.len < 5 { + self.items[self.len] = p; + self.len += 1; + } else { + for idx in 2..4 { + self.items[idx] = self.items[idx + 1]; + } + self.items[4] = p; + } + } + fn head(&self, n: usize) -> P { + self.items[n] + } + fn tail(&self, n: usize) -> P { + self.items[self.len - n] + } +} + +type Contour = VecDeque

; + +/// Stroke options after z2d's `painter.stroke` clamping. +pub struct StrokeParams { + pub thickness: f64, + pub cap_mode: CapMode, + pub miter_limit: f64, + pub tolerance: f64, +} + +struct Plotter { + p: StrokeParams, + join_mode: JoinMode, + pen: Option, + points: PointBuffer, + clockwise: Option, + outer: Contour, + inner: Contour, + edges: Vec<(P, P)>, +} + +/// Plot the stroke polygon of `nodes`; returns the polygon as a list of +/// directed edges (point pairs). +pub fn plot(nodes: &[Node], p: StrokeParams) -> Vec<(P, P)> { + let pen = if p.cap_mode == CapMode::Round { + Some(Pen::new(p.thickness, p.tolerance)) + } else { + None + }; + let mut pl = Plotter { + p, + join_mode: JoinMode::Miter, + pen, + points: PointBuffer::default(), + clockwise: None, + outer: Contour::new(), + inner: Contour::new(), + edges: Vec::new(), + }; + for n in nodes { + match *n { + Node::MoveTo(pt) => { + if pl.points.len > 0 { + pl.finish(); + } + pl.points.len = 0; + pl.points.add(pt); + } + Node::LineTo(pt) => pl.run_line_to(JoinMode::Miter, pt), + Node::CurveTo(b, c, d) => pl.run_curve_to(b, c, d), + Node::ClosePath => pl.run_close_path(), + } + } + pl.finish(); + pl.edges +} + +fn add_contour_edges(edges: &mut Vec<(P, P)>, c: &Contour) { + let mut last: Option

= None; + for &pt in c { + if let Some(l) = last { + edges.push((l, pt)); + } + last = Some(pt); + } + if let (Some(first), Some(l)) = (c.front(), last) { + edges.push((l, *first)); + } +} + +impl Plotter { + fn run_line_to(&mut self, mode: JoinMode, pt: P) { + if self.points.len == 0 { + return; + } + let cur = self.points.tail(1); + if pt == cur { + return; + } + self.points.add(pt); + if self.points.len > 2 { + self.join( + mode, + self.points.tail(3), + self.points.tail(2), + self.points.tail(1), + ); + } + } + + fn run_curve_to(&mut self, b: P, c: P, d: P) { + if self.points.len == 0 { + return; + } + let a = self.points.tail(1); + if self.pen.is_none() { + self.pen = Some(Pen::new(self.p.thickness, self.p.tolerance)); + } + // z2d `Spline.decompose` with tolerance^2. + if a == b && c == d { + self.run_line_to(JoinMode::Round, d); + return; + } + let tol = self.p.tolerance * self.p.tolerance; + let mut k = [a, b, c, d]; + self.decompose_into(&mut k, a, tol); + self.run_line_to(JoinMode::Round, d); + } + + fn decompose_into(&mut self, k: &mut [P; 4], start: P, tol: f64) { + if knots_error_sq(k) < tol { + if k[0] != start { + self.run_line_to(JoinMode::Round, k[0]); + } + return; + } + let mut s2 = de_casteljau(k); + self.decompose_into(k, start, tol); + self.decompose_into(&mut s2, start, tol); + } + + fn run_close_path(&mut self) { + match self.points.len { + 0 => {} + 1 => self.plot_dotted(self.points.head(0)), + 2 => self.plot_single(self.points.head(0), self.points.head(1)), + _ => self.plot_closed_joined( + self.points.head(0), + self.points.head(1), + self.points.tail(2), + self.points.tail(1), + ), + } + self.points.len = 0; + } + + fn finish(&mut self) { + match self.points.len { + 0 | 1 => {} + 2 => self.plot_single(self.points.head(0), self.points.head(1)), + _ => self.plot_open_joined( + self.points.head(0), + self.points.head(1), + self.points.tail(2), + self.points.tail(1), + ), + } + } + + fn plot_dotted(&mut self, point: P) { + if self.p.cap_mode == CapMode::Round { + let pen = self.pen.as_ref().expect("round caps need a pen"); + for v in &pen.vertices { + self.outer + .push_back((point.0 + v.point.0, point.1 + v.point.1)); + } + add_contour_edges(&mut self.edges, &self.outer); + self.outer.clear(); + self.clockwise = None; + } + } + + fn plot_single(&mut self, start: P, end: P) { + let f = Face::new(start, end, self.p.thickness); + let rev = Face::new(end, start, self.p.thickness); + let mut sink = |pt: P| self.outer.push_back(pt); + rev.cap(&mut sink, self.p.cap_mode, true, self.pen.as_ref()); + f.cap(&mut sink, self.p.cap_mode, true, self.pen.as_ref()); + add_contour_edges(&mut self.edges, &self.outer); + self.outer.clear(); + self.clockwise = None; + } + + fn plot_open_joined(&mut self, start0: P, end0: P, start1: P, end1: P) { + let t = self.p.thickness; + let start_rev = Face::new(end0, start0, t); + let end_face = Face::new(start1, end1, t); + let clockwise = self.clockwise.unwrap_or(true); + + // Start cap: inserted before the first corner of the outer contour. + let mut cursor = 0usize; + let outer = &mut self.outer; + start_rev.cap( + &mut |pt| { + outer.insert(cursor, pt); + cursor += 1; + }, + self.p.cap_mode, + clockwise, + self.pen.as_ref(), + ); + // End cap: appended. + end_face.cap( + &mut |pt| outer.push_back(pt), + self.p.cap_mode, + clockwise, + self.pen.as_ref(), + ); + // Concatenate the inner contour onto the outer. + let inner = std::mem::take(&mut self.inner); + self.outer.extend(inner); + add_contour_edges(&mut self.edges, &self.outer); + self.outer.clear(); + self.clockwise = None; + } + + fn plot_closed_joined(&mut self, initial0: P, initial1: P, p1: P, p2: P) { + let mode = self.join_mode; + if p2 == initial0 { + self.join(mode, p1, initial0, initial1); + } else { + self.join(mode, p1, p2, initial0); + self.join(mode, p2, initial0, initial1); + } + add_contour_edges(&mut self.edges, &self.outer); + add_contour_edges(&mut self.edges, &self.inner); + self.outer.clear(); + self.inner.clear(); + self.clockwise = None; + } + + fn join(&mut self, join_mode: JoinMode, p0: P, p1: P, p2: P) { + if p0 == p1 || p1 == p2 { + if self.clockwise.is_none() { + self.clockwise = Some(false); + } + return; + } + let t = self.p.thickness; + let inf = Face::new(p0, p1, t); + let out = Face::new(p1, p2, t); + let join_clockwise = Slope::compare(inf.dev_slope, out.dev_slope) < 0; + let poly_clockwise = self.clockwise.unwrap_or(join_clockwise); + let switched = join_clockwise != poly_clockwise; + + // `outer_joiner` plots to the outer contour (append) unless the + // direction switched, in which case it plots to the inner (prepend). + let plot_outer = |pl: &mut Plotter, pt: P, to_outer: bool| { + if to_outer { + pl.outer.push_back(pt); + } else { + pl.inner.push_front(pt); + } + }; + + if Slope::compare(inf.dev_slope, out.dev_slope) == 0 { + plot_outer( + self, + if join_clockwise { + inf.p1_ccw + } else { + inf.p1_cw + }, + !switched, + ); + plot_outer( + self, + if join_clockwise { + inf.p1_cw + } else { + inf.p1_ccw + }, + switched, + ); + if self.clockwise.is_none() { + self.clockwise = Some(poly_clockwise); + } + return; + } + + match join_mode { + JoinMode::Miter => { + if Slope::compare_for_miter_limit(inf.dev_slope, out.dev_slope, self.p.miter_limit) + { + let pt = inf.intersect(&out, join_clockwise); + plot_outer(self, pt, !switched); + } else { + plot_outer( + self, + if join_clockwise { + inf.p1_ccw + } else { + inf.p1_cw + }, + !switched, + ); + plot_outer( + self, + if join_clockwise { + out.p0_ccw + } else { + out.p0_cw + }, + !switched, + ); + } + } + JoinMode::Round => { + plot_outer( + self, + if join_clockwise { + inf.p1_ccw + } else { + inf.p1_cw + }, + !switched, + ); + let pen = self.pen.take().expect("round joins need a pen"); + let mut vit = pen.vertex_iterator_for(inf.dev_slope, out.dev_slope, join_clockwise); + while let Some(v) = vit.next() { + plot_outer(self, (p1.0 + v.point.0, p1.1 + v.point.1), !switched); + } + self.pen = Some(pen); + plot_outer( + self, + if join_clockwise { + out.p0_ccw + } else { + out.p0_cw + }, + !switched, + ); + } + } + + plot_outer( + self, + if join_clockwise { + inf.p1_cw + } else { + inf.p1_ccw + }, + switched, + ); + plot_outer(self, p1, switched); + plot_outer( + self, + if join_clockwise { + out.p0_cw + } else { + out.p0_ccw + }, + switched, + ); + if self.clockwise.is_none() { + self.clockwise = Some(poly_clockwise); + } + } +} + +fn lerp_half(a: P, b: P) -> P { + (a.0 + (b.0 - a.0) / 2.0, a.1 + (b.1 - a.1) / 2.0) +} + +/// z2d `Spline.Knots.errorSq`. +#[allow(clippy::float_cmp)] // exact comparison, like z2d +pub fn knots_error_sq(k: &[P; 4]) -> f64 { + let [a, b, c, d] = *k; + let (mut bx, mut by) = (b.0 - a.0, b.1 - a.1); + let (mut cx, mut cy) = (c.0 - a.0, c.1 - a.1); + if a.0 != d.0 || a.1 != d.1 { + let (dx, dy) = (d.0 - a.0, d.1 - a.1); + let dd = dx * dx + dy * dy; + let bd = bx * dx + by * dy; + if bd >= dd { + bx -= dx; + by -= dy; + } else { + bx -= bd / dd * dx; + by -= bd / dd * dy; + } + let cd = cx * dx + cy * dy; + if cd >= dd { + cx -= dx; + cy -= dy; + } else { + cx -= cd / dd * dx; + cy -= cd / dd * dy; + } + } + let (be, ce) = (bx * bx + by * by, cx * cx + cy * cy); + if be > ce { be } else { ce } +} + +/// z2d `Spline.Knots.deCasteljau`: `k` becomes the first half, the second +/// half is returned. +pub fn de_casteljau(k: &mut [P; 4]) -> [P; 4] { + let [a, b, c, d] = *k; + let ab = lerp_half(a, b); + let bc = lerp_half(b, c); + let cd = lerp_half(c, d); + let abbc = lerp_half(ab, bc); + let bccd = lerp_half(bc, cd); + let fin = lerp_half(abbc, bccd); + *k = [a, ab, abbc, fin]; + [fin, bccd, cd, d] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params() -> StrokeParams { + StrokeParams { + thickness: 2.0, + cap_mode: CapMode::Butt, + miter_limit: 10.0, + tolerance: 0.01, + } + } + + // Ports of the z2d `stroke_plotter` tests. + #[test] + fn degenerate_move_line_then_good_line() { + let nodes = [ + Node::MoveTo((10.0, 10.0)), + Node::LineTo((10.0, 10.0)), + Node::LineTo((20.0, 20.0)), + ]; + assert_eq!(plot(&nodes, params()).len(), 4); + let nodes = [ + Node::MoveTo((10.0, 10.0)), + Node::LineTo((20.0, 20.0)), + Node::LineTo((20.0, 20.0)), + ]; + assert_eq!(plot(&nodes, params()).len(), 4); + } + + #[test] + fn slope_difference_below_epsilon_is_finite() { + let nodes = [ + Node::MoveTo((2.8641015625e2, 1.43154296875e1)), + Node::LineTo((2.822548828125e2, 1.6e1)), + Node::LineTo((2.80990234375e2, 1.65126953125e1)), + ]; + let edges = plot(&nodes, params()); + assert_eq!(edges.len(), 6); + for (a, b) in edges { + assert!(a.0.is_finite() && a.1.is_finite() && b.0.is_finite() && b.1.is_finite()); + } + } + + #[test] + fn round_cap_dot_and_curve_do_not_panic() { + let mut p = params(); + p.cap_mode = CapMode::Round; + let nodes = [Node::MoveTo((5.0, 5.0)), Node::ClosePath]; + assert!(plot(&nodes, p).len() > 4); + let nodes = [ + Node::MoveTo((0.0, 0.0)), + Node::CurveTo((5.0, 0.0), (10.0, 5.0), (10.0, 10.0)), + ]; + assert!(plot(&nodes, params()).len() > 6); + } +} diff --git a/crates/ghostty-os/src/zig_error.rs b/crates/ghostty-os/src/zig_error.rs index 44a0a5e..22ea482 100644 --- a/crates/ghostty-os/src/zig_error.rs +++ b/crates/ghostty-os/src/zig_error.rs @@ -2,35 +2,133 @@ //! `error.FileNotFound`), following Zig std's posix errno mapping for the //! calls Ghostty makes (`openat`, `fstat`, `realpath`, `execve`, ...). -/// Zig error name (without the `error.` prefix) for a Linux errno. +/// Zig error name (without the `error.` prefix) for a platform error code: +/// a POSIX `errno` on unix targets (values come from `libc`, so Linux and +/// Darwin/BSD numbering are both right) or a Win32 `GetLastError` code on +/// Windows (the `CreateProcessW` / file-open mapping of Zig std). #[must_use] +#[cfg(unix)] pub fn errno_name(errno: i32) -> &'static str { + use libc::{ + EACCES, + EAGAIN, + EBUSY, + EEXIST, + EFBIG, + EILSEQ, + EISDIR, + ELOOP, + EMFILE, + ENAMETOOLONG, + ENFILE, + ENODEV, + ENOENT, + ENOMEM, + ENOSPC, + ENOTDIR, + ENXIO, + EOPNOTSUPP, + EOVERFLOW, + EPERM, + EROFS, + ESRCH, + ETXTBSY, + }; match errno { - 1 => "PermissionDenied", // EPERM - 2 => "FileNotFound", // ENOENT - 3 => "ProcessNotFound", // ESRCH - 6 | 19 => "NoDevice", // ENXIO, ENODEV - 11 => "WouldBlock", // EAGAIN - 12 => "SystemResources", // ENOMEM - 13 => "AccessDenied", // EACCES - 16 => "DeviceBusy", // EBUSY - 17 => "PathAlreadyExists", // EEXIST - 20 => "NotDir", // ENOTDIR - 21 => "IsDir", // EISDIR - 23 => "SystemFdQuotaExceeded", // ENFILE - 24 => "ProcessFdQuotaExceeded", // EMFILE - 26 => "FileBusy", // ETXTBSY - 27 | 75 => "FileTooBig", // EFBIG, EOVERFLOW - 28 => "NoSpaceLeft", // ENOSPC - 30 => "ReadOnlyFileSystem", // EROFS - 36 => "NameTooLong", // ENAMETOOLONG - 40 => "SymLinkLoop", // ELOOP - 84 => "InvalidWtf8", // EILSEQ - 95 => "FileLocksNotSupported", // EOPNOTSUPP + EPERM => "PermissionDenied", + ENOENT => "FileNotFound", + ESRCH => "ProcessNotFound", + ENXIO | ENODEV => "NoDevice", + EAGAIN => "WouldBlock", + ENOMEM => "SystemResources", + EACCES => "AccessDenied", + EBUSY => "DeviceBusy", + EEXIST => "PathAlreadyExists", + ENOTDIR => "NotDir", + EISDIR => "IsDir", + ENFILE => "SystemFdQuotaExceeded", + EMFILE => "ProcessFdQuotaExceeded", + ETXTBSY => "FileBusy", + EFBIG | EOVERFLOW => "FileTooBig", + ENOSPC => "NoSpaceLeft", + EROFS => "ReadOnlyFileSystem", + ENAMETOOLONG => "NameTooLong", + ELOOP => "SymLinkLoop", + EILSEQ => "InvalidWtf8", + EOPNOTSUPP => "FileLocksNotSupported", _ => "Unexpected", } } +/// See the unix variant; this is the Windows (Win32 error code) table. +#[must_use] +#[cfg(windows)] +#[allow(non_upper_case_globals)] // windows-sys spells two constants `..._64k` +pub fn errno_name(code: i32) -> &'static str { + use windows_sys::Win32::Foundation::{ + ERROR_ACCESS_DENIED, + ERROR_AUTODATASEG_EXCEEDS_64k, + ERROR_BAD_EXE_FORMAT, + ERROR_BAD_FORMAT, + ERROR_COMMITMENT_LIMIT, + ERROR_DIRECTORY, + ERROR_DISK_FULL, + ERROR_DYNLINK_FROM_INVALID_RING, + ERROR_EXE_MACHINE_TYPE_MISMATCH, + ERROR_EXE_MARKED_INVALID, + ERROR_FILE_NOT_FOUND, + ERROR_FILENAME_EXCED_RANGE, + ERROR_INFLOOP_IN_RELOC_CHAIN, + ERROR_INVALID_EXE_SIGNATURE, + ERROR_INVALID_MINALLOCSIZE, + ERROR_INVALID_MODULETYPE, + ERROR_INVALID_NAME, + ERROR_INVALID_SEGDPL, + ERROR_INVALID_STACKSEG, + ERROR_INVALID_STARTING_CODESEG, + ERROR_IOPL_NOT_ENABLED, + ERROR_ITERATED_DATA_EXCEEDS_64k, + ERROR_PATH_NOT_FOUND, + ERROR_RELOC_CHAIN_XEEDS_SEGLIM, + ERROR_RING2SEG_MUST_BE_MOVABLE, + ERROR_SHARING_VIOLATION, + }; + match code as u32 { + ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND | ERROR_DIRECTORY => "FileNotFound", + ERROR_ACCESS_DENIED => "AccessDenied", + ERROR_INVALID_NAME => "InvalidName", + ERROR_FILENAME_EXCED_RANGE => "NameTooLong", + ERROR_SHARING_VIOLATION => "FileBusy", + ERROR_COMMITMENT_LIMIT => "SystemResources", + ERROR_DISK_FULL => "NoSpaceLeft", + ERROR_BAD_FORMAT + | ERROR_INVALID_STARTING_CODESEG + | ERROR_INVALID_STACKSEG + | ERROR_INVALID_MODULETYPE + | ERROR_INVALID_EXE_SIGNATURE + | ERROR_EXE_MARKED_INVALID + | ERROR_BAD_EXE_FORMAT + | ERROR_ITERATED_DATA_EXCEEDS_64k + | ERROR_INVALID_MINALLOCSIZE + | ERROR_DYNLINK_FROM_INVALID_RING + | ERROR_IOPL_NOT_ENABLED + | ERROR_INVALID_SEGDPL + | ERROR_AUTODATASEG_EXCEEDS_64k + | ERROR_RING2SEG_MUST_BE_MOVABLE + | ERROR_RELOC_CHAIN_XEEDS_SEGLIM + | ERROR_INFLOOP_IN_RELOC_CHAIN + | ERROR_EXE_MACHINE_TYPE_MISMATCH => "InvalidExe", + _ => "Unexpected", + } +} + +/// Targets without a native error-code table (wasm). +#[must_use] +#[cfg(not(any(unix, windows)))] +pub fn errno_name(_code: i32) -> &'static str { + "Unexpected" +} + /// `error.Name` text for an I/O error. #[must_use] pub fn io_error_name(err: &std::io::Error) -> String { @@ -45,19 +143,91 @@ pub fn io_error_name(err: &std::io::Error) -> String { } } -#[cfg(all(test, unix))] +#[cfg(test)] mod tests { use super::*; + #[cfg(unix)] + fn n(e: i32) -> String { + io_error_name(&std::io::Error::from_raw_os_error(e)) + } + + #[cfg(unix)] #[test] - fn names() { - let n = |e| io_error_name(&std::io::Error::from_raw_os_error(e)); - assert_eq!(n(2), "error.FileNotFound"); - assert_eq!(n(20), "error.NotDir"); - assert_eq!(n(13), "error.AccessDenied"); - assert_eq!(n(21), "error.IsDir"); - assert_eq!(n(36), "error.NameTooLong"); - assert_eq!(n(40), "error.SymLinkLoop"); + fn libc_constants_map() { + assert_eq!(n(libc::ENOENT), "error.FileNotFound"); + assert_eq!(n(libc::ENOTDIR), "error.NotDir"); + assert_eq!(n(libc::EACCES), "error.AccessDenied"); + assert_eq!(n(libc::EISDIR), "error.IsDir"); + assert_eq!(n(libc::ENAMETOOLONG), "error.NameTooLong"); + assert_eq!(n(libc::ELOOP), "error.SymLinkLoop"); + assert_eq!(n(libc::EAGAIN), "error.WouldBlock"); + assert_eq!(n(libc::EOPNOTSUPP), "error.FileLocksNotSupported"); assert_eq!(n(9999), "error.Unexpected"); } + + #[cfg(any(target_os = "linux", target_os = "android"))] + #[test] + fn linux_table() { + for (code, name) in [ + (1, "PermissionDenied"), + (2, "FileNotFound"), + (11, "WouldBlock"), + (13, "AccessDenied"), + (20, "NotDir"), + (21, "IsDir"), + (36, "NameTooLong"), + (40, "SymLinkLoop"), + (75, "FileTooBig"), + (84, "InvalidWtf8"), + (95, "FileLocksNotSupported"), + ] { + assert_eq!(errno_name(code), name); + } + } + + #[cfg(target_vendor = "apple")] + #[test] + fn darwin_table() { + for (code, name) in [ + (1, "PermissionDenied"), + (2, "FileNotFound"), + (13, "AccessDenied"), + (20, "NotDir"), + (21, "IsDir"), + (35, "WouldBlock"), + (62, "SymLinkLoop"), + (63, "NameTooLong"), + (84, "FileTooBig"), + (92, "InvalidWtf8"), + (102, "FileLocksNotSupported"), + ] { + assert_eq!(errno_name(code), name); + } + // Linux-only numbers must not leak through. + assert_eq!(errno_name(36), "Unexpected"); + assert_eq!(errno_name(40), "Unexpected"); + } + + #[cfg(windows)] + #[test] + fn windows_table() { + for (code, name) in [ + (2, "FileNotFound"), + (3, "FileNotFound"), + (5, "AccessDenied"), + (32, "FileBusy"), + (112, "NoSpaceLeft"), + (123, "InvalidName"), + (193, "InvalidExe"), + (206, "NameTooLong"), + (267, "FileNotFound"), + (1455, "SystemResources"), + (87, "Unexpected"), + ] { + assert_eq!(errno_name(code), name); + } + let e = std::io::Error::from_raw_os_error(5); + assert_eq!(io_error_name(&e), "error.AccessDenied"); + } } diff --git a/docs/rewrite/notes/final-Z15.md b/docs/rewrite/notes/final-Z15.md new file mode 100644 index 0000000..c3484e1 --- /dev/null +++ b/docs/rewrite/notes/final-Z15.md @@ -0,0 +1,29 @@ +# Z15: RV12 P3 closures + +## 1. Platform error codes (`ghostty-os/src/zig_error.rs`) +`errno_name` is now per target: unix uses `libc::E*` constants (Linux and Darwin/BSD numbering both +correct, e.g. Darwin `ELOOP=62`, `ENAMETOOLONG=63`, `EAGAIN=35`); Windows maps Win32 codes the way +Zig std's `windowsCreateProcess` / file-open paths do (FILE_NOT_FOUND, PATH_NOT_FOUND, DIRECTORY -> +FileNotFound; ACCESS_DENIED -> AccessDenied; INVALID_NAME, FILENAME_EXCED_RANGE -> NameTooLong; +SHARING_VIOLATION -> FileBusy; COMMITMENT_LIMIT -> SystemResources; DISK_FULL -> NoSpaceLeft; the +ENOEXEC family -> InvalidExe), using `windows-sys` constants. Other targets (wasm): `Unexpected`. +Tests are cfg'd per platform (libc constants everywhere, literal tables for Linux/Apple/Windows) and +compile-checked with `just check-macos` / `just check-windows`. + +## 2. Dot theme names (`ghostty-config/src/theme.rs`) +`zig_basename` ports `std.fs.path.basename` (ComponentIterator.last: trailing separators stripped, +`.`/`..` are ordinary names; `X:` drive root on Windows) instead of `Path::file_name`. `theme=.` and +`theme=..` now pass the basename check and report Zig's `not reading theme from ".": it is a +directory`. Differentially checked against /usr/bin/ghostty (`.`, `..`, `a/`, `a/b`, `./`: identical). + +## 3. Sprite stroking (`ghostty-font/src/sprite/{stroke,canvas}.rs`) +tiny-skia is gone (dependency removed). New `stroke.rs` ports z2d 0.10 `stroke_plotter`, `Face`, `Pen`, +`Slope`, `Spline` (curve flattening with round joins), `PointBuffer` and Contour/Polygon edge building +(identity CTM, no dashes). `canvas.rs` now builds z2d-style nodes (`close` appends the implicit +`move_to`), fills via direct node flattening in f64 (no f32 skia round trip), ports z2d's Cairo-style +`arc` (segment count from the tolerance table), and uses z2d's truncating integer `src_over` +(`s + d - s*d/255`, `dst_in` `cov*a/255`) instead of rounding. +Sprite-sheet reference test: every one of the 36 sheets is now bit-exact (was 1.2% differing, max 86); +the test now requires 0 differing pixels. +`just visual-parity` (iteration build of the production profile): page, page-bar, tabs and chrome all +0 differing pixels (was 196 px / 0.020% for page). diff --git a/docs/rewrite/notes/visual-parity-VP.md b/docs/rewrite/notes/visual-parity-VP.md index cc07521..6ea2fae 100644 --- a/docs/rewrite/notes/visual-parity-VP.md +++ b/docs/rewrite/notes/visual-parity-VP.md @@ -30,13 +30,13 @@ | scenario | before | after | |---|---|---| -| page (full frame) | 0.130% (1250 px, max 93) | 0.020% (196 px, max 86; 0.001% > 32 levels) | -| page-bar | same as page | 0.020% | +| page (full frame) | 0.130% (1250 px, max 93) | 0.000% (0 px; was 0.020% / 196 px before Z15) | +| page-bar | same as page | 0.000% | | tabs + splits | 44.6% (a whole pane blank, bug) | 0.000% (bit-identical) | | chrome (headerbar, tab bar) | 0.000% | 0.000% | -Sprite sheets (`ghostty-font` tests, Zig reference PNGs): differing pixels 5.76% -> 1.20%, pixels off by -more than 96 levels 74 -> 0 (test tolerance tightened from 4% to 0.1% big diffs). +Sprite sheets (`ghostty-font` tests, Zig reference PNGs): differing pixels 5.76% -> 1.20% -> 0 (bit-exact after Z15; +test tolerance is now zero). Font metrics, cell size, baseline, glyph rasterization, colors, cursor, underline positions, padding, GTK CSS/headerbar/tab bar were already pixel-identical; no change was needed there. @@ -51,12 +51,12 @@ GTK CSS/headerbar/tab bar were already pixel-identical; no change was needed the 2. **Sprite antialiasing** (`ghostty-font/src/sprite/canvas.rs`): replaced tiny-skia's coverage with a port of z2d 0.10's default `multisample_4x` rasterizer (4x4 samples, scanline spans with edge x rounded at sample row centers, non-zero filter, alpha = `coverage * 16 - 1`, full = 255, z2d's Bezier - flattening with tolerance 0.1). Stroke outlines still come from tiny-skia's stroker. + flattening with tolerance 0.1). (Stroke outlines were still tiny-skia's until Z15, which ported z2d's stroker.) -## Remaining differences (page: 196 px, all <= 86 levels) +## Remaining differences -- Curly underline: first/last pixels and peaks differ slightly. z2d builds its own stroke polygon (pen/joins) - for curves; tiny-skia's stroker offsets the Bezier differently. -- Diagonal box glyphs (`U+2571..2573`) have a few edge pixels off by up to ~38 levels (same cause). -- Porting z2d's stroker (`tess/stroke_plotter`, `Pen`) would remove the rest; judged not worth it - (0.02% of pixels, imperceptible). +None (Z15). z2d's stroke plotter (pen, joins, caps, spline flattening), Cairo-style arc segmentation and +z2d's truncating integer compositing were ported (`ghostty-font/src/sprite/stroke.rs`, `canvas.rs`; +see `final-Z15.md`). Final `just visual-parity`: page, page-bar and tabs are 0 differing pixels on the +full frame, the terminal area and the chrome (max delta 0). All sprite sheets in the `ghostty-font` +reference test are bit-exact (tolerance now zero).