From 9c4e1e9d92e6743cf5a943155995f58bb8d005e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Wed, 3 Jun 2026 02:34:42 +0000 Subject: [PATCH] fix(renderer): paint reverse-video cell background with default colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reverse-video cell (SGR 7) whose foreground is the default color rendered with no background fill. `renderCellBackground` treated `fgIsDefault` as "use theme background" and skipped the paint, so the cell showed the dark theme background instead of the inverted (theme.foreground) block. `renderCellText` had the mirror-image bug, falling back to theme.foreground instead of the swapped theme.background. This is exactly how reverse-video TUIs — and program-drawn block cursors like Claude Code's, which hides the hardware cursor (DECTCEM) and draws its own inverted cell — render their cursor. The result was an empty/dark cursor block in ghostty-web where native ghostty shows a solid block. Inverse cells now always paint their background (theme.foreground when the source foreground is default); inverse text falls back to theme.background. Adds an e2e regression test: a default-color reverse-video run must paint a solid light background. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/renderer.ts | 22 +++++++++++++++++++--- tests/e2e/01-rendering.spec.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/lib/renderer.ts b/lib/renderer.ts index 9a68789..d920f05 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -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); } @@ -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 diff --git a/tests/e2e/01-rendering.spec.ts b/tests/e2e/01-rendering.spec.ts index 60d0d91..d5726bd 100644 --- a/tests/e2e/01-rendering.spec.ts +++ b/tests/e2e/01-rendering.spec.ts @@ -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);