# Files changed · PR \#21641 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/21641/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(web\): the terminal cursor no longer leaves faint lines when it blinks off](https://git.cafe/pingdotgg/t3code/pull/21641.md)

Version: 12

Head commit: d1cc00b55b7cc906be9b39ae2d63a6ce01a4c914

Base commit: 8d3b061121fe65669d5d43d011b48cd3027a4108

Comparison base: 8d3b061121fe65669d5d43d011b48cd3027a4108

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/src/terminal/ghostty/renderer.browser.test.ts

Status: added; +117; −0

```diff
@@ -1,0 +1,117 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import type { GhosttyCell, GhosttySnapshot } from "./core";
+import { renderGhosttySnapshot } from "./renderer";
+
+const background = { r: 255, g: 255, b: 255 };
+
+const blank: GhosttyCell = {
+  text: "",
+  wide: 0,
+  foreground: { r: 0, g: 0, b: 0 },
+  background,
+  bold: false,
+  italic: false,
+  invisible: false,
+  strikethrough: false,
+  overline: false,
+  underline: false,
+  selected: false,
+};
+
+/** Counts device pixels that are not the terminal background. */
+function pixelsOffBackground(context: CanvasRenderingContext2D): number {
+  const { data } = context.getImageData(0, 0, context.canvas.width, context.canvas.height);
+  let count = 0;
+  for (let index = 0; index < data.length; index += 4) {
+    if (data[index] !== 255 || data[index + 1] !== 255 || data[index + 2] !== 255) count += 1;
+  }
+  return count;
+}
+
+/** A 40 by 60 CSS pixel canvas drawn at the given device pixel ratio. */
+function scaledContext(scale: number): CanvasRenderingContext2D {
+  const canvas = document.createElement("canvas");
+  canvas.width = Math.round(40 * scale);
+  canvas.height = Math.round(60 * scale);
+  const context = canvas.getContext("2d");
+  if (!context) throw new Error("2D canvas unavailable");
+  context.setTransform(scale, 0, 0, scale, 0, 0);
+  return context;
+}
+
+const layout = {
+  metrics: { width: 8, height: 17, baseline: 13 },
+  fontSize: 13,
+  fontFamily: "monospace",
+  padding: 4,
+  originY: 4,
+};
+
+describe.each([1.25, 2.2])("renderGhosttySnapshot at a %sx device pixel ratio", (scale) => {
+  it.each([
+    ["bar", 0],
+    ["block", 1],
+    ["underline", 2],
+    ["hollow block", 3],
+  ])("erases a %s cursor completely when it blinks off", (_name, cursorStyle) => {
+    const context = scaledContext(scale);
+    const snapshot: GhosttySnapshot = {
+      cols: 3,
+      rows: 3,
+      foreground: { r: 0, g: 0, b: 0 },
+      background,
+      cursor: { r: 0, g: 0, b: 0 },
+      cursorX: 1,
+      cursorY: 1,
+      cursorVisible: true,
+      cursorBlinking: true,
+      cursorStyle,
+      dirtyRows: new Set(),
+      rowData: [0, 1, 2].map(() => ({
+        cells: [blank, blank, blank],
+        text: "",
+        isWrapContinuation: false,
+        wrapsToNext: false,
+      })),
+    };
+    const options = { ...layout, context, snapshot };
+
+    renderGhosttySnapshot({ ...options, forceFull: true, cursorOn: true });
+    expect(pixelsOffBackground(context)).toBeGreaterThan(0);
+
+    renderGhosttySnapshot({ ...options, forceFull: false, cursorOn: false, previousCursorY: 1 });
+    expect(pixelsOffBackground(context)).toBe(0);
+  });
+
+  it("erases a Powerline separator completely when only its row repaints", () => {
+    const context = scaledContext(scale);
+    const firstRow = (cell: GhosttyCell): GhosttySnapshot => ({
+      cols: 3,
+      rows: 3,
+      foreground: { r: 0, g: 0, b: 0 },
+      background,
+      cursor: { r: 0, g: 0, b: 0 },
+      cursorX: -1,
+      cursorY: -1,
+      cursorVisible: false,
+      cursorBlinking: false,
+      cursorStyle: 1,
+      dirtyRows: new Set([0]),
+      rowData: [cell, blank, blank].map((middle) => ({
+        cells: [blank, middle, blank],
+        text: "",
+        isWrapContinuation: false,
+        wrapsToNext: false,
+      })),
+    });
+    const options = { ...layout, context, forceFull: false, cursorOn: false };
+
+    renderGhosttySnapshot({ ...options, snapshot: firstRow(blank), forceFull: true });
+    renderGhosttySnapshot({ ...options, snapshot: firstRow({ ...blank, text: "\ue0b0" }) });
+    expect(pixelsOffBackground(context)).toBeGreaterThan(0);
+
+    renderGhosttySnapshot({ ...options, snapshot: firstRow(blank) });
+    expect(pixelsOffBackground(context)).toBe(0);
+  });
+});
```

