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
This commit is contained in:
ddidderr committed 2026-10-12 14:20:49 +02:00
1 parent c9051641a5
commit f68b11d469
10 files changed
+1333 -185

No files matched your search

Generated
-39
View File
@@ -88,12 +88,6 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.8" version = "0.7.8"
@@ -1549,7 +1543,6 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
"png", "png",
"thiserror 2.0.21", "thiserror 2.0.21",
"tiny-skia",
"yeslogic-fontconfig-sys", "yeslogic-fontconfig-sys",
] ]
@@ -3861,12 +3854,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
@@ -4148,32 +4135,6 @@ dependencies = [
"time-core", "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]] [[package]]
name = "toml" name = "toml"
version = "1.1.8+spec-1.1.0" version = "1.1.8+spec-1.1.0"
+60 -5
View File
@@ -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 /// 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 /// 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. /// a Some return value is returned, there are never any errors added.
@@ -184,11 +216,7 @@ pub fn open(
}); });
} }
let basename = theme_path if theme_text != zig_basename(theme_text) {
.file_name()
.map(EncodedOs::from_os)
.unwrap_or_default();
if *theme != basename {
diag( diag(
diags, diags,
format!( 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] #[test]
fn theme_found_in_resources() { fn theme_found_in_resources() {
let dir = std::env::temp_dir().join(format!("ghostty-theme-test-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("ghostty-theme-test-{}", std::process::id()));
-1
View File
@@ -6,7 +6,6 @@ license.workspace = true
[dependencies] [dependencies]
ghostty-os.workspace = true ghostty-os.workspace = true
tiny-skia = "0.12.0"
png = "0.18.1" png = "0.18.1"
thiserror = "2.0.21" thiserror = "2.0.21"
log = "0.4.34" log = "0.4.34"
+152 -90
View File
@@ -4,12 +4,17 @@
//! Ghostty uses the `z2d` library for antialiased path drawing. This port //! Ghostty uses the `z2d` library for antialiased path drawing. This port
//! reproduces z2d's default `multisample_4x` rasterizer (4x4 samples per //! reproduces z2d's default `multisample_4x` rasterizer (4x4 samples per
//! pixel, scanline spans, alpha `coverage * 16 - 1`) in [`Canvas::rasterize`]; //! 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 std::ops::Sub;
use tiny_skia::{LineCap, LineJoin, PathBuilder, PathSegment, Stroke}; use super::stroke;
use crate::atlas::{self, Atlas, Format, Region}; use crate::atlas::{self, Atlas, Format, Region};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -148,65 +153,130 @@ impl Path {
return; return;
} }
self.nodes.push(PathNode::ClosePath); 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; self.current = self.start;
} }
/// Add a circular arc centered at `(x, y)` from angle `a0` to `a1` /// 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 /// (radians, increasing angles, clockwise in canvas space with +y down),
/// current point, a line is drawn to the start of the arc, otherwise /// like z2d's `Path.arc` (`internal/arc.zig`, Cairo's arc algorithm). If
/// the arc starts a new subpath. /// 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) { pub fn arc(&mut self, x: f64, y: f64, radius: f64, a0: f64, a1: f64) {
use std::f64::consts::FRAC_PI_2; let mut a1 = a1;
while a1 < a0 {
a1 += std::f64::consts::TAU;
}
self.arc_in_direction(x, y, radius, a0, a1, 0.1);
}
let (sx, sy) = (x + radius * a0.cos(), y + radius * a0.sin()); /// z2d `arc_line_to`: a no-op when already at the destination.
if self.current.is_some() { fn arc_line_to(&mut self, x: f64, y: f64) {
self.line_to(sx, sy); 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 { } else {
self.move_to(sx, sy); self.arc_line_to(xc + radius * amin.cos(), yc + radius * amin.sin());
}
} }
let mut sweep = a1 - a0; fn arc_segment(&mut self, xc: f64, yc: f64, radius: f64, a: f64, b: f64) {
// Split into segments of at most a quarter turn. let (r_sin_a, r_cos_a) = (radius * a.sin(), radius * a.cos());
let segs = ((sweep.abs() / FRAC_PI_2).ceil() as usize).max(1); let (r_sin_b, r_cos_b) = (radius * b.sin(), radius * b.cos());
sweep /= segs as f64; let h = 4.0 / 3.0 * ((b - a) / 4.0).tan();
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( self.curve_to(
x + radius * (c0 - k * s0), xc + r_cos_a - h * r_sin_a,
y + radius * (s0 + k * c0), yc + r_sin_a + h * r_cos_a,
x + radius * (c1 + k * s1), xc + r_cos_b + h * r_sin_b,
y + radius * (s1 - k * c1), yc + r_sin_b - h * r_cos_b,
x + radius * c1, xc + r_cos_b,
y + radius * s1, yc + r_sin_b,
); );
a += sweep; }
/// 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<stroke::Node> {
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()
} }
} }
fn to_skia(&self, dx: f64, dy: f64) -> Option<tiny_skia::Path> { /// z2d `arc_error_normalized`.
let mut pb = PathBuilder::new(); fn arc_error_normalized(angle: f64) -> f64 {
let f = |v: f64, d: f64| (v + d) as f32; 2.0 / 27.0 * (angle / 4.0).sin().powf(6.0) / (angle / 4.0).cos().powf(2.0)
for n in &self.nodes { }
match *n {
PathNode::MoveTo(x, y) => pb.move_to(f(x, dx), f(y, dy)), /// z2d `arc_max_angle_for_tolerance_normalized`.
PathNode::LineTo(x, y) => pb.line_to(f(x, dx), f(y, dy)), fn arc_max_angle_for_tolerance_normalized(tolerance: f64) -> f64 {
PathNode::CurveTo(x1, y1, x2, y2, x3, y3) => pb.cubic_to( use std::f64::consts::PI;
f(x1, dx), const TABLE: [(f64, f64); 11] = [
f(y1, dy), (PI / 1.0, 0.0185185185185185036127),
f(x2, dx), (PI / 2.0, 0.000272567143730179811158),
f(y2, dy), (PI / 3.0, 2.38647043651461047433e-05),
f(x3, dx), (PI / 4.0, 4.2455377443222443279e-06),
f(y3, dy), (PI / 5.0, 1.11281001494389081528e-06),
), (PI / 6.0, 3.72662000942734705475e-07),
PathNode::ClosePath => pb.close(), (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;
} }
} }
pb.finish() 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). /// Line cap modes (subset of z2d's).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -469,30 +539,35 @@ impl Canvas {
/// with z2d's default multisample anti-aliasing. /// with z2d's default multisample anti-aliasing.
fn rasterize(&mut self, path: &Path, stroke: Option<StrokeOptions>) -> bool { fn rasterize(&mut self, path: &Path, stroke: Option<StrokeOptions>) -> bool {
self.cov.fill(0); 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; return false;
}; }
let p = match stroke { let edges = match stroke {
None => p, None => flatten_to_edges(&nodes),
Some(opts) => { Some(opts) => {
let s = Stroke { // z2d `painter.stroke`: thin lines revert to butt caps, and
width: opts.line_width as f32, // there is a minimum line width.
miter_limit: 10.0, let params = stroke::StrokeParams {
line_cap: match opts.line_cap_mode { thickness: opts.line_width.max(0.00390625),
CapMode::Butt => LineCap::Butt, cap_mode: if opts.line_width >= 2.0 {
CapMode::Round => LineCap::Round, opts.line_cap_mode
CapMode::Square => LineCap::Square, } else {
CapMode::Butt
}, },
line_join: LineJoin::Miter, miter_limit: 10.0,
dash: None, tolerance: 0.1,
}; };
match p.stroke(&s, 1.0) { let mut edges = Vec::new();
Some(o) => o, for (p0, p1) in stroke::plot(&nodes, params) {
None => return false, add_edge(&mut edges, p0, p1);
} }
edges
} }
}; };
let edges = flatten_to_edges(&p); if edges.is_empty() {
return false;
}
multisample_fill( multisample_fill(
&edges, &edges,
self.sfc_width as usize, self.sfc_width as usize,
@@ -511,9 +586,11 @@ impl Canvas {
if cov == 0 { if cov == 0 {
continue; 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); 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)) { if self.rasterize(path, Some(mut_opts)) {
let a = u32::from(color.0); let a = u32::from(color.0);
for (d, px) in stroke.iter_mut().zip(self.cov.iter()) { 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) { 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 m = (255.0 * (f64::from(*s) / 255.0) * (f64::from(*f) / 255.0)).round() as u32;
let d = u32::from(*dst); 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(()) Ok(())
} }
@@ -728,9 +805,9 @@ fn flatten_cubic(
out.push(d); 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). /// implicitly closed (fill semantics).
fn flatten_to_edges(p: &tiny_skia::Path) -> Vec<Edge> { fn flatten_to_edges(nodes: &[stroke::Node]) -> Vec<Edge> {
let mut edges = Vec::new(); let mut edges = Vec::new();
let mut pts: Vec<(f64, f64)> = Vec::new(); let mut pts: Vec<(f64, f64)> = Vec::new();
let flush = |pts: &mut Vec<(f64, f64)>, edges: &mut Vec<Edge>| { let flush = |pts: &mut Vec<(f64, f64)>, edges: &mut Vec<Edge>| {
@@ -742,38 +819,23 @@ fn flatten_to_edges(p: &tiny_skia::Path) -> Vec<Edge> {
} }
pts.clear(); pts.clear();
}; };
let f = |p: tiny_skia::Point| (f64::from(p.x), f64::from(p.y));
let mut cur = (0.0, 0.0); let mut cur = (0.0, 0.0);
for seg in p.segments() { for node in nodes {
match seg { match *node {
PathSegment::MoveTo(q) => { stroke::Node::MoveTo(q) => {
flush(&mut pts, &mut edges); flush(&mut pts, &mut edges);
cur = f(q); cur = q;
pts.push(cur); pts.push(cur);
} }
PathSegment::LineTo(q) => { stroke::Node::LineTo(q) => {
cur = f(q); cur = q;
pts.push(cur); pts.push(cur);
} }
PathSegment::QuadTo(c, q) => { stroke::Node::CurveTo(c1, c2, 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));
flatten_cubic(cur, c1, c2, q, &mut pts); flatten_cubic(cur, c1, c2, q, &mut pts);
cur = q; cur = q;
} }
PathSegment::CubicTo(c1, c2, q) => { stroke::Node::ClosePath => flush(&mut pts, &mut edges),
let q = f(q);
flatten_cubic(cur, f(c1), f(c2), q, &mut pts);
cur = q;
}
PathSegment::Close => {
flush(&mut pts, &mut edges);
}
} }
} }
flush(&mut pts, &mut edges); flush(&mut pts, &mut edges);
+5 -8
View File
@@ -254,8 +254,7 @@ mod tests {
} }
/// Per-pixel difference above which a pixel counts as a "big" difference. /// Per-pixel difference above which a pixel counts as a "big" difference.
/// z2d and tiny-skia antialias differently, so small differences are expected /// Kept for the diagnostics only: the sheets are expected to match exactly.
/// along diagonals and curves.
const BIG: u8 = 96; const BIG: u8 = 96;
fn diff_sheet(test: &[u8], reference: &[u8]) -> Diff { fn diff_sheet(test: &[u8], reference: &[u8]) -> Diff {
@@ -321,13 +320,11 @@ mod tests {
d.ink, d.differing, d.big, d.max d.ink, d.differing, d.big, d.max
); );
// Tolerances: the multisample rasterizer matches z2d's coverage; // The multisample rasterizer, the stroker and the compositing are
// remaining differences come from stroke geometry (tiny-skia's // ports of z2d's, so every sheet must match the reference exactly.
// stroker) and are small. Practically no pixel 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 big_ratio = d.big as f64 / d.ink.max(1) as f64;
let ok = big_ratio <= 0.001; let ok = d.differing == 0;
if !ok || d.differing > 0 { if !ok {
// Write a visual diff (red = reference, green = ours). // Write a visual diff (red = reference, green = ours).
let mut rgb = Vec::with_capacity(atlas.len() * 3); let mut rgb = Vec::with_capacity(atlas.len() * 3);
for (t, r) in atlas.iter().zip(&reference) { for (t, r) in atlas.iter().zip(&reference) {
+1
View File
@@ -5,6 +5,7 @@
pub mod canvas; pub mod canvas;
pub mod draw; pub mod draw;
pub mod face; pub mod face;
pub mod stroke;
pub use canvas::{Box, Canvas, Color, Point}; pub use canvas::{Box, Canvas, Color, Point};
pub use face::Face; pub use face::Face;
+874
View File
@@ -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<PenVertex>,
}
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<P>;
/// 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<Pen>,
points: PointBuffer,
clockwise: Option<bool>,
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<P> = 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);
}
}
+201 -31
View File
@@ -2,35 +2,133 @@
//! `error.FileNotFound`), following Zig std's posix errno mapping for the //! `error.FileNotFound`), following Zig std's posix errno mapping for the
//! calls Ghostty makes (`openat`, `fstat`, `realpath`, `execve`, ...). //! 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] #[must_use]
#[cfg(unix)]
pub fn errno_name(errno: i32) -> &'static str { 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 { match errno {
1 => "PermissionDenied", // EPERM EPERM => "PermissionDenied",
2 => "FileNotFound", // ENOENT ENOENT => "FileNotFound",
3 => "ProcessNotFound", // ESRCH ESRCH => "ProcessNotFound",
6 | 19 => "NoDevice", // ENXIO, ENODEV ENXIO | ENODEV => "NoDevice",
11 => "WouldBlock", // EAGAIN EAGAIN => "WouldBlock",
12 => "SystemResources", // ENOMEM ENOMEM => "SystemResources",
13 => "AccessDenied", // EACCES EACCES => "AccessDenied",
16 => "DeviceBusy", // EBUSY EBUSY => "DeviceBusy",
17 => "PathAlreadyExists", // EEXIST EEXIST => "PathAlreadyExists",
20 => "NotDir", // ENOTDIR ENOTDIR => "NotDir",
21 => "IsDir", // EISDIR EISDIR => "IsDir",
23 => "SystemFdQuotaExceeded", // ENFILE ENFILE => "SystemFdQuotaExceeded",
24 => "ProcessFdQuotaExceeded", // EMFILE EMFILE => "ProcessFdQuotaExceeded",
26 => "FileBusy", // ETXTBSY ETXTBSY => "FileBusy",
27 | 75 => "FileTooBig", // EFBIG, EOVERFLOW EFBIG | EOVERFLOW => "FileTooBig",
28 => "NoSpaceLeft", // ENOSPC ENOSPC => "NoSpaceLeft",
30 => "ReadOnlyFileSystem", // EROFS EROFS => "ReadOnlyFileSystem",
36 => "NameTooLong", // ENAMETOOLONG ENAMETOOLONG => "NameTooLong",
40 => "SymLinkLoop", // ELOOP ELOOP => "SymLinkLoop",
84 => "InvalidWtf8", // EILSEQ EILSEQ => "InvalidWtf8",
95 => "FileLocksNotSupported", // EOPNOTSUPP EOPNOTSUPP => "FileLocksNotSupported",
_ => "Unexpected", _ => "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. /// `error.Name` text for an I/O error.
#[must_use] #[must_use]
pub fn io_error_name(err: &std::io::Error) -> String { 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 { mod tests {
use super::*; use super::*;
#[cfg(unix)]
fn n(e: i32) -> String {
io_error_name(&std::io::Error::from_raw_os_error(e))
}
#[cfg(unix)]
#[test] #[test]
fn names() { fn libc_constants_map() {
let n = |e| io_error_name(&std::io::Error::from_raw_os_error(e)); assert_eq!(n(libc::ENOENT), "error.FileNotFound");
assert_eq!(n(2), "error.FileNotFound"); assert_eq!(n(libc::ENOTDIR), "error.NotDir");
assert_eq!(n(20), "error.NotDir"); assert_eq!(n(libc::EACCES), "error.AccessDenied");
assert_eq!(n(13), "error.AccessDenied"); assert_eq!(n(libc::EISDIR), "error.IsDir");
assert_eq!(n(21), "error.IsDir"); assert_eq!(n(libc::ENAMETOOLONG), "error.NameTooLong");
assert_eq!(n(36), "error.NameTooLong"); assert_eq!(n(libc::ELOOP), "error.SymLinkLoop");
assert_eq!(n(40), "error.SymLinkLoop"); assert_eq!(n(libc::EAGAIN), "error.WouldBlock");
assert_eq!(n(libc::EOPNOTSUPP), "error.FileLocksNotSupported");
assert_eq!(n(9999), "error.Unexpected"); 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");
}
} }
+29
View File
@@ -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).
+11 -11
View File
@@ -30,13 +30,13 @@
| scenario | before | after | | scenario | before | after |
|---|---|---| |---|---|---|
| page (full frame) | 0.130% (1250 px, max 93) | 0.020% (196 px, max 86; 0.001% > 32 levels) | | 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.020% | | page-bar | same as page | 0.000% |
| tabs + splits | 44.6% (a whole pane blank, bug) | 0.000% (bit-identical) | | tabs + splits | 44.6% (a whole pane blank, bug) | 0.000% (bit-identical) |
| chrome (headerbar, tab bar) | 0.000% | 0.000% | | 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 Sprite sheets (`ghostty-font` tests, Zig reference PNGs): differing pixels 5.76% -> 1.20% -> 0 (bit-exact after Z15;
more than 96 levels 74 -> 0 (test tolerance tightened from 4% to 0.1% big diffs). test tolerance is now zero).
Font metrics, cell size, baseline, glyph rasterization, colors, cursor, underline positions, padding, 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. 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 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 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 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) None (Z15). z2d's stroke plotter (pen, joins, caps, spline flattening), Cairo-style arc segmentation and
for curves; tiny-skia's stroker offsets the Bezier differently. z2d's truncating integer compositing were ported (`ghostty-font/src/sprite/stroke.rs`, `canvas.rs`;
- Diagonal box glyphs (`U+2571..2573`) have a few edge pixels off by up to ~38 levels (same cause). see `final-Z15.md`). Final `just visual-parity`: page, page-bar and tabs are 0 differing pixels on the
- Porting z2d's stroker (`tess/stroke_plotter`, `Pen`) would remove the rest; judged not worth it full frame, the terminal area and the chrome (max delta 0). All sprite sheets in the `ghostty-font`
(0.02% of pixels, imperceptible). reference test are bit-exact (tolerance now zero).