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
115 changes: 115 additions & 0 deletions lib/block-elements.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, test } from 'bun:test';
import { CanvasRenderer } from './renderer';
import { CellFlags, type GhosttyCell } from './types';

const block: GhosttyCell = {
codepoint: 0x2588,
fg_r: 122,
fg_g: 162,
fg_b: 247,
bg_r: 34,
bg_g: 36,
bg_b: 54,
flags: 0,
width: 1,
hyperlink_id: 0,
grapheme_len: 0,
};

function setup(scale = 1) {
const renderer = new CanvasRenderer(document.createElement('canvas'), {
devicePixelRatio: scale,
}) as any;
renderer.metrics = { width: 16, height: 16, baseline: 13 };
const fills: { rect: number[]; color: string; alpha: number }[] = [];
const texts: string[] = [];
renderer.ctx.fillRect = (...rect: number[]) => {
fills.push({ rect, color: renderer.ctx.fillStyle, alpha: renderer.ctx.globalAlpha });
};
renderer.ctx.fillText = (text: string) => texts.push(text);
return { renderer, fills, texts };
}

describe('Unicode block elements', () => {
test.each([
[0x2580, 0, 0, 16, 8],
[0x2581, 0, 14, 16, 2],
[0x2584, 0, 8, 16, 8],
[0x2588, 0, 0, 16, 16],
[0x2589, 0, 0, 14, 16],
[0x258c, 0, 0, 8, 16],
[0x258f, 0, 0, 2, 16],
[0x2590, 8, 0, 8, 16],
[0x2594, 0, 0, 16, 2],
[0x2595, 14, 0, 2, 16],
])('draws block U+%s with the expected bounds', (codepoint, ...rect) => {
const { renderer, fills, texts } = setup();
renderer.renderCellText({ ...block, codepoint }, 0, 0);
expect(fills.map((fill) => fill.rect)).toEqual([rect]);
expect(texts).toEqual([]);
});

test.each([
[0x2596, [2]],
[0x2597, [3]],
[0x2598, [0]],
[0x2599, [0, 2, 3]],
[0x259a, [0, 3]],
[0x259b, [0, 1, 2]],
[0x259c, [0, 1, 3]],
[0x259d, [1]],
[0x259e, [1, 2]],
[0x259f, [1, 2, 3]],
] as [number, number[]][])('draws the correct quadrants for U+%s', (codepoint, quadrants) => {
const { renderer, fills } = setup();
renderer.renderCellText({ ...block, codepoint }, 0, 0);
expect(fills.map((fill) => fill.rect)).toEqual(
quadrants.map((quadrant) => [(quadrant % 2) * 8, Math.floor(quadrant / 2) * 8, 8, 8])
);
});

test.each([1, 1.1, 1.25, 1.5, 2])('tiles full blocks at scale %s', (scale) => {
const { renderer, fills } = setup(scale);
renderer.metrics = { width: 9, height: 17, baseline: 14 };
for (let y = 0; y < 2; y++) {
for (let x = 0; x < 4; x++) renderer.renderCellText(block, x, y);
}
const physical = fills.map(({ rect }) => rect.map((value) => value * scale));
for (const rect of physical) {
for (const value of rect) expect(value).toBeCloseTo(Math.round(value), 10);
}
for (let y = 0; y < 2; y++) {
for (let x = 1; x < 4; x++) {
const previous = physical[y * 4 + x - 1];
expect(previous[0] + previous[2]).toBeCloseTo(physical[y * 4 + x][0], 10);
}
}
expect(physical[0][1] + physical[0][3]).toBeCloseTo(physical[4][1], 10);
});

test('preserves selected, inverse, faint and cursor foreground colors', () => {
const { renderer, fills } = setup();
renderer.renderCellText({ ...block, flags: CellFlags.INVERSE }, 0, 0);
expect(fills[0].color).toBe('rgb(34, 36, 54)');
renderer.renderCellText({ ...block, flags: CellFlags.FAINT }, 1, 0);
expect(fills[1].alpha).toBe(0.5);
expect(renderer.ctx.globalAlpha).toBe(1);
renderer.isInSelection = () => true;
renderer.renderCellText(block, 2, 0);
expect(fills[2].color).toBe(renderer.theme.selectionForeground);
renderer.renderCellText(block, 3, 0, '#123456');
expect(fills[3].color).toBe('#123456');
});

test('keeps ordinary text, shades, box drawing and graphemes on the font path', () => {
const { renderer, fills, texts } = setup();
for (const codepoint of [0x41, 0x2591, 0x2592, 0x2593, 0x2551]) {
renderer.renderCellText({ ...block, codepoint }, 0, 0);
}
renderer.currentBuffer = { getGraphemeString: () => '█\u0301' };
renderer.renderCellText({ ...block, grapheme_len: 1 }, 0, 0);
expect(texts).toEqual(['A', '░', '▒', '▓', '║', '█\u0301']);
renderer.renderCellText({ ...block, flags: CellFlags.INVISIBLE }, 0, 0);
expect(fills).toEqual([]);
});
});
50 changes: 49 additions & 1 deletion lib/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,11 @@ export class CanvasRenderer {
// Simple cell - single codepoint
char = String.fromCodePoint(cell.codepoint || 32); // Default to space if null
}
this.ctx.fillText(char, textX, textY);
// Font glyphs can leave gaps between adjacent block elements. Draw their
// geometry directly, but preserve the font path for grapheme clusters.
if (cell.grapheme_len > 0 || !this.renderBlockElement(cell, x, y)) {
this.ctx.fillText(char, textX, textY);
}

