Skip to content
Merged
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
22 changes: 19 additions & 3 deletions lib/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -782,8 +782,18 @@ export class CanvasRenderer {
// we cannot infer it from the RGB triple because (0,0,0) is a valid
// explicit color (programs emit it for "true black" backgrounds, e.g.
// letterboxed image renderings).
const useThemeBg = cell.flags & CellFlags.INVERSE ? cell.fgIsDefault : cell.bgIsDefault;
if (!useThemeBg) {
if (cell.flags & CellFlags.INVERSE) {
// Inverse: the cell's background is its foreground color. A *default*
// foreground resolves to theme.foreground — which is NOT the line-level
// theme.background fill — so it must be painted explicitly. The old code
// treated `fgIsDefault` as "use theme background" and skipped the fill,
// so reverse-video cells (and program-drawn block cursors, e.g. Claude
// Code's) rendered the dark background instead of a solid block.
this.ctx.fillStyle = cell.fgIsDefault
? this.theme.foreground
: this.rgbToCSS(bg_r, bg_g, bg_b);
this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height);
} else if (!cell.bgIsDefault) {
this.ctx.fillStyle = this.rgbToCSS(bg_r, bg_g, bg_b);
this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height);
}
Expand Down Expand Up @@ -844,7 +854,13 @@ export class CanvasRenderer {
// when the cell has the default fg (tag NONE), not when its explicit
// RGB happens to be (0,0,0).
const useThemeFg = cell.flags & CellFlags.INVERSE ? cell.bgIsDefault : cell.fgIsDefault;
this.ctx.fillStyle = useThemeFg ? this.theme.foreground : this.rgbToCSS(fg_r, fg_g, fg_b);
// For an inverse cell the text takes the cell's *background* color; a
// default background resolves to theme.background (the swapped theme
// color), not theme.foreground — so the glyph stays legible against the
// inverted (theme.foreground) block painted in renderCellBackground.
const themeColor =
cell.flags & CellFlags.INVERSE ? this.theme.background : this.theme.foreground;
this.ctx.fillStyle = useThemeFg ? themeColor : this.rgbToCSS(fg_r, fg_g, fg_b);
}

// Apply faint effect
Expand Down
30 changes: 30 additions & 0 deletions tests/e2e/01-rendering.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,36 @@ test.describe('Rendering', () => {
expect(await hasRenderedContent(page)).toBe(true);
});

// Regression: a reverse-video cell with DEFAULT colors must paint its
// background with theme.foreground (a solid light block). This is how
// reverse-video TUIs — and program-drawn block cursors like Claude Code's —
// render their cursor. A prior bug treated `fgIsDefault` as "use theme
// background" and skipped the fill, leaving the block dark/empty.
test('reverse-video cell with default colors paints a solid (light) background', async ({
page,
}) => {
// 10 reverse-video spaces at row 0: no glyphs, so the sampled pixels are
// purely the inverted cell background.
await termWrite(page, '\x1b[7m \x1b[0m');
const avgBrightness = await page.evaluate(() => {
const canvas = document.querySelector('#terminal-container canvas') as HTMLCanvasElement;
const ctx = canvas.getContext('2d')!;
// Sample a band inside the first cells, well within the reverse run and
// before the trailing cursor.
const { data } = ctx.getImageData(2, 2, 40, 10);
let sum = 0;
let n = 0;
for (let i = 0; i < data.length; i += 4) {
sum += (data[i] + data[i + 1] + data[i + 2]) / 3;
n++;
}
return sum / n;
});
// Default theme: background ≈ 30, foreground ≈ 212. A painted inverse
// background reads bright; the pre-fix bug left it at the dark background.
expect(avgBrightness).toBeGreaterThan(120);
});

test('plain text appears in buffer', async ({ page }) => {
await termWrite(page, 'Hello World');
const line = await getLine(page, 0);
Expand Down
Loading