diff --git a/lib/renderer.test.ts b/lib/renderer.test.ts index 73b620b6..502cc21a 100644 --- a/lib/renderer.test.ts +++ b/lib/renderer.test.ts @@ -7,7 +7,8 @@ */ import { describe, expect, test } from 'bun:test'; -import { DEFAULT_THEME } from './renderer'; +import { CanvasRenderer, DEFAULT_THEME } from './renderer'; +import { CellFlags, type GhosttyCell } from './types'; describe('CanvasRenderer', () => { describe('Default Theme', () => { @@ -61,4 +62,78 @@ describe('CanvasRenderer', () => { expect(DEFAULT_THEME.cursor).toMatch(hexPattern); }); }); + describe('Background pixel alignment', () => { + test.each([1, 1.1, 1.25, 1.5, 2])('tiles physical pixels at scale %s', (scale) => { + const renderer = new CanvasRenderer(document.createElement('canvas'), { + devicePixelRatio: scale, + }); + const internal = renderer as any; + internal.metrics = { width: 9, height: 17, baseline: 14 }; + const fills: number[][] = []; + internal.ctx.fillRect = (...rect: number[]) => fills.push(rect); + const cell: GhosttyCell = { + codepoint: 32, + fg_r: 200, + fg_g: 200, + fg_b: 200, + bg_r: 34, + bg_g: 36, + bg_b: 54, + width: 1, + flags: 0, + hyperlink_id: 0, + grapheme_len: 0, + }; + + // Include ordinary, selected, inverse, and wide cells in adjacent rows. + for (let y = 0; y < 3; y++) { + for (let x = 0; x < 4; x++) { + internal.isInSelection = () => y === 1; + internal.renderCellBackground({ ...cell, flags: y === 2 ? CellFlags.INVERSE : 0 }, x, y); + } + internal.renderCellBackground({ ...cell, width: 2 }, 4, y); + } + const pixels = fills.map((rect) => rect.map((value) => value * scale)); + for (const rect of pixels) { + for (const value of rect) expect(value).toBeCloseTo(Math.round(value), 10); + } + for (let y = 0; y < 3; y++) { + const row = pixels.slice(y * 5, (y + 1) * 5); + expect(row[0][0]).toBe(0); + for (let x = 1; x < row.length; x++) { + expect(row[x - 1][0] + row[x - 1][2]).toBeCloseTo(row[x][0], 10); + } + expect(row[4][0] + row[4][2]).toBeCloseTo(Math.round(6 * 9 * scale), 10); + if (y > 0) { + expect(pixels[(y - 1) * 5][1] + pixels[(y - 1) * 5][3]).toBeCloseTo(row[0][1], 10); + } + } + }); + + test('line clearing and cell backgrounds share the same physical bounds', () => { + const renderer = new CanvasRenderer(document.createElement('canvas'), { + devicePixelRatio: 1.25, + }); + const internal = renderer as any; + internal.metrics = { width: 9, height: 17, baseline: 14 }; + const clears: number[][] = []; + const fills: number[][] = []; + internal.ctx.clearRect = (...rect: number[]) => clears.push(rect); + internal.ctx.fillRect = (...rect: number[]) => fills.push(rect); + internal.renderLine([], 1, 6); + expect(clears).toEqual([[0, 16.8, 54.4, 17.6]]); + expect(fills).toEqual(clears); + }); + + test('canvas backing dimensions use rounded physical bounds', () => { + const canvas = document.createElement('canvas'); + const renderer = new CanvasRenderer(canvas, { devicePixelRatio: 1.25 }); + (renderer as any).metrics = { width: 9, height: 17, baseline: 14 }; + renderer.resize(6, 3); + expect(canvas.width).toBe(68); + expect(canvas.height).toBe(64); + expect(canvas.style.width).toBe('54px'); + expect(canvas.style.height).toBe('51px'); + }); + }); }); diff --git a/lib/renderer.ts b/lib/renderer.ts index 3b51bfdd..2e55026e 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -242,8 +242,8 @@ export class CanvasRenderer { this.canvas.style.height = `${cssHeight}px`; // Set actual canvas size (scaled for DPI) - this.canvas.width = cssWidth * this.devicePixelRatio; - this.canvas.height = cssHeight * this.devicePixelRatio; + this.canvas.width = Math.round(cssWidth * this.devicePixelRatio); + this.canvas.height = Math.round(cssHeight * this.devicePixelRatio); // Scale context to match DPI (setting canvas.width/height resets the context) this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio); @@ -287,8 +287,8 @@ export class CanvasRenderer { // Resize canvas if dimensions changed const needsResize = - this.canvas.width !== dims.cols * this.metrics.width * this.devicePixelRatio || - this.canvas.height !== dims.rows * this.metrics.height * this.devicePixelRatio; + this.canvas.width !== Math.round(dims.cols * this.metrics.width * this.devicePixelRatio) || + this.canvas.height !== Math.round(dims.rows * this.metrics.height * this.devicePixelRatio); if (needsResize) { this.resize(dims.cols, dims.rows); @@ -514,17 +514,16 @@ export class CanvasRenderer { * cover any left-extending portions of graphemes from cell N-1. */ private renderLine(line: GhosttyCell[], y: number, cols: number): void { - const lineY = y * this.metrics.height; - const lineWidth = cols * this.metrics.width; + const bounds = this.getCellBounds(0, y, cols); // Clear line background then fill with theme color. // We clear just the cell area - glyph overflow is handled by also // redrawing adjacent rows (see render() method). // clearRect is needed because fillRect composites rather than replaces, // so transparent/translucent backgrounds wouldn't clear previous content. - this.ctx.clearRect(0, lineY, lineWidth, this.metrics.height); + this.ctx.clearRect(bounds.x, bounds.y, bounds.width, bounds.height); this.ctx.fillStyle = this.theme.background; - this.ctx.fillRect(0, lineY, lineWidth, this.metrics.height); + this.ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height); // PASS 1: Draw all cell backgrounds first // This ensures all backgrounds are painted before any text, allowing text @@ -544,15 +543,28 @@ export class CanvasRenderer { } } + /** Snap shared cell edges to physical pixels to avoid antialiased background seams. */ + private getCellBounds(x: number, y: number, width: number) { + const scale = this.devicePixelRatio; + const left = Math.round(x * this.metrics.width * scale); + const right = Math.round((x + width) * this.metrics.width * scale); + const top = Math.round(y * this.metrics.height * scale); + const bottom = Math.round((y + 1) * this.metrics.height * scale); + return { + x: left / scale, + y: top / scale, + width: (right - left) / scale, + height: (bottom - top) / scale, + }; + } + /** * Render a cell's background only (Pass 1 of two-pass rendering) * Selection highlighting is integrated here to avoid z-order issues with * complex glyphs (like Devanagari) that extend outside their cell bounds. */ private renderCellBackground(cell: GhosttyCell, x: number, y: number): void { - const cellX = x * this.metrics.width; - const cellY = y * this.metrics.height; - const cellWidth = this.metrics.width * cell.width; + const bounds = this.getCellBounds(x, y, cell.width); // Check if this cell is selected const isSelected = this.isInSelection(x, y); @@ -560,7 +572,7 @@ export class CanvasRenderer { if (isSelected) { // Draw selection background (solid color, not overlay) this.ctx.fillStyle = this.theme.selectionBackground; - this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height); + this.ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height); return; // Selection background replaces cell background } @@ -581,7 +593,7 @@ export class CanvasRenderer { const isDefaultBg = bg_r === 0 && bg_g === 0 && bg_b === 0; if (!isDefaultBg) { this.ctx.fillStyle = this.rgbToCSS(bg_r, bg_g, bg_b); - this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height); + this.ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height); } }