// Reset alpha
if (cell.flags & CellFlags.FAINT) {
Expand Down Expand Up @@ -713,6 +717,50 @@ export class CanvasRenderer {
}
}

/** Render solid Unicode block elements on shared physical-pixel edges. */
private renderBlockElement(cell: GhosttyCell, x: number, y: number): boolean {
const code = cell.codepoint;
// Coordinates are expressed in eighths of a terminal cell.
let rectangles: number[][];
if (code === 0x2580) {
rectangles = [[0, 0, 8, 4]]; // Upper half
} else if (code >= 0x2581 && code <= 0x2588) {
rectangles = [[0, 8 - (code - 0x2580), 8, 8]]; // Lower eighths through full block
} else if (code >= 0x2589 && code <= 0x258f) {
rectangles = [[0, 0, 0x2590 - code, 8]]; // Left seven eighths through one eighth
} else if (code === 0x2590) {
rectangles = [[4, 0, 8, 8]]; // Right half
} else if (code === 0x2594) {
rectangles = [[0, 0, 8, 1]]; // Upper eighth
} else if (code === 0x2595) {
rectangles = [[7, 0, 8, 8]]; // Right eighth
} else if (code >= 0x2596 && code <= 0x259f) {
// Bits represent upper-left, upper-right, lower-left, lower-right.
const mask = [4, 8, 1, 13, 9, 7, 11, 2, 6, 14][code - 0x2596];
rectangles = [];
for (let quadrant = 0; quadrant < 4; quadrant++) {
if (mask & (1 << quadrant)) {
const left = (quadrant % 2) * 4;
const top = Math.floor(quadrant / 2) * 4;
rectangles.push([left, top, left + 4, top + 4]);
}
}
} else {
// Shades, box drawing, and ordinary text retain font rendering.
return false;
}

const scale = this.devicePixelRatio;
for (const [left, top, right, bottom] of rectangles) {
const x1 = Math.round((x + (left / 8) * cell.width) * this.metrics.width * scale);
const x2 = Math.round((x + (right / 8) * cell.width) * this.metrics.width * scale);
const y1 = Math.round((y + top / 8) * this.metrics.height * scale);
const y2 = Math.round((y + bottom / 8) * this.metrics.height * scale);
this.ctx.fillRect(x1 / scale, y1 / scale, (x2 - x1) / scale, (y2 - y1) / scale);
}
return true;
}

/**
* Render cursor
*/
Expand Down