From 2cabca5df5d76106cd09744b678962f609bc5707 Mon Sep 17 00:00:00 2001 From: David Brochart Date: Tue, 3 Nov 2026 09:08:18 +0100 Subject: [PATCH] fix: render block elements on physical pixel boundaries --- lib/block-elements.test.ts | 115 +++++++++++++++++++++++++++++++++++++ lib/renderer.ts | 50 +++++++++++++++- 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 lib/block-elements.test.ts diff --git a/lib/block-elements.test.ts b/lib/block-elements.test.ts new file mode 100644 index 00000000..a494c975 --- /dev/null +++ b/lib/block-elements.test.ts @@ -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([]); + }); +}); diff --git a/lib/renderer.ts b/lib/renderer.ts index 3b51bfdd..b35bcc82 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -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) { @@ -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 */