From a32d594d158dc3317ce94a3d4be59baf80675d85 Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Sun, 12 Jul 2026 01:09:21 +0800 Subject: [PATCH] fix: draw Powerline separators as vector geometry and clamp oversized glyphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rendering fixes for prompt "smudge" artifacts in the canvas renderer: 1. Powerline separators (U+E0B0-U+E0B7) are now drawn as exact vector paths filling the cell box instead of font glyphs. Font rasterization leaves anti-aliasing fringe on the diagonals and sub-cell ink height, so the background bleeds through above/below the glyph — with powerline-heavy prompts (starship segments) this reads as smudged, banded segment edges. WezTerm and xterm.js (customGlyphs) special-case these codepoints for the same reason. 2. fillText now passes the cell's allocated width as maxWidth. Glyphs served by fallback fonts (Nerd icons, emoji, symbols) often carry an advance wider than the cell measured from the primary font; unclamped ink bleeds into neighbor cells, where later partial repaints (cursor row) shave it into ragged artifacts since rows repaint by background fill without clearRect. maxWidth compresses the advance to fit, the way a Mono-patched font would. Verified pixel-level against WezTerm rendering the same starship prompt (fading-arrow theme, rounded caps, Nerd icons) on a 2x display. Co-Authored-By: Claude Fable 5 --- lib/renderer.test.ts | 23 ++++++++++++- lib/renderer.ts | 82 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/lib/renderer.test.ts b/lib/renderer.test.ts index 73b620b6..fa14a259 100644 --- a/lib/renderer.test.ts +++ b/lib/renderer.test.ts @@ -7,7 +7,7 @@ */ import { describe, expect, test } from 'bun:test'; -import { DEFAULT_THEME } from './renderer'; +import { DEFAULT_THEME, isPowerlineSeparator } from './renderer'; describe('CanvasRenderer', () => { describe('Default Theme', () => { @@ -61,4 +61,25 @@ describe('CanvasRenderer', () => { expect(DEFAULT_THEME.cursor).toMatch(hexPattern); }); }); + + describe('Powerline Separator Detection', () => { + test('covers exactly U+E0B0 through U+E0B7', () => { + expect(isPowerlineSeparator(0xe0b0)).toBe(true); // solid right triangle + expect(isPowerlineSeparator(0xe0b1)).toBe(true); // right angle outline + expect(isPowerlineSeparator(0xe0b2)).toBe(true); // solid left triangle + expect(isPowerlineSeparator(0xe0b3)).toBe(true); // left angle outline + expect(isPowerlineSeparator(0xe0b4)).toBe(true); // solid right half-circle + expect(isPowerlineSeparator(0xe0b5)).toBe(true); // right half-circle outline + expect(isPowerlineSeparator(0xe0b6)).toBe(true); // solid left half-circle + expect(isPowerlineSeparator(0xe0b7)).toBe(true); // left half-circle outline + }); + + test('does not match neighbors or ordinary text', () => { + expect(isPowerlineSeparator(0xe0af)).toBe(false); + expect(isPowerlineSeparator(0xe0b8)).toBe(false); // extended set, still font-rendered + expect(isPowerlineSeparator(0xe0a0)).toBe(false); // branch glyph (has real ink, not geometry) + expect(isPowerlineSeparator(0x0041)).toBe(false); // 'A' + expect(isPowerlineSeparator(0)).toBe(false); // null cell + }); + }); }); diff --git a/lib/renderer.ts b/lib/renderer.ts index 3b51bfdd..9fd6af52 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -87,6 +87,18 @@ export const DEFAULT_THEME: Required = { brightWhite: '#ffffff', }; +/** + * Powerline separator codepoints (U+E0B0-U+E0B7: solid/outline triangles and + * half-circles). These are rendered as exact vector geometry instead of font + * glyphs — font rasterization leaves anti-aliasing fringe on the diagonals and + * sub-cell ink height (the background bleeds through above/below the glyph), + * which reads as smudging next to solid prompt-segment backgrounds. WezTerm + * and xterm.js (customGlyphs) special-case these codepoints the same way. + */ +export function isPowerlineSeparator(codepoint: number): boolean { + return codepoint >= 0xe0b0 && codepoint <= 0xe0b7; +} + // ============================================================================ // CanvasRenderer Class // ============================================================================ @@ -585,6 +597,63 @@ export class CanvasRenderer { } } + /** + * Draw a Powerline separator (U+E0B0-U+E0B7) as vector geometry covering + * exactly the cell box. Even codepoints are the solid variants, odd ones the + * thin outline variants. Uses the already-set fillStyle, so colors follow + * the cell's foreground exactly like a font glyph would. + */ + private drawPowerlineGlyph( + codepoint: number, + cellX: number, + cellY: number, + cellWidth: number, + cellHeight: number + ): void { + const ctx = this.ctx; + const midY = cellY + cellHeight / 2; + ctx.beginPath(); + switch (codepoint) { + case 0xe0b0: // solid right-pointing triangle + case 0xe0b1: // right-pointing angle outline + ctx.moveTo(cellX, cellY); + ctx.lineTo(cellX + cellWidth, midY); + ctx.lineTo(cellX, cellY + cellHeight); + break; + case 0xe0b2: // solid left-pointing triangle + case 0xe0b3: // left-pointing angle outline + ctx.moveTo(cellX + cellWidth, cellY); + ctx.lineTo(cellX, midY); + ctx.lineTo(cellX + cellWidth, cellY + cellHeight); + break; + case 0xe0b4: // solid right half-circle + case 0xe0b5: // right half-circle outline + ctx.moveTo(cellX, cellY); + ctx.ellipse(cellX, midY, cellWidth, cellHeight / 2, 0, -Math.PI / 2, Math.PI / 2); + break; + default: // 0xe0b6 solid left half-circle, 0xe0b7 outline + ctx.moveTo(cellX + cellWidth, cellY + cellHeight); + ctx.ellipse( + cellX + cellWidth, + midY, + cellWidth, + cellHeight / 2, + 0, + Math.PI / 2, + Math.PI * 1.5 + ); + break; + } + if (codepoint % 2 === 1) { + ctx.strokeStyle = this.ctx.fillStyle; + ctx.lineWidth = 1; + ctx.stroke(); + } else { + ctx.closePath(); + ctx.fill(); + } + } + /** * Render a cell's text and decorations (Pass 2 of two-pass rendering) * Selection foreground color is applied here to match the selection background. @@ -647,7 +716,18 @@ export class CanvasRenderer { // Simple cell - single codepoint char = String.fromCodePoint(cell.codepoint || 32); // Default to space if null } - this.ctx.fillText(char, textX, textY); + if (cell.grapheme_len === 0 && isPowerlineSeparator(cell.codepoint || 0)) { + // Powerline separators as vector geometry filling the cell box exactly + // (see isPowerlineSeparator for why fonts can't render these cleanly). + this.drawPowerlineGlyph(cell.codepoint || 0, cellX, cellY, cellWidth, this.metrics.height); + } else { + // Clamp to the cell's allocated width: fallback-font glyphs (Nerd icons, + // emoji, symbols) often carry an advance wider than the cell measured + // from the primary font, and unclamped ink bleeds into neighbor cells + // where later partial repaints shave it into ragged artifacts. maxWidth + // compresses the advance to fit, like a Mono-patched font would. + this.ctx.fillText(char, textX, textY, cellWidth); + } // Reset alpha if (cell.flags & CellFlags.FAINT) {