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

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

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

Visibility: public

[fix\(web\): render powerline separators at cell edges](https://git.cafe/pingdotgg/t3code/pull/5607.md)

Version: 3

Head commit: 9fd417571439c9f74768d059be6967c3e5495119

Base commit: b72740852ffb5ab727e1ae7c042d9e89533bc341

Comparison base: b72740852ffb5ab727e1ae7c042d9e89533bc341

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.test.ts

Status: modified; +72; −0

```diff
@@ -71,6 +71,78 @@
 });
 
 describe("renderGhosttySnapshot", () => {
+  it("draws solid Powerline separators to their cell edges instead of as text", () => {
+    const paths: number[][][] = [];
+    let path: number[][] = [];
+    const fillTextCalls: unknown[][] = [];
+    const context = {
+      canvas: { width: 200, height: 40 },
+      beginPath: () => {
+        path = [];
+      },
+      clip: () => {},
+      closePath: () => {},
+      fill: () => paths.push(path),
+      fillRect: () => {},
+      fillText: (...args: unknown[]) => fillTextCalls.push(args),
+      lineTo: (x: number, y: number) => path.push([x, y]),
+      moveTo: (x: number, y: number) => path.push([x, y]),
+      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: -1,
+      cursorY: -1,
+      cursorVisible: false,
+      cursorBlinking: false,
+      cursorStyle: 1,
+      dirtyRows: new Set([0]),
+      rowData: [
+        {
+          cells: [cell("a"), cell("\ue0b0"), cell("\ue0b2")],
+          text: "a\ue0b0\ue0b2",
+          isWrapContinuation: false,
+          wrapsToNext: false,
+        },
+      ],
+    };
+
+    renderGhosttySnapshot({
+      context,
+      snapshot,
+      metrics: { width: 10, height: 20, baseline: 15 },
+      fontSize: 12,
+      fontFamily: "monospace",
+      padding: 4,
+      forceFull: false,
+      cursorOn: false,
+    });
+
+    expect(fillTextCalls).toEqual([["a", 4, 19, 10]]);
+    expect(paths).toEqual([
+      [
+        [14, 4],
+        [24, 14],
+        [14, 24],
+      ],
+      [
+        [34, 4],
+        [24, 14],
+        [34, 24],
+      ],
+    ]);
+  });
+
   it("underlines every cell in a hovered wrapped link", () => {
     const fillRectCalls: number[][] = [];
     const context = {
```

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

Status: modified; +47; −3

```diff
@@ -18,6 +18,8 @@
 }
 
 const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)";
+const POWERLINE_SOLID_RIGHT = "\ue0b0";
+const POWERLINE_SOLID_LEFT = "\ue0b2";
 
 function cssColor(color: GhosttyColor): string {
   return `rgb(${color.r}, ${color.g}, ${color.b})`;
@@ -35,6 +37,30 @@
   );
 }
 
+function isSolidPowerlineSeparator(text: string): boolean {
+  return text === POWERLINE_SOLID_RIGHT || text === POWERLINE_SOLID_LEFT;
+}
+
+// Fallback fonts inset or scale these private-use glyphs, leaving a gap where
+// the separator should meet the next cell. Fill the triangle from the cell's
+// own edges instead, as native terminals do.
+function fillSolidPowerlineSeparator(
+  context: CanvasRenderingContext2D,
+  text: string,
+  left: number,
+  top: number,
+  metrics: GhosttyCellMetrics,
+): void {
+  const right = left + metrics.width;
+  const [base, point] = text === POWERLINE_SOLID_RIGHT ? [left, right] : [right, left];
+  context.beginPath();
+  context.moveTo(base, top);
+  context.lineTo(point, top + metrics.height / 2);
+  context.lineTo(base, top + metrics.height);
+  context.closePath();
+  context.fill();
+}
+
 export function ghosttyTextRunEnd(
   cells: readonly GhosttyCell[],
   start: number,
@@ -48,7 +74,7 @@
       end += 1;
       continue;
     }
-    if (next.text.length === 0 || !sameStyle(next)) break;
+    if (next.text.length === 0 || isSolidPowerlineSeparator(next.text) || !sameStyle(next)) break;
     end += 1;
   }
   return end;
@@ -192,6 +218,20 @@
         runStart += 1;
         continue;
       }
+      if (isSolidPowerlineSeparator(first.text)) {
+        if (!first.invisible) {
+          context.fillStyle = cssColor(first.foreground);
+          fillSolidPowerlineSeparator(
+            context,
+            first.text,
+            padding + runStart * metrics.width,
+            top,
+            metrics,
+          );
+        }
+        runStart += 1;
+        continue;
+      }
       const runEnd = ghosttyTextRunEnd(row.cells, runStart, (cell) => sameTextStyle(cell, first));
       const text = row.cells
         .slice(runStart, runEnd)
@@ -261,9 +301,13 @@
       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);
+        if (isSolidPowerlineSeparator(cell.text)) {
+          fillSolidPowerlineSeparator(context, cell.text, left, top, metrics);
+        } else {
+          context.font = fontForCell(cell, fontSize, fontFamily);
+          context.fillText(cell.text, left, top + metrics.baseline, metrics.width);
+        }
       }
     }
   }
```
