diff --git a/apps/web/src/terminal/ghostty/renderer.test.ts b/apps/web/src/terminal/ghostty/renderer.test.ts index 5f5c41c8fecb..9f538a3e5f36 100644 --- a/apps/web/src/terminal/ghostty/renderer.test.ts +++ b/apps/web/src/terminal/ghostty/renderer.test.ts @@ -226,8 +226,74 @@ describe("renderGhosttySnapshot", () => { }); // The cursor row still repaints so the block disappears, but the inverted - // glyph the on phase draws over the cell is gone. - expect(fillTextCalls).toEqual([["abx", 4, 15, 21.6]]); + // glyph the on phase draws over the cell is gone. The blink-off path also + // redraws the cursor cell's own glyph after clearing it, so the cell text + // appears twice: once as part of the row and once as the per-cell redraw. + expect(fillTextCalls).toEqual([ + ["abx", 4, 15, 21.6], + ["x", 18.4, 15, 7.2], + ]); + }); + + it("clears the full cursor cell and redraws text during blink off phase", () => { + const fillRectCalls: number[][] = []; + const fillTextCalls: unknown[][] = []; + const context = { + canvas: { width: 200, height: 40 }, + beginPath: () => {}, + clip: () => {}, + fillRect: (...args: number[]) => fillRectCalls.push(args), + fillText: (...args: unknown[]) => fillTextCalls.push(args), + rect: () => {}, + resetTransform: () => {}, + restore: () => {}, + save: () => {}, + set fillStyle(_value: string) {}, + set font(_value: string) {}, + set textBaseline(_value: string) {}, + } as unknown as CanvasRenderingContext2D; + const snapshot: GhosttySnapshot = { + cols: 3, + rows: 1, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + cursorX: 2, + cursorY: 0, + cursorVisible: true, + cursorBlinking: true, + cursorStyle: 0, + dirtyRows: new Set(), + rowData: [ + { + cells: [cell("a"), cell("b"), cell("x")], + text: "abx", + isWrapContinuation: false, + wrapsToNext: false, + }, + ], + }; + + renderGhosttySnapshot({ + context, + snapshot, + metrics: { width: 7.2, height: 16, baseline: 11 }, + fontSize: 12, + fontFamily: "monospace", + padding: 4, + forceFull: false, + cursorOn: false, + }); + + // The cursor cell must be explicitly cleared with a full-width rect to + // erase bar/underline/stroke edge remnants, not just rely on the row + // background fill which may leave subpixel artifacts at cell boundaries. + const cursorCellClear = fillRectCalls.find( + ([x, , w]) => Math.abs(x - (4 + 2 * 7.2)) < 0.01 && Math.abs(w - 7.2) < 0.01, + ); + expect(cursorCellClear).toBeDefined(); + // The glyph under the cursor must be redrawn so it remains visible. + expect(fillTextCalls.some(([text]) => text === "x")).toBe(true); }); it("repaints the previous cursor row after the cursor moves", () => { diff --git a/apps/web/src/terminal/ghostty/renderer.ts b/apps/web/src/terminal/ghostty/renderer.ts index 9d47718464ea..71b0e937d53a 100644 --- a/apps/web/src/terminal/ghostty/renderer.ts +++ b/apps/web/src/terminal/ghostty/renderer.ts @@ -244,27 +244,43 @@ export function renderGhosttySnapshot(options: { } } - if (cursorOn && snapshot.cursorVisible && snapshot.cursorX >= 0 && snapshot.cursorY >= 0) { + if (snapshot.cursorVisible && snapshot.cursorX >= 0 && snapshot.cursorY >= 0) { const left = padding + snapshot.cursorX * metrics.width; const top = originY + snapshot.cursorY * metrics.height; - context.fillStyle = cssColor(snapshot.cursor); - if (!focused) { - // An unfocused terminal draws a hollow cursor so the active pane is obvious. - context.strokeStyle = cssColor(snapshot.cursor); - context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); - } else if (snapshot.cursorStyle === 0) { - context.fillRect(left, top, 2, metrics.height); - } else if (snapshot.cursorStyle === 2) { - context.fillRect(left, top + metrics.height - 2, metrics.width, 2); - } else if (snapshot.cursorStyle === 3) { - context.strokeStyle = cssColor(snapshot.cursor); - context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); + if (cursorOn) { + context.fillStyle = cssColor(snapshot.cursor); + if (!focused) { + // An unfocused terminal draws a hollow cursor so the active pane is obvious. + context.strokeStyle = cssColor(snapshot.cursor); + context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); + } else if (snapshot.cursorStyle === 0) { + context.fillRect(left, top, 2, metrics.height); + } else if (snapshot.cursorStyle === 2) { + context.fillRect(left, top + metrics.height - 2, metrics.width, 2); + } else if (snapshot.cursorStyle === 3) { + context.strokeStyle = cssColor(snapshot.cursor); + context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); + } else { + context.fillRect(left, top, metrics.width, metrics.height); + const cell = snapshot.rowData[snapshot.cursorY]?.cells[snapshot.cursorX]; + if (cell?.text) { + context.font = fontForCell(cell, fontSize, fontFamily); + context.fillStyle = cssColor(snapshot.background); + context.fillText(cell.text, left, top + metrics.baseline, metrics.width); + } + } } else { + // During the blink off phase, explicitly clear the full cursor cell to + // erase any subpixel edge remnants left by bar, underline, or stroke + // cursors whose thin geometry may not be fully covered by the row + // background fill alone. + context.fillStyle = cssColor(snapshot.background); context.fillRect(left, top, metrics.width, metrics.height); + // Redraw the cell text so the glyph remains visible under the cleared cursor. const cell = snapshot.rowData[snapshot.cursorY]?.cells[snapshot.cursorX]; - if (cell?.text) { + if (cell && !cell.invisible && cell.text.length > 0) { context.font = fontForCell(cell, fontSize, fontFamily); - context.fillStyle = cssColor(snapshot.background); + context.fillStyle = cssColor(cell.foreground); context.fillText(cell.text, left, top + metrics.baseline, metrics.width); } }