From ce9373aa7a01119f3811cf148f4fa8b88ef08cfb Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 13:52:22 +0200 Subject: [PATCH] feat: visual parity with the original Ghostty (pixel-diff pipeline) Adds scripts/visual-parity.sh (`just visual-parity`): launches the installed Ghostty 1.3.1 and the Rust build in an isolated headless nested weston (GL renderer, Mesa forced, weston-screenshooter via --debug), with identical isolated config, a deterministic test page (ASCII, ligatures, all SGR styles and underline kinds, 16/256/truecolor, box/block/braille/powerline/nerd icons, CJK, emoji, wide chars, cursor) and a tabs+splits scenario driven through Xwayland, then diffs the captures (terminal area and chrome separately, heatmaps). Fixes found by comparing against the original: - A pane stayed blank after a nested split: the GTK render path called draw_frame(false) where Ghostty uses drawFrame(true) (a re-realized GLArea has a new swap chain that nothing else redraws). - Sprite glyph antialiasing now ports z2d's default 4x4 multisample rasterizer instead of tiny-skia's analytic coverage (pixels differing from Ghostty's reference sprite sheets: 5.76% -> 1.20%, none off by more than 96 levels); the sheet test tolerance is tightened to 0.1%. Result vs Ghostty 1.3.1: test page 0.020% of pixels differ (196 px at curly-underline ends and diagonal box strokes, max delta 86 levels); tabs+splits and headerbar/tab bar are bit-identical. Details in docs/rewrite/notes/visual-parity-VP.md. Test Plan: - just visual-parity (needs weston, weston-screenshooter, xdotool) - cargo test -p ghostty-font (sprite sheet comparison) --- crates/ghostty-core/src/surface/mod.rs | 5 +- crates/ghostty-font/src/sprite/canvas.rs | 325 ++++++++++++++++++++--- crates/ghostty-font/src/sprite/face.rs | 13 +- docs/rewrite/notes/visual-parity-VP.md | 62 +++++ justfile | 4 + scripts/visual-parity.sh | 127 +++++++++ scripts/visual-parity/compare.py | 54 ++++ scripts/visual-parity/testpage.sh | 43 +++ scripts/visual-parity/zoom.py | 11 + 9 files changed, 599 insertions(+), 45 deletions(-) create mode 100644 docs/rewrite/notes/visual-parity-VP.md create mode 100755 scripts/visual-parity.sh create mode 100755 scripts/visual-parity/compare.py create mode 100755 scripts/visual-parity/testpage.sh create mode 100644 scripts/visual-parity/zoom.py diff --git a/crates/ghostty-core/src/surface/mod.rs b/crates/ghostty-core/src/surface/mod.rs index d554f56..4e8c029 100644 --- a/crates/ghostty-core/src/surface/mod.rs +++ b/crates/ghostty-core/src/surface/mod.rs @@ -2125,7 +2125,10 @@ impl CoreSurface for Surface { fn draw_frame(&self, _default_framebuffer: u32) -> Result<(), CoreError> { // GTK's GLArea binds its own FBO before `render`; the renderer // queries GL_FRAMEBUFFER_BINDING itself. - self.renderer.draw_frame(false)?; + // Zig: apprt gtk `glRender` calls `renderer.drawFrame(true)`. Sync + // must be true: after a GLArea (re)realize (e.g. a split rebuild) the + // swap chain is new and nothing else would redraw an unchanged pane. + self.renderer.draw_frame(true)?; self.debug_screenshot(); Ok(()) } diff --git a/crates/ghostty-font/src/sprite/canvas.rs b/crates/ghostty-font/src/sprite/canvas.rs index e06fa51..82a1e68 100644 --- a/crates/ghostty-font/src/sprite/canvas.rs +++ b/crates/ghostty-font/src/sprite/canvas.rs @@ -1,12 +1,14 @@ //! This exposes primitives to draw 2D graphics and export the graphic to //! a font atlas. Port of `font/sprite/canvas.zig`. //! -//! Ghostty uses the `z2d` library for antialiased path drawing; this port -//! uses `tiny-skia` to rasterize path coverage (see [`Canvas::fill_path`]). +//! 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. use std::ops::Sub; -use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform}; +use tiny_skia::{LineCap, LineJoin, PathBuilder, PathSegment, Stroke}; use crate::atlas::{self, Atlas, Format, Region}; @@ -248,8 +250,8 @@ pub struct Canvas { pub clip_right: u32, pub clip_bottom: u32, - /// Scratch pixmap used to rasterize path coverage. - scratch: Pixmap, + /// Coverage mask (alpha8, same layout as `sfc`) of the last rasterized path. + cov: Vec, } impl Canvas { @@ -267,9 +269,7 @@ impl Canvas { clip_left: 0, clip_right: 0, clip_bottom: 0, - // tiny-skia can't make zero sized pixmaps; sprites are never empty - // but be defensive. - scratch: Pixmap::new(w.max(1), h.max(1)).expect("non-zero pixmap size"), + cov: vec![0; w as usize * h as usize], } } @@ -465,22 +465,15 @@ impl Canvas { ) } - /// Rasterize `path` (filled or stroked) into the scratch pixmap and - /// return nothing; the coverage is then in `self.scratch`'s alpha. + /// Rasterize `path` (filled or stroked) into the coverage mask `self.cov` + /// with z2d's default multisample anti-aliasing. fn rasterize(&mut self, path: &Path, stroke: Option) -> bool { - self.scratch.fill(tiny_skia::Color::TRANSPARENT); - let Some(p) = path.to_skia(0.0, 0.0) else { + self.cov.fill(0); + let Some(p) = path.to_skia(f64::from(self.padding_x), f64::from(self.padding_y)) else { return false; }; - let mut paint = Paint::default(); - paint.set_color_rgba8(0, 0, 0, 255); - paint.anti_alias = true; - let ts = Transform::from_translate(self.padding_x as f32, self.padding_y as f32); - match stroke { - None => { - self.scratch - .fill_path(&p, &paint, FillRule::Winding, ts, None); - } + let p = match stroke { + None => p, Some(opts) => { let s = Stroke { width: opts.line_width as f32, @@ -493,22 +486,28 @@ impl Canvas { line_join: LineJoin::Miter, dash: None, }; - self.scratch.stroke_path(&p, &paint, &s, ts, None); + match p.stroke(&s, 1.0) { + Some(o) => o, + None => return false, + } } - } + }; + let edges = flatten_to_edges(&p); + multisample_fill( + &edges, + self.sfc_width as usize, + self.sfc_height as usize, + &mut self.cov, + ); true } - /// Composite the scratch coverage onto the canvas using `src_over` + /// Composite the coverage mask onto the canvas using `src_over` /// with an opaque pattern of alpha `color`. fn composite_scratch(&mut self, color: Color) { let a = u32::from(color.0); - for (dst, px) in self - .sfc - .iter_mut() - .zip(self.scratch.data().as_chunks::<4>().0.iter()) - { - let cov = u32::from(px[3]); + for (dst, px) in self.sfc.iter_mut().zip(self.cov.iter()) { + let cov = u32::from(*px); if cov == 0 { continue; } @@ -547,11 +546,8 @@ impl Canvas { let mut fill = vec![0u8; self.sfc.len()]; if self.rasterize(&closed, None) { - for (d, px) in fill - .iter_mut() - .zip(self.scratch.data().as_chunks::<4>().0.iter()) - { - *d = px[3]; + for (d, px) in fill.iter_mut().zip(self.cov.iter()) { + *d = *px; } } @@ -561,11 +557,8 @@ impl Canvas { mut_opts.line_width *= 2.0; if self.rasterize(path, Some(mut_opts)) { let a = u32::from(color.0); - for (d, px) in stroke - .iter_mut() - .zip(self.scratch.data().as_chunks::<4>().0.iter()) - { - *d = ((u32::from(px[3]) * a + 127) / 255) as u8; + for (d, px) in stroke.iter_mut().zip(self.cov.iter()) { + *d = ((u32::from(*px) * a + 127) / 255) as u8; } } @@ -614,6 +607,258 @@ impl Canvas { } } +// --------------------------------------------------------------------------- +// z2d multisample rasterizer port (`z2d/src/internal/raster/multisample.zig`, +// `tess/Polygon.zig`, `tess/Spline.zig`). + +const MS_SCALE: i32 = 4; + +#[derive(Clone, Copy)] +struct Edge { + y0: f64, + y1: f64, + x_start: f64, + x_inc: f64, +} + +impl Edge { + fn top(&self) -> f64 { + if self.y0 < self.y1 { self.y0 } else { self.y1 } + } + fn bottom(&self) -> f64 { + if self.y0 < self.y1 { self.y1 } else { self.y0 } + } + fn dir(&self) -> i32 { + if self.y0 < self.y1 { -1 } else { 1 } + } +} + +fn add_edge(edges: &mut Vec, p0: (f64, f64), p1: (f64, f64)) { + let s = f64::from(MS_SCALE); + let (x0, y0, x1, y1) = (p0.0 * s, p0.1 * s, p1.0 * s, p1.1 * s); + if y0 < y1 { + edges.push(Edge { + y0, + y1, + x_start: x0, + x_inc: (x1 - x0) / (y1 - y0), + }); + } else if y0 > y1 { + edges.push(Edge { + y0, + y1, + x_start: x1, + x_inc: (x0 - x1) / (y0 - y1), + }); + } +} + +fn lerp_half(a: (f64, f64), b: (f64, f64)) -> (f64, f64) { + (a.0 + (b.0 - a.0) / 2.0, a.1 + (b.1 - a.1) / 2.0) +} + +type Knots = [(f64, f64); 4]; + +#[allow(clippy::float_cmp)] // exact comparison, like z2d +fn knots_error_sq(k: &Knots) -> 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; + } + } + (bx * bx + by * by).max(cx * cx + cy * cy) +} + +fn de_casteljau(k: &mut Knots) -> Knots { + 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] +} + +fn decompose_into(k: &mut Knots, start: (f64, f64), tol_sq: f64, out: &mut Vec<(f64, f64)>) { + if knots_error_sq(k) < tol_sq { + if k[0] != start { + out.push(k[0]); + } + return; + } + let mut s2 = de_casteljau(k); + decompose_into(k, start, tol_sq, out); + decompose_into(&mut s2, start, tol_sq, out); +} + +/// Flatten a cubic (z2d spline, tolerance 0.1), pushing every point after `a`. +fn flatten_cubic( + a: (f64, f64), + b: (f64, f64), + c: (f64, f64), + d: (f64, f64), + out: &mut Vec<(f64, f64)>, +) { + if a == b && c == d { + out.push(d); + return; + } + let mut k: Knots = [a, b, c, d]; + decompose_into(&mut k, a, 0.1 * 0.1, out); + out.push(d); +} + +/// Flatten a tiny-skia path into scaled polygon edges. Every subpath is +/// implicitly closed (fill semantics). +fn flatten_to_edges(p: &tiny_skia::Path) -> Vec { + let mut edges = Vec::new(); + let mut pts: Vec<(f64, f64)> = Vec::new(); + let flush = |pts: &mut Vec<(f64, f64)>, edges: &mut Vec| { + if pts.len() > 1 { + for w in pts.windows(2) { + add_edge(edges, w[0], w[1]); + } + add_edge(edges, pts[pts.len() - 1], pts[0]); + } + 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) => { + flush(&mut pts, &mut edges); + cur = f(q); + pts.push(cur); + } + PathSegment::LineTo(q) => { + cur = f(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)); + 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); + } + } + } + flush(&mut pts, &mut edges); + edges +} + +/// Port of `multisample.run` with a non-zero fill rule and an opaque source: +/// writes the per-pixel alpha mask (`coverage * 16 - 1`, full = 255). +fn multisample_fill(edges: &[Edge], width: usize, height: usize, cov: &mut [u8]) { + if edges.is_empty() || width == 0 || height == 0 { + return; + } + let scale = MS_SCALE as usize; + let full = (scale * scale) as u32; + let mut counts = vec![0u32; width]; + let mut xs: Vec<(i32, i32)> = Vec::new(); + let max_x = (width * scale) as i32; + for y in 0..height { + counts.fill(0); + let mut any = false; + for sub in 0..scale { + let ys = (y * scale + sub) as i32; + let mid = f64::from(ys) + 0.5; + xs.clear(); + for e in edges { + if e.top() < mid && e.bottom() >= mid { + let x = (e.x_start + e.x_inc * (mid - e.top())).round() as i32; + xs.push((x, e.dir())); + } + } + if xs.is_empty() { + continue; + } + // Stable sort by x like pdqsort on x (ties are order-insensitive + // for the winding filter outcome in practice). + xs.sort_by_key(|v| v.0); + // Non-zero filter. + let mut winding = 0; + let mut filtered: Vec = Vec::with_capacity(xs.len()); + for &(x, d) in &xs { + if winding == 0 { + winding += d; + filtered.push(x); + } else { + winding += d; + if winding == 0 { + filtered.push(x); + } + } + } + let mut x_min = 0; + for pair in filtered.as_chunks::<2>().0 { + let start = pair[0].max(x_min); + if start >= max_x { + break; + } + let end = pair[1].clamp(start, max_x); + if end > start { + any = true; + // Count samples per pixel. + let (mut sx, ex) = (start, end); + while sx < ex { + let px = (sx / MS_SCALE) as usize; + let next = ((px as i32 + 1) * MS_SCALE).min(ex); + counts[px] += (next - sx) as u32; + sx = next; + } + } + x_min = end; + } + } + if any { + let row = &mut cov[y * width..(y + 1) * width]; + for (o, &c) in row.iter_mut().zip(&counts) { + let c = c.min(full); + *o = match c { + 0 => 0, + c if c == full => 255, + c => (c * (256 / full) - 1).min(255) as u8, + }; + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ghostty-font/src/sprite/face.rs b/crates/ghostty-font/src/sprite/face.rs index b5bda7c..688ef99 100644 --- a/crates/ghostty-font/src/sprite/face.rs +++ b/crates/ghostty-font/src/sprite/face.rs @@ -316,12 +316,17 @@ mod tests { return; } let d = diff_sheet(atlas, &reference); + eprintln!( + "sprite-sheet {name}: ink={} differing={} big={} max={}", + d.ink, d.differing, d.big, d.max + ); - // Tolerances: antialiasing differences between z2d and tiny-skia - // are fine, but the geometry must match: very few pixels may be - // off by a lot, relative to the amount of ink on the sheet. + // 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. let big_ratio = d.big as f64 / d.ink.max(1) as f64; - let ok = big_ratio <= 0.04; + let ok = big_ratio <= 0.001; if !ok || d.differing > 0 { // Write a visual diff (red = reference, green = ours). let mut rgb = Vec::with_capacity(atlas.len() * 3); diff --git a/docs/rewrite/notes/visual-parity-VP.md b/docs/rewrite/notes/visual-parity-VP.md new file mode 100644 index 0000000..cc07521 --- /dev/null +++ b/docs/rewrite/notes/visual-parity-VP.md @@ -0,0 +1,62 @@ +# Visual parity vs original Ghostty 1.3.1 (agent VP) + +## Method (`scripts/visual-parity.sh`, `just visual-parity [scenario...]`) + +- A private headless `weston` (`--backend=headless --renderer=gl`, 1200x800, `desktop-shell`, no + panel) per run, on its own socket in `$XDG_RUNTIME_DIR=/tmp/vp-runtime-$UID` (socket paths must be + <108 bytes). The user's desktop is never touched. Mesa is forced for weston (`__EGL_VENDOR_LIBRARY_FILENAMES` + -> 50_mesa.json); the NVIDIA EGL crashes Xwayland's glamor. `XWAYLAND_NO_GLAMOR=1`. +- `weston --debug` is what authorizes `weston-screenshooter` (without it: "Output capture error: unauthorized"). +- Both apps run with `env -i`, `GSETTINGS_BACKEND=memory`, no D-Bus, isolated `XDG_CONFIG_HOME` with the + same explicit config (JetBrains Mono 13, bg/fg, padding 8, `maximize = true` so the window geometry + is deterministic, `cursor-style-blink = false`, `title = Ghostty`, no shell integration) and + `LANG=C.UTF-8` (otherwise `printf '\U..'` in the test page prints literally). +- Scenarios: `page` (block cursor), `page-bar` (bar cursor), `tabs` (new tab + split right + split down, + driven with `xdotool` through Xwayland since the original Ghostty has no input replay; Rust is driven + the same way so both apps are treated identically). + Test page: `scripts/visual-parity/testpage.sh` (ASCII, ligatures, bold/italic/faint/inverse/strike/ + overline, all underline styles + colored, 16/256/truecolor, box drawing, blocks, braille, powerline, + nerd font icons, CJK, emoji, wide chars, math, cursor). Not covered: mouse selection (no input + injection into the original on Wayland; could be added through the Xwayland path). +- `scripts/visual-parity/compare.py`: per-pixel max-channel delta, % differing, % > 8 / > 32 levels, max, + mean, red heat-map PNG; run on the full frame, the terminal area and the headerbar strip. + `scripts/visual-parity/zoom.py`: ref | rust | diff crops, upscaled. +- Output (not committed): `target/visual-parity//{ref,rust,diff,diff-term,diff-chrome}.png`, + `target/visual-parity/stats.txt`. `RUST_BIN` defaults to `target/production/ghostty`. + Iteration build: `CARGO_TARGET_DIR=target/vp CARGO_PROFILE_PRODUCTION_LTO=off + CARGO_PROFILE_PRODUCTION_CODEGEN_UNITS=16 CARGO_PROFILE_PRODUCTION_INCREMENTAL=true cargo build --profile production`. + +## Results (% of pixels differing at all; max channel delta) + +| 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% | +| 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). + +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. + +## Fixes + +1. **Split rebuild blanked an unchanged pane** (`ghostty-core/src/surface/mod.rs`, `draw_frame`): the GTK + `render` handler called `renderer.draw_frame(false)`; Zig's `glRender` uses `drawFrame(true)`. With + `sync = false` and nothing dirty the renderer only re-presents the last target, which is cleared when + the GLArea is re-realized (nested split = tree rebuild = unrealize/realize of every surface whose size + did not change), so that pane stayed empty until something changed. Now `true` like Zig. +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. + +## Remaining differences (page: 196 px, all <= 86 levels) + +- 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). diff --git a/justfile b/justfile index 4234e22..e495f07 100644 --- a/justfile +++ b/justfile @@ -290,3 +290,7 @@ check-wasm: check-all: clippy test check-wasm check-windows scripts/macos-crosscheck.sh clippy aarch64-apple-darwin scripts/macos-crosscheck.sh clippy x86_64-apple-darwin + +# Capture original Ghostty vs the Rust build in a headless weston and diff them. +visual-parity *scenarios: + RUST_BIN="${RUST_BIN:-target/vp/production/ghostty}" scripts/visual-parity.sh {{scenarios}} diff --git a/scripts/visual-parity.sh b/scripts/visual-parity.sh new file mode 100755 index 0000000..f17fd0b --- /dev/null +++ b/scripts/visual-parity.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Visual parity: capture original Ghostty and the Rust build in an isolated, +# headless nested weston and compare pixel-wise. +# +# scripts/visual-parity.sh [scenario...] (default: page page-bar tabs) +# +# env: RUST_BIN (default target/production/ghostty), REF_BIN (default +# /usr/bin/ghostty), OUT (default target/visual-parity), W/H output size. +# Output: $OUT//{ref,rust,diff}.png (+ chrome/term crops) and stats.txt. +set -u +ROOT=$(cd "$(dirname "$0")/.." && pwd) +RUST_BIN=${RUST_BIN:-$ROOT/target/production/ghostty} +REF_BIN=${REF_BIN:-/usr/bin/ghostty} +OUT=${OUT:-$ROOT/target/visual-parity} +W=${W:-1200}; H=${H:-800} +VP=$ROOT/scripts/visual-parity +SCEN=${*:-page page-bar tabs} +mkdir -p "$OUT" +export XDG_RUNTIME_DIR=${VP_RUNTIME_DIR:-/tmp/vp-runtime-$UID} +mkdir -p -m700 "$XDG_RUNTIME_DIR" +WORK=$(mktemp -d "$OUT/work.XXXX") +SOCK=vp$$ + +XWL= +case " $SCEN " in *" tabs "*) XWL=xwayland=true; mkdir -p /tmp/.X11-unix ;; esac +cat >"$WORK/weston.ini" <"$WORK/weston.log" 2>&1 & +WPID=$! +trap 'kill $WPID 2>/dev/null; wait 2>/dev/null' EXIT +for _ in $(seq 50); do [ -S "$XDG_RUNTIME_DIR/$SOCK" ] && break; sleep 0.1; done +sleep 1 + +write_cfg() { # dir, extra + mkdir -p "$1/ghostty" + cat >"$1/ghostty/config" </dev/null 2>&1) + mv "$d"/*.png "$1" && rmdir "$d" +} + +# run BIN LABEL SCENARIO OUTPNG +run_one() { + local bin=$1 label=$2 scen=$3 out=$4 cfgx="" + local cfg="$WORK/cfg-$label-$scen" + case $scen in page-bar) cfgx="cursor-style = bar" ;; esac + write_cfg "$cfg" "$cfgx" + local env=(LANG=C.UTF-8 XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR WAYLAND_DISPLAY=$SOCK XDG_CONFIG_HOME=$cfg GSETTINGS_BACKEND=memory + DBUS_SESSION_BUS_ADDRESS=unix:path=/nonexistent GDK_BACKEND=wayland + HOME=$WORK/home-$label) + mkdir -p "$WORK/home-$label" + case $scen in + tabs) + # Needs input: run under Xwayland and drive with xdotool. + env=("${env[@]/GDK_BACKEND=wayland/GDK_BACKEND=x11}") + env+=(DISPLAY="$(xwl_display)") + env -i PATH="$PATH" "${env[@]}" timeout 60 "$bin" --gtk-single-instance=false \ + -e "$VP/testpage.sh" >"$WORK/$label-$scen.log" 2>&1 & + local pid=$! + sleep 6 + xdotool_keys + sleep 3 + shoot "$out" + kill $pid 2>/dev/null; wait $pid 2>/dev/null + ;; + *) + env -i PATH="$PATH" "${env[@]}" timeout 60 "$bin" --gtk-single-instance=false \ + -e "$VP/testpage.sh" >"$WORK/$label-$scen.log" 2>&1 & + local pid=$! + sleep 7 + shoot "$out" + kill $pid 2>/dev/null; wait $pid 2>/dev/null + ;; + esac +} +xwl_display() { grep -o "display :[0-9]*" "$WORK/weston.log" | tail -1 | sed 's/display //'; } +xdotool_keys() { + export DISPLAY=$(xwl_display) + xdotool key --clearmodifiers ctrl+shift+t; sleep 1 + xdotool key --clearmodifiers ctrl+shift+o; sleep 1 + xdotool key --clearmodifiers ctrl+shift+e; sleep 1 +} + +: >"$OUT/stats.txt" +for s in $SCEN; do + mkdir -p "$OUT/$s" + run_one "$REF_BIN" ref "$s" "$OUT/$s/ref.png" + run_one "$RUST_BIN" rust "$s" "$OUT/$s/rust.png" + python3 "$VP/compare.py" "$OUT/$s/ref.png" "$OUT/$s/rust.png" "$OUT/$s/diff.png" --name "$s/full" | tee -a "$OUT/stats.txt" + # terminal area: window content below the headerbar; chrome: the headerbar strip. + # Window geometry is deterministic under desktop-shell; override with TERM_CROP / CHROME_CROP. + python3 "$VP/compare.py" "$OUT/$s/ref.png" "$OUT/$s/rust.png" "$OUT/$s/diff-term.png" \ + --crop "${TERM_CROP:-0,48,1200,752}" --name "$s/terminal" | tee -a "$OUT/stats.txt" + python3 "$VP/compare.py" "$OUT/$s/ref.png" "$OUT/$s/rust.png" "$OUT/$s/diff-chrome.png" \ + --crop "${CHROME_CROP:-0,0,1200,48}" --name "$s/chrome" | tee -a "$OUT/stats.txt" +done +[ -n "${KEEP:-}" ] || rm -rf "$WORK" diff --git a/scripts/visual-parity/compare.py b/scripts/visual-parity/compare.py new file mode 100755 index 0000000..7ecc438 --- /dev/null +++ b/scripts/visual-parity/compare.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Pixel compare two PNG captures: stats + diff heatmap. + +usage: compare.py REF.png RUST.png DIFF.png [--crop x,y,w,h] [--name NAME] +Prints one line: name, size, differing pixels (%), >8 / >32 levels, max delta, mean delta. +""" +import sys +import numpy as np +from PIL import Image + + +def main(): + a = sys.argv[1:] + crop = None + name = "all" + pos = [] + i = 0 + while i < len(a): + if a[i] == "--crop": + crop = tuple(int(v) for v in a[i + 1].split(",")) + i += 2 + elif a[i] == "--name": + name = a[i + 1] + i += 2 + else: + pos.append(a[i]) + i += 1 + ref = Image.open(pos[0]).convert("RGB") + rs = Image.open(pos[1]).convert("RGB") + if crop: + x, y, w, h = crop + ref, rs = ref.crop((x, y, x + w, y + h)), rs.crop((x, y, x + w, y + h)) + if ref.size != rs.size: + print(f"{name}: SIZE MISMATCH {ref.size} vs {rs.size}") + w, h = min(ref.size[0], rs.size[0]), min(ref.size[1], rs.size[1]) + ref, rs = ref.crop((0, 0, w, h)), rs.crop((0, 0, w, h)) + r = np.asarray(ref).astype(int) + s = np.asarray(rs).astype(int) + d = np.abs(r - s).max(axis=2) + n = d.size + diff = int((d > 0).sum()) + print( + f"{name}: {ref.size[0]}x{ref.size[1]} differing={diff} ({100*diff/n:.3f}%) " + f">8={100*(d>8).sum()/n:.3f}% >32={100*(d>32).sum()/n:.3f}% max={d.max()} mean={d.mean():.4f}" + ) + # heatmap: dimmed reference with differences in red (intensity ~ delta) + heat = (r * 0.25).astype(np.uint8) + m = np.clip(d * 4, 0, 255).astype(np.uint8) + heat[..., 0] = np.maximum(heat[..., 0], m) + heat[..., 1] = np.where(m > 0, heat[..., 1] // 2, heat[..., 1]) + Image.fromarray(heat).save(pos[2]) + + +main() diff --git a/scripts/visual-parity/testpage.sh b/scripts/visual-parity/testpage.sh new file mode 100755 index 0000000..b496cb6 --- /dev/null +++ b/scripts/visual-parity/testpage.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Deterministic test page for visual-parity captures. No prompt, no variance. +E=$'\e' +printf '\e[?25l\e[2J\e[H' +printf '%s\n' 'ASCII: The quick brown fox jumps over the lazy dog 0123456789 {}[]()<>' +printf '%s\n' 'Ligs: -> => == != >= <= :: |> <| www fi fl ffi' +printf '%s\n' 'Sym: !"#$%&'"'"'()*+,-./:;<=>?@[\]^_`{|}~ gjpqy ÀÉÎÕÜ ñ ß' +printf '\e[1mBold\e[0m \e[3mItalic\e[0m \e[1;3mBoldItalic\e[0m \e[2mFaint\e[0m \e[7mInverse\e[0m \e[9mStrike\e[0m \e[53mOverline\e[0m \e[8mhidden\e[0m|\n' +printf 'UL: \e[4mSingle\e[0m \e[4:2mDouble\e[0m \e[4:3mCurly\e[0m \e[4:4mDotted\e[0m \e[4:5mDashed\e[0m \e[4:3;58;2;255;0;0mRedCurly\e[0m \e[4;9mUL+Strike\e[0m\n' +printf '16: ' +for i in 0 1 2 3 4 5 6 7; do printf '\e[4%dm \e[0m' $i; done; printf ' ' +for i in 0 1 2 3 4 5 6 7; do printf '\e[10%dm \e[0m' $i; done; printf '\n' +printf 'FG: ' +for i in 0 1 2 3 4 5 6 7; do printf '\e[3%dmAb\e[0m' $i; done; printf ' ' +for i in 0 1 2 3 4 5 6 7; do printf '\e[9%dmAb\e[0m' $i; done; printf '\n' +printf '256: ' +for i in $(seq 16 87); do printf '\e[48;5;%dm \e[0m' $i; done; printf '\n ' +for i in $(seq 88 159); do printf '\e[48;5;%dm \e[0m' $i; done; printf '\n ' +for i in $(seq 160 231); do printf '\e[48;5;%dm \e[0m' $i; done; printf '\n ' +for i in $(seq 232 255); do printf '\e[48;5;%dm \e[0m' $i; done; printf '\n' +printf 'RGB: ' +for i in $(seq 0 4 255); do printf '\e[48;2;%d;%d;%dm \e[0m' $i $((255-i)) 128; done; printf '\n' +printf 'FGRGB: \e[38;2;255;128;0mOrange\e[0m \e[38;2;0;200;255;48;2;40;40;90mCyanOnBlue\e[0m \e[38;5;196mRed196\e[0m \e[38;5;21;48;5;226mBlueOnYellow\e[0m\n' +printf 'Box: ┌─┬─┐ ╔═╦═╗ ╭─╮ ┏━┳━┓ │ │ │ ║ ║ ║ ├─┼─┤ ╠═╬═╣ ╰─╯ ┗━┻━┛ ╱╲╳ ┈┉┊┋ ╌╍╎╏\n' +printf ' └─┴─┘ ╚═╩═╝ ┐┘└┌ ╒╓╕╖ ╞╟╡╢ ╤╥╧╨ ╪╫ ╴╵╶╷ ╸╹╺╻ ╼╽╾╿\n' +printf 'Blk: ▀▁▂▃▄▅▆▇█▉▊▋▌▍▎▏▐░▒▓▔▕▖▗▘▙▚▛▜▝▞▟ ◢◣◤◥ ⬛\n' +printf 'Brl: ⠀⠁⠂⠃⠄⠅⠆⠇⡀⡁⣀⣿⠿⣤⢕⡪ ⣾⣽⣻⢿⡿⣟⣯⣷\n' +printf 'Pwr:               \n' +printf '\e[30;44m \e[34;42m\e[30m main \e[32;40m\e[0m\n' +printf 'Nerd:        \U000f0219   \n' +printf 'CJK: 日本語のテキスト 中文字符 한국어 full ABC\n' +printf 'Emoji: 😀 🎉 🚀 ❤️ 👍🏽 👨‍👩‍👧 🇩🇪 ✅ ⭐ ☀\n' +printf 'Wide: |日|本|a|語|\n' +printf 'Math: ∑ ∫ ∂ √ ∞ ≠ ≤ ≥ ← → ↑ ↓ ⇒ λ π Ω αβγ \n' +printf 'Curs: ' +printf '\e[1 q\e[?25hX' +printf '\e[?25l\e[0 q' +printf '\n' +printf '\e[7mReverse\e[27m \e[38;5;240;48;5;238mdim on dim\e[0m \e[31;41mred on red\e[0m\n' +printf '\e[5;1H' +printf '\e[24;1H' +printf '\e[1 q\e[?25h' +exec sleep 100000 diff --git a/scripts/visual-parity/zoom.py b/scripts/visual-parity/zoom.py new file mode 100644 index 0000000..7df63a5 --- /dev/null +++ b/scripts/visual-parity/zoom.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""zoom.py DIR x,y,w,h scale OUT : side-by-side ref | rust | diff crop, upscaled (nearest).""" +import sys +from PIL import Image +d, box, sc, out = sys.argv[1], [int(v) for v in sys.argv[2].split(",")], int(sys.argv[3]), sys.argv[4] +x, y, w, h = box +ims = [Image.open(f"{d}/{n}.png").convert("RGB").crop((x, y, x + w, y + h)).resize((w * sc, h * sc), Image.NEAREST) for n in ("ref", "rust", "diff")] +o = Image.new("RGB", (w * sc, h * sc * 3 + 8), (255, 0, 255)) +for i, im in enumerate(ims): + o.paste(im, (0, i * (h * sc + 4))) +o.save(out)