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
77 changes: 76 additions & 1 deletion lib/renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
});
38 changes: 25 additions & 13 deletions lib/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -544,23 +543,36 @@ 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);

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
}

Expand All @@ -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);
}
}

Expand Down