Skip to content

feat(renderer): powerline glyph rendering + fold-in of PR #128#24

Open
sauyon wants to merge 3 commits into
mainfrom
sauyon/vibrant-bose-09c67e
Open

feat(renderer): powerline glyph rendering + fold-in of PR #128#24
sauyon wants to merge 3 commits into
mainfrom
sauyon/vibrant-bose-09c67e

Conversation

@sauyon
Copy link
Copy Markdown
Owner

@sauyon 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>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread demo/bin/render-test.ts
Comment on lines +257 to +280
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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/pngjs

Then, 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;
}

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread demo/bin/render-test.ts
Comment on lines +257 to +280
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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;
  }
}

sauyon and others added 2 commits May 18, 2026 13:35
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant