feat(renderer): powerline glyph rendering + fold-in of PR #128#24
feat(renderer): powerline glyph rendering + fold-in of PR #128#24sauyon wants to merge 3 commits into
Conversation
sauyon
commented
May 18, 2026
- lib/powerline.ts: port Ghostty's src/font/sprite/draw/powerline.zig for U+E0B0..U+E0BF and U+E0D2/U+E0D4 (18 glyphs total). Drawn as Canvas2D paths sized to the cell; structurally mirrors box-drawing.ts. Diagonals (E0B9/BB/BD/BF) delegate to drawBoxOrBlock for clean tiling against U+2571/U+2572. Includes lib/powerline.test.ts with 35 structural + coverage tests.
- lib/renderer.ts: wire powerline dispatch into the cell text path. Add optional style? to IRenderable.getCursor() so the buffer's reported cursor style (from CSI q / DECSCUSR via WASM) wins over the renderer-level default, matching Ghostty native's precedence rule (renderer/cursor.zig:36-68). cursorStyle option now documents itself as a fallback for non-WASM IRenderable implementations.
- demo/render-test.html, demo/bin/render-test.ts, demo/baselines/: headless visual-regression suite folded as-is from PR feat: powerline and block character rendering with visual tests coder/ghostty-web#128.
- package.json: test:render{,:update,:web} scripts + puppeteer devDependency.
- .gitignore: ignore demo/baselines/*.fail.png artifacts.
- lib/powerline.ts: port Ghostty's src/font/sprite/draw/powerline.zig for U+E0B0..U+E0BF and U+E0D2/U+E0D4 (18 glyphs total). Drawn as Canvas2D paths sized to the cell; structurally mirrors box-drawing.ts. Diagonals (E0B9/BB/BD/BF) delegate to drawBoxOrBlock for clean tiling against U+2571/U+2572. Includes lib/powerline.test.ts with 35 structural + coverage tests. - lib/renderer.ts: wire powerline dispatch into the cell text path. Add optional style? to IRenderable.getCursor() so the buffer's reported cursor style (from CSI q / DECSCUSR via WASM) wins over the renderer-level default, matching Ghostty native's precedence rule (renderer/cursor.zig:36-68). cursorStyle option now documents itself as a fallback for non-WASM IRenderable implementations. - demo/render-test.html, demo/bin/render-test.ts, demo/baselines/: headless visual-regression suite folded as-is from PR coder/ghostty-web#128. - package.json: test:render{,:update,:web} scripts + puppeteer devDependency. - .gitignore: ignore demo/baselines/*.fail.png artifacts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements a native Powerline glyph renderer using Canvas2D and introduces a visual regression testing suite powered by Puppeteer. It also updates the renderer to support per-cursor style overrides, facilitating better testing. Feedback recommends using a pixel-level comparison library like pixelmatch in the test runner to improve the reliability of visual checks against baseline images.
| function calculateDiffPercent(buf1: Buffer, buf2: Buffer): number { | ||
| // Simple approach: compare decoded pixel data | ||
| // For a more accurate comparison, use a library like pixelmatch | ||
|
|
||
| // Quick heuristic based on buffer size difference and content | ||
| const sizeDiff = Math.abs(buf1.length - buf2.length); | ||
| const maxSize = Math.max(buf1.length, buf2.length); | ||
|
|
||
| if (sizeDiff > 0) { | ||
| // Different sizes means different images | ||
| return (sizeDiff / maxSize) * 100; | ||
| } | ||
|
|
||
| // Compare bytes | ||
| let diffBytes = 0; | ||
| const minLen = Math.min(buf1.length, buf2.length); | ||
| for (let i = 0; i < minLen; i++) { | ||
| if (buf1[i] !== buf2[i]) { | ||
| diffBytes++; | ||
| } | ||
| } | ||
|
|
||
| return (diffBytes / maxSize) * 100; | ||
| } |
There was a problem hiding this comment.
The current image comparison logic is not robust as it compares raw, compressed PNG buffers. This can lead to flaky tests where visually identical images have different byte representations due to compression artifacts, or where subtle visual differences are missed.
For a more reliable visual regression test, I recommend using a pixel-level comparison library like pixelmatch.
You'll need to add pixelmatch and pngjs to your devDependencies:
bun add -d pixelmatch pngjs @types/pixelmatch @types/pngjsThen, you can import them at the top of the file:
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';And replace calculateDiffPercent with this more robust implementation:
function calculateDiffPercent(buf1: Buffer, buf2: Buffer): number {
const png1 = PNG.sync.read(buf1);
const png2 = PNG.sync.read(buf2);
if (png1.width !== png2.width || png1.height !== png2.height) {
// Images have different dimensions, which is a clear failure.
return 100;
}
const { width, height } = png1;
const numDiffPixels = pixelmatch(png1.data, png2.data, null, width, height, {
threshold: 0.1,
});
return (numDiffPixels / (width * height)) * 100;
}There was a problem hiding this comment.
Code Review
This pull request introduces a visual regression testing framework for the renderer, including a headless test runner using Puppeteer and a browser-based test page. It also adds a new Powerline glyph renderer to improve terminal rendering accuracy. The review comment correctly identifies that the current byte-for-byte image comparison logic is fragile and suggests a more robust pixel-based comparison using the existing fast-png dependency.
| function calculateDiffPercent(buf1: Buffer, buf2: Buffer): number { | ||
| // Simple approach: compare decoded pixel data | ||
| // For a more accurate comparison, use a library like pixelmatch | ||
|
|
||
| // Quick heuristic based on buffer size difference and content | ||
| const sizeDiff = Math.abs(buf1.length - buf2.length); | ||
| const maxSize = Math.max(buf1.length, buf2.length); | ||
|
|
||
| if (sizeDiff > 0) { | ||
| // Different sizes means different images | ||
| return (sizeDiff / maxSize) * 100; | ||
| } | ||
|
|
||
| // Compare bytes | ||
| let diffBytes = 0; | ||
| const minLen = Math.min(buf1.length, buf2.length); | ||
| for (let i = 0; i < minLen; i++) { | ||
| if (buf1[i] !== buf2[i]) { | ||
| diffBytes++; | ||
| } | ||
| } | ||
|
|
||
| return (diffBytes / maxSize) * 100; | ||
| } |
There was a problem hiding this comment.
The current image comparison logic in calculateDiffPercent is based on buffer length and byte-for-byte comparison. This can be fragile and lead to flaky tests for a few reasons:
- PNG metadata can change without any visual difference.
- Different PNG compression levels can produce different byte streams for the same visual image.
- A small change can lead to a large difference in buffer size, making the diff percentage not representative of the visual change.
A more robust approach would be to decode both PNG buffers into raw pixel data and then compare them pixel by pixel. This would make the tests resilient to non-visual changes in the PNG files.
You could use the fast-png library, which is already a dependency in this project, to decode the PNGs. You'll need to add 'import { decode } from 'fast-png';' at the top of the file.
function calculateDiffPercent(buf1: Buffer, buf2: Buffer): number {
try {
const img1 = decode(buf1);
const img2 = decode(buf2);
if (img1.width !== img2.width || img1.height !== img2.height) {
// Treat dimension mismatch as 100% difference.
return 100;
}
const data1 = img1.data;
const data2 = img2.data;
let diffPixels = 0;
const totalPixels = img1.width * img1.height;
// Compare RGBA values, allowing for minor anti-aliasing differences.
for (let i = 0; i < data1.length; i += 4) {
if (
Math.abs(data1[i] - data2[i]) > 2 || // R
Math.abs(data1[i + 1] - data2[i + 1]) > 2 || // G
Math.abs(data1[i + 2] - data2[i + 2]) > 2 || // B
Math.abs(data1[i + 3] - data2[i + 3]) > 2 // A
) {
diffPixels++;
}
}
return (diffPixels / totalPixels) * 100;
} catch (error) {
console.error('Error decoding PNG for diff:', error);
// Fallback to simple buffer comparison on decode error.
return buf1.equals(buf2) ? 0 : 100;
}
}Local gemini-review findings (off-PR):
- Extract Op/RecordingCtx/makeCtx into lib/canvas-recorder.ts; both
box-drawing.test.ts and powerline.test.ts now consume the shared
helper, dropping ~250 lines of duplicated mock-context scaffolding.
- Single `as unknown as` cast lives once inside makeRecordingCtx;
test files import a value already typed as CanvasRenderingContext2D
and pass it straight to draw functions, no per-test cast jump.
- Remove the closure allocated by withMirror() on every call to
fillStadium/innerStrokeStadium/fillBisector — replaced with an
inline `if (mirror) { save; ... }` guard around a no-allocation
mirrorAroundCellCenter helper that just does the translate/scale.
Gemini-bot inline review on PR #24:
- demo/bin/render-test.ts:280 calculateDiffPercent now decodes both
PNGs via fast-png (already a project dep) and compares RGBA
pixel-by-pixel with a ±2/channel tolerance for AA jitter, instead
of byte-comparing the compressed PNG buffers. Dimension mismatch
reports 100% diff. Fixes the flakiness the bot flagged: PNG
metadata or compression-level changes no longer count as visual
regressions.
All 69 lib tests still pass; typecheck, fmt, lint clean (the two
pre-existing biome warnings in url-detection.test.ts are unchanged).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups on the previous gemini-cleanup commit: - vite.config.js: add lib/canvas-recorder.ts to the vite-plugin-dts `exclude` list. The recorder isn't imported from lib/index.ts, so its JS gets tree-shaken by Rollup, but the dts plugin's exclude only filtered *.test.ts and was happily emitting RecordingOp / RecordingCanvas / makeRecordingCtx into the published .d.ts bundle. Now they stay internal. Updated the file's doc to spell out both protection mechanisms (entry-point tree-shaking for JS, explicit dts exclude for types). - demo/bin/render-test.ts: calculateDiffPercent now treats a channel-layout mismatch (RGB vs RGBA) as 100% diff, the same way it already treats a dimension mismatch. Previously it took `Math.min(a.channels, b.channels)`, which would silently ignore alpha for an RGB-vs-RGBA pair and falsely match an opaque baseline against a transparent current. Canvas2D's toDataURL always emits RGBA so this doesn't fire in practice, but the earlier comment claimed robustness the code didn't deliver. All 69 lib tests still pass; typecheck/fmt/lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>