## apps/web/src/terminal/ghostty/renderer.ts

Status: modified; +25; −17

```diff
@@ -49,14 +49,15 @@
   text: string,
   left: number,
   top: number,
+  width: number,
+  height: number,
-  metrics: GhosttyCellMetrics,
 ): void {
-  const right = left + metrics.width;
+  const right = left + width;
   const [base, point] = text === POWERLINE_SOLID_RIGHT ? [left, right] : [right, left];
   context.beginPath();
   context.moveTo(base, top);
+  context.lineTo(point, top + height / 2);
-  context.lineTo(point, top + metrics.height / 2);
+  context.lineTo(base, top + height);
-  context.lineTo(base, top + metrics.height);
   context.closePath();
   context.fill();
 }
@@ -147,6 +148,10 @@
   const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND;
   const hoveredLinkRange = options.hoveredLinkRange ?? null;
   const originY = options.originY ?? padding;
+  // Rows start on whole device pixels, so repainting a row replaces every pixel
+  // the last frame drew in it, including a blinking cursor's edges.
+  const scale = context.getTransform().d;
+  const rowTop = (row: number) => Math.round((originY + row * metrics.height) * scale) / scale;
   const rowsToDraw = forceFull
     ? Array.from({ length: snapshot.rows }, (_, index) => index)
     : [...snapshot.dirtyRows];
@@ -174,10 +179,11 @@
   for (const rowIndex of rowsToDraw) {
     const row = snapshot.rowData[rowIndex];
     if (!row) continue;
+    const top = rowTop(rowIndex);
-    const top = originY + rowIndex * metrics.height;
+    const height = rowTop(rowIndex + 1) - top;
 
     context.fillStyle = cssColor(snapshot.background);
-    context.fillRect(padding, top, snapshot.cols * metrics.width, metrics.height);
+    context.fillRect(padding, top, snapshot.cols * metrics.width, height);
 
     let backgroundStart = 0;
     while (backgroundStart < row.cells.length) {
@@ -200,11 +206,11 @@
         const width = (backgroundEnd - backgroundStart) * metrics.width;
         if (!ghosttyColorsEqual(first.background, snapshot.background)) {
           context.fillStyle = cssColor(first.background);
+          context.fillRect(left, top, width, height);
-          context.fillRect(left, top, width, metrics.height);
         }
         if (first.selected) {
           context.fillStyle = selectionBackground;
-          context.fillRect(left, top, width, metrics.height);
+          context.fillRect(left, top, width, height);
         }
       }
       backgroundStart = backgroundEnd;
@@ -226,7 +232,8 @@
             first.text,
             padding + runStart * metrics.width,
             top,
+            metrics.width,
-            metrics,
+            height,
           );
         }
         runStart += 1;
@@ -244,7 +251,7 @@
           padding + runStart * metrics.width,
           top,
           (runEnd - runStart) * metrics.width,
+          height,
-          metrics.height,
         );
         context.clip();
         context.font = fontForCell(first, fontSize, fontFamily);
@@ -285,25 +292,26 @@
 
   if (cursorOn && snapshot.cursorVisible && snapshot.cursorX >= 0 && snapshot.cursorY >= 0) {
     const left = padding + snapshot.cursorX * metrics.width;
+    const top = rowTop(snapshot.cursorY);
+    const height = rowTop(snapshot.cursorY + 1) - top;
-    const top = originY + snapshot.cursorY * metrics.height;
     context.fillStyle = cssColor(snapshot.cursor);
     if (!focused) {
       context.strokeStyle = cssColor(snapshot.cursor);
-      context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1);
+      context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, height - 1);
     } else if (snapshot.cursorStyle === 0) {
-      context.fillRect(left, top, 2, metrics.height);
+      context.fillRect(left, top, 2, height);
     } else if (snapshot.cursorStyle === 2) {
+      context.fillRect(left, top + height - 2, metrics.width, 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);
+      context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, height - 1);
     } else {
+      context.fillRect(left, top, metrics.width, height);
-      context.fillRect(left, top, metrics.width, metrics.height);
       const cell = snapshot.rowData[snapshot.cursorY]?.cells[snapshot.cursorX];
       if (cell?.text) {
         context.fillStyle = cssColor(snapshot.background);
         if (isSolidPowerlineSeparator(cell.text)) {
-          fillSolidPowerlineSeparator(context, cell.text, left, top, metrics);
+          fillSolidPowerlineSeparator(context, cell.text, left, top, metrics.width, height);
         } else {
           context.font = fontForCell(cell, fontSize, fontFamily);
           context.fillText(cell.text, left, top + metrics.baseline, metrics.width);
```

## apps/web/src/terminal/ghostty/surface.test.ts

Status: modified; +1; −0

```diff
@@ -120,6 +120,7 @@
       canvas,
       beginPath() {},
       clip() {},
+      getTransform: () => ({ d: 1 }),
       rect() {},
       resetTransform() {},
       restore() {},
```
