Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion lib/renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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
});
});
});
82 changes: 81 additions & 1 deletion lib/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ export const DEFAULT_THEME: Required<ITheme> = {
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
// ============================================================================
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down