From 99d930780c5a96c4ebc5c570e043febdcca95d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferro?= Date: Sat, 7 Nov 2026 10:32:59 +0000 Subject: [PATCH 1/2] fix(web): draw rounded powerline caps to fill the terminal cell The Ghostty canvas renderer already fills the solid triangle separators (U+E0B0, U+E0B2) from the cell edges, but the rounded caps (U+E0B4, U+E0B6) used by Powerlevel10k and Starship pill styles were still drawn from the font. Fonts size those glyphs to their own em box, which is shorter and narrower than the terminal cell, so pill segments render as boxes with small rounded nubs. Draw them as half-ellipses spanning the full cell, overlapping the closed segment by 1px to avoid an anti-aliasing seam at fractional cell edges. --- .../web/src/terminal/ghostty/renderer.test.ts | 116 ++++++++++++++++++ apps/web/src/terminal/ghostty/renderer.ts | 28 ++++- 2 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/terminal/ghostty/renderer.test.ts diff --git a/apps/web/src/terminal/ghostty/renderer.test.ts b/apps/web/src/terminal/ghostty/renderer.test.ts new file mode 100644 index 0000000000000..2c69ed87b8394 --- /dev/null +++ b/apps/web/src/terminal/ghostty/renderer.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { GHOSTTY_CELL_WIDE, type GhosttyCell, type GhosttySnapshot } from "./core"; +import { renderGhosttySnapshot } from "./renderer"; + +const BLACK = { r: 0, g: 0, b: 0 }; +const WHITE = { r: 255, g: 255, b: 255 }; +const METRICS = { width: 8, height: 20, baseline: 15 }; + +function cell(text: string): GhosttyCell { + return { + text, + wide: GHOSTTY_CELL_WIDE.narrow, + foreground: WHITE, + background: BLACK, + bold: false, + italic: false, + invisible: false, + strikethrough: false, + overline: false, + underline: false, + selected: false, + }; +} + +function render(text: string) { + const calls: Array<{ readonly op: string; readonly args: readonly unknown[] }> = []; + const record = + (op: string) => + (...args: unknown[]) => { + calls.push({ op, args }); + }; + const context = { + canvas: { width: 100, height: 100 }, + fillStyle: "", + strokeStyle: "", + font: "", + textBaseline: "", + save: record("save"), + restore: record("restore"), + resetTransform: record("resetTransform"), + getTransform: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }), + fillRect: record("fillRect"), + strokeRect: record("strokeRect"), + beginPath: record("beginPath"), + moveTo: record("moveTo"), + lineTo: record("lineTo"), + ellipse: record("ellipse"), + closePath: record("closePath"), + fill: record("fill"), + rect: record("rect"), + clip: record("clip"), + fillText: record("fillText"), + } as unknown as CanvasRenderingContext2D; + const snapshot: GhosttySnapshot = { + cols: 1, + rows: 1, + foreground: WHITE, + background: BLACK, + cursor: WHITE, + cursorX: -1, + cursorY: -1, + cursorVisible: false, + cursorBlinking: false, + cursorStyle: 1, + dirtyRows: new Set([0]), + rowData: [{ cells: [cell(text)], text, isWrapContinuation: false, wrapsToNext: false }], + }; + renderGhosttySnapshot({ + context, + snapshot, + metrics: METRICS, + fontSize: 14, + fontFamily: "monospace", + padding: 0, + forceFull: false, + cursorOn: false, + }); + return calls; +} + +describe("renderGhosttySnapshot powerline separators", () => { + it("fills the right half-circle cap from the cell's left edge across its full height", () => { + const calls = render(""); + expect(calls.some((call) => call.op === "fillText")).toBe(false); + expect(calls.find((call) => call.op === "ellipse")?.args).toEqual([ + 0, + 10, + 8, + 10, + 0, + -Math.PI / 2, + Math.PI / 2, + ]); + }); + + it("fills the left half-circle cap from the cell's right edge across its full height", () => { + const calls = render(""); + expect(calls.some((call) => call.op === "fillText")).toBe(false); + expect(calls.find((call) => call.op === "ellipse")?.args).toEqual([ + 8, + 10, + 8, + 10, + 0, + Math.PI / 2, + (3 * Math.PI) / 2, + ]); + }); + + it("still draws other private-use glyphs from the font", () => { + const calls = render(""); + expect(calls.some((call) => call.op === "ellipse")).toBe(false); + expect(calls.some((call) => call.op === "fillText")).toBe(true); + }); +}); diff --git a/apps/web/src/terminal/ghostty/renderer.ts b/apps/web/src/terminal/ghostty/renderer.ts index 98e3699ab314b..97cf126b66744 100644 --- a/apps/web/src/terminal/ghostty/renderer.ts +++ b/apps/web/src/terminal/ghostty/renderer.ts @@ -20,6 +20,8 @@ export interface GhosttyCellRange { const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)"; const POWERLINE_SOLID_RIGHT = "\ue0b0"; const POWERLINE_SOLID_LEFT = "\ue0b2"; +const POWERLINE_ROUND_RIGHT = "\ue0b4"; +const POWERLINE_ROUND_LEFT = "\ue0b6"; function cssColor(color: GhosttyColor): string { return `rgb(${color.r}, ${color.g}, ${color.b})`; @@ -36,12 +38,17 @@ function sameTextStyle(left: GhosttyCell, right: GhosttyCell): boolean { } function isSolidPowerlineSeparator(text: string): boolean { - return text === POWERLINE_SOLID_RIGHT || text === POWERLINE_SOLID_LEFT; + return ( + text === POWERLINE_SOLID_RIGHT || + text === POWERLINE_SOLID_LEFT || + text === POWERLINE_ROUND_RIGHT || + text === POWERLINE_ROUND_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. +// the separator should meet the next cell. Fill the triangle or half-ellipse +// from the cell's own edges instead, as native terminals do. function fillSolidPowerlineSeparator( context: CanvasRenderingContext2D, text: string, @@ -51,10 +58,23 @@ function fillSolidPowerlineSeparator( height: number, ): void { const right = left + width; + const middle = top + height / 2; + if (text === POWERLINE_ROUND_RIGHT || text === POWERLINE_ROUND_LEFT) { + const [base, start, inward] = + text === POWERLINE_ROUND_RIGHT ? [left, -Math.PI / 2, -1] : [right, Math.PI / 2, 1]; + context.beginPath(); + context.ellipse(base, middle, width, height / 2, 0, start, start + Math.PI); + context.closePath(); + // Overlap the segment this cap closes by a pixel so anti-aliasing at a + // fractional cell edge does not leave a hairline seam. + context.rect(Math.min(base, base + inward), top, 1, height); + context.fill(); + return; + } 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, middle); context.lineTo(base, top + height); context.closePath(); context.fill(); From da7f6235c5d0e9900748283c2a62aeb45d6e80a5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 21:17:04 +0000 Subject: [PATCH 2/2] fix(web): keep the block cursor's rounded cap inside its cell The seam overlap that joins a rounded cap to its segment painted the background colour into the neighbouring cell when the cap sat under a block cursor. Skip the overlap there, and replace the mocked-context unit test with a pixel test beside the existing renderer browser tests. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../terminal/ghostty/renderer.browser.test.ts | 54 ++++++++ .../web/src/terminal/ghostty/renderer.test.ts | 116 ------------------ apps/web/src/terminal/ghostty/renderer.ts | 10 +- 3 files changed, 60 insertions(+), 120 deletions(-) delete mode 100644 apps/web/src/terminal/ghostty/renderer.test.ts diff --git a/apps/web/src/terminal/ghostty/renderer.browser.test.ts b/apps/web/src/terminal/ghostty/renderer.browser.test.ts index eb48b04167748..3d3aec210d30e 100644 --- a/apps/web/src/terminal/ghostty/renderer.browser.test.ts +++ b/apps/web/src/terminal/ghostty/renderer.browser.test.ts @@ -29,6 +29,13 @@ function pixelsOffBackground(context: CanvasRenderingContext2D): number { return count; } +/** Whether the device pixel under a CSS pixel position is closer to black than white. */ +function isDarkAt(context: CanvasRenderingContext2D, x: number, y: number): boolean { + const { a, d } = context.getTransform(); + const { data } = context.getImageData(Math.floor(x * a), Math.floor(y * d), 1, 1); + return data[0]! < 128 && data[1]! < 128 && data[2]! < 128; +} + /** A 40 by 60 CSS pixel canvas drawn at the given device pixel ratio. */ function scaledContext(scale: number): CanvasRenderingContext2D { const canvas = document.createElement("canvas"); @@ -114,4 +121,51 @@ describe.each([1.25, 2.2])("renderGhosttySnapshot at a %sx device pixel ratio", renderGhosttySnapshot({ ...options, snapshot: firstRow(blank) }); expect(pixelsOffBackground(context)).toBe(0); }); + + // Cell 1 spans x 11.8 to 19.6 and row 0 spans y 4 to 21 in CSS pixels. + it.each([ + ["right", "\ue0b4", { flat: 12.3, round: 19.1, neighbour: 0, outside: 11.3 }], + ["left", "\ue0b6", { flat: 19.1, round: 12.3, neighbour: 2, outside: 20.1 }], + ])("fills the rounded %s cap to the cell edges, under a block cursor too", (_side, text, x) => { + const context = scaledContext(scale); + const segment = { ...blank, background: { r: 0, g: 0, b: 0 } }; + const cells = [blank, { ...blank, text }, blank]; + cells[x.neighbour] = segment; + const snapshot = (cursorVisible: boolean): GhosttySnapshot => ({ + cols: 3, + rows: 1, + foreground: { r: 0, g: 0, b: 0 }, + background, + cursor: { r: 0, g: 0, b: 0 }, + cursorX: 1, + cursorY: 0, + cursorVisible, + cursorBlinking: false, + cursorStyle: 1, + dirtyRows: new Set([0]), + rowData: [{ cells, text: "", isWrapContinuation: false, wrapsToNext: false }], + }); + + renderGhosttySnapshot({ + ...layout, + context, + snapshot: snapshot(false), + forceFull: true, + cursorOn: false, + }); + expect(isDarkAt(context, x.flat, 4.5)).toBe(true); + expect(isDarkAt(context, x.flat, 20.5)).toBe(true); + expect(isDarkAt(context, x.round, 12.5)).toBe(true); + expect(isDarkAt(context, x.round, 4.5)).toBe(false); + + renderGhosttySnapshot({ + ...layout, + context, + snapshot: snapshot(true), + forceFull: true, + cursorOn: true, + }); + expect(isDarkAt(context, x.outside, 12.5)).toBe(true); + expect(isDarkAt(context, x.flat, 12.5)).toBe(false); + }); }); diff --git a/apps/web/src/terminal/ghostty/renderer.test.ts b/apps/web/src/terminal/ghostty/renderer.test.ts deleted file mode 100644 index 2c69ed87b8394..0000000000000 --- a/apps/web/src/terminal/ghostty/renderer.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { GHOSTTY_CELL_WIDE, type GhosttyCell, type GhosttySnapshot } from "./core"; -import { renderGhosttySnapshot } from "./renderer"; - -const BLACK = { r: 0, g: 0, b: 0 }; -const WHITE = { r: 255, g: 255, b: 255 }; -const METRICS = { width: 8, height: 20, baseline: 15 }; - -function cell(text: string): GhosttyCell { - return { - text, - wide: GHOSTTY_CELL_WIDE.narrow, - foreground: WHITE, - background: BLACK, - bold: false, - italic: false, - invisible: false, - strikethrough: false, - overline: false, - underline: false, - selected: false, - }; -} - -function render(text: string) { - const calls: Array<{ readonly op: string; readonly args: readonly unknown[] }> = []; - const record = - (op: string) => - (...args: unknown[]) => { - calls.push({ op, args }); - }; - const context = { - canvas: { width: 100, height: 100 }, - fillStyle: "", - strokeStyle: "", - font: "", - textBaseline: "", - save: record("save"), - restore: record("restore"), - resetTransform: record("resetTransform"), - getTransform: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }), - fillRect: record("fillRect"), - strokeRect: record("strokeRect"), - beginPath: record("beginPath"), - moveTo: record("moveTo"), - lineTo: record("lineTo"), - ellipse: record("ellipse"), - closePath: record("closePath"), - fill: record("fill"), - rect: record("rect"), - clip: record("clip"), - fillText: record("fillText"), - } as unknown as CanvasRenderingContext2D; - const snapshot: GhosttySnapshot = { - cols: 1, - rows: 1, - foreground: WHITE, - background: BLACK, - cursor: WHITE, - cursorX: -1, - cursorY: -1, - cursorVisible: false, - cursorBlinking: false, - cursorStyle: 1, - dirtyRows: new Set([0]), - rowData: [{ cells: [cell(text)], text, isWrapContinuation: false, wrapsToNext: false }], - }; - renderGhosttySnapshot({ - context, - snapshot, - metrics: METRICS, - fontSize: 14, - fontFamily: "monospace", - padding: 0, - forceFull: false, - cursorOn: false, - }); - return calls; -} - -describe("renderGhosttySnapshot powerline separators", () => { - it("fills the right half-circle cap from the cell's left edge across its full height", () => { - const calls = render(""); - expect(calls.some((call) => call.op === "fillText")).toBe(false); - expect(calls.find((call) => call.op === "ellipse")?.args).toEqual([ - 0, - 10, - 8, - 10, - 0, - -Math.PI / 2, - Math.PI / 2, - ]); - }); - - it("fills the left half-circle cap from the cell's right edge across its full height", () => { - const calls = render(""); - expect(calls.some((call) => call.op === "fillText")).toBe(false); - expect(calls.find((call) => call.op === "ellipse")?.args).toEqual([ - 8, - 10, - 8, - 10, - 0, - Math.PI / 2, - (3 * Math.PI) / 2, - ]); - }); - - it("still draws other private-use glyphs from the font", () => { - const calls = render(""); - expect(calls.some((call) => call.op === "ellipse")).toBe(false); - expect(calls.some((call) => call.op === "fillText")).toBe(true); - }); -}); diff --git a/apps/web/src/terminal/ghostty/renderer.ts b/apps/web/src/terminal/ghostty/renderer.ts index 97cf126b66744..630745c4faf3e 100644 --- a/apps/web/src/terminal/ghostty/renderer.ts +++ b/apps/web/src/terminal/ghostty/renderer.ts @@ -56,6 +56,7 @@ function fillSolidPowerlineSeparator( top: number, width: number, height: number, + seamOverlap: number, ): void { const right = left + width; const middle = top + height / 2; @@ -65,9 +66,9 @@ function fillSolidPowerlineSeparator( context.beginPath(); context.ellipse(base, middle, width, height / 2, 0, start, start + Math.PI); context.closePath(); - // Overlap the segment this cap closes by a pixel so anti-aliasing at a - // fractional cell edge does not leave a hairline seam. - context.rect(Math.min(base, base + inward), top, 1, height); + // Overlap the segment this cap closes so anti-aliasing at a fractional + // cell edge does not leave a hairline seam. + context.rect(Math.min(base, base + inward * seamOverlap), top, seamOverlap, height); context.fill(); return; } @@ -254,6 +255,7 @@ export function renderGhosttySnapshot(options: { top, metrics.width, height, + 1, ); } runStart += 1; @@ -331,7 +333,7 @@ export function renderGhosttySnapshot(options: { if (cell?.text) { context.fillStyle = cssColor(snapshot.background); if (isSolidPowerlineSeparator(cell.text)) { - fillSolidPowerlineSeparator(context, cell.text, left, top, metrics.width, height); + fillSolidPowerlineSeparator(context, cell.text, left, top, metrics.width, height, 0); } else { context.font = fontForCell(cell, fontSize, fontFamily); context.fillText(cell.text, left, top + metrics.baseline, metrics.width);