Files
openchamber/packages/ui/src/lib/ghostty/renderer.ts
T
Bohdan Triapitsyn 39fa8c1917 feat(terminal): replace ghostty-web with an in-repo libghostty-vt adapter
The terminal ran on the ghostty-web npm package plus a hand-written patch,
and every rendering bug (recycled rows, duplicated reflow fragments, prompt
artifacts) had to be worked around from outside. The emulator now is the
official libghostty-vt C ABI compiled to WebAssembly, driven by a browser
adapter ported from T3 Code (MIT, notice in LICENSE-T3CODE) and owned in
packages/ui/src/lib/ghostty. The artifact is reproducible with
scripts/build-libghostty-wasm.sh, including a workaround for Zig 0.15.2 on
macOS 27 SDKs.

On top of the port: one WASM instance per page with every tab kept mounted
and hidden tabs paused; history replayed at the PTY size it was drawn for;
shells spawned only after the first fitted grid so zsh never prints the
PROMPT_SP marker; box drawing, block elements and Powerline arrows drawn
procedurally to the exact cell so TUI borders and block logos have no gaps
between rows; a software-rasterized canvas so Gecko renders every tab's text
with the same smoothing; the symbols-only Nerd Font bundled instead of a CDN
fetch; touch selection and scrolling driven through the surface API; a copy
button in the tab strip for touch hosts; localized aria labels.

Testing: bun tests run the real WASM (reflow, palette, replay isolation,
recycled rows, box glyph geometry); viewport and view tests use a surface
double; verified in Chromium and Zen (windowed and headless) for crisp text,
new tabs, panel reopen, resize and box glyph rendering; package type-check,
oxlint/eslint on new files, web build.
2026-09-07 12:18:22 +03:00

332 lines
11 KiB
TypeScript

// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.).
// See LICENSE-T3CODE in this directory.
import {
GHOSTTY_CELL_WIDE,
ghosttyColorsEqual,
type GhosttyCell,
type GhosttyColor,
type GhosttySnapshot,
} from './core';
import { drawBoxDrawingGlyph, isBoxDrawingText, type BoxDrawingContext } from './boxDrawing';
/** The canvas operations the renderer uses; a CanvasRenderingContext2D satisfies it structurally. */
export interface GhosttyRenderContext extends BoxDrawingContext {
readonly canvas: { readonly width: number; readonly height: number };
font: string;
textBaseline: CanvasTextBaseline;
fillRect(x: number, y: number, w: number, h: number): void;
strokeRect(x: number, y: number, w: number, h: number): void;
fillText(text: string, x: number, y: number, maxWidth?: number): void;
save(): void;
restore(): void;
beginPath(): void;
rect(x: number, y: number, w: number, h: number): void;
clip(): void;
resetTransform(): void;
}
export interface GhosttyMeasureContext {
font: string;
measureText(text: string): {
readonly width: number;
readonly actualBoundingBoxAscent: number;
readonly actualBoundingBoxDescent: number;
};
}
export interface GhosttyCellMetrics {
readonly width: number;
readonly height: number;
readonly baseline: number;
}
export interface GhosttyCellRange {
readonly start: { readonly x: number; readonly y: number };
readonly end: { readonly x: number; readonly y: number };
}
const DEFAULT_SELECTION_BACKGROUND = 'rgba(72, 122, 191, 0.35)';
function cssColor(color: GhosttyColor): string {
return `rgb(${color.r}, ${color.g}, ${color.b})`;
}
function sameTextStyle(left: GhosttyCell, right: GhosttyCell): boolean {
// Selection deliberately does not participate: it only tints the background
// overlay, and splitting a text run at a selection boundary visibly shifts
// glyph spacing whenever the face's true advance differs from the cell width.
return (
ghosttyColorsEqual(left.foreground, right.foreground) &&
left.bold === right.bold &&
left.italic === right.italic &&
left.invisible === right.invisible
);
}
export function ghosttyTextRunEnd(
cells: readonly GhosttyCell[],
start: number,
sameStyle: (cell: GhosttyCell) => boolean,
): number {
let end = start + 1;
while (end < cells.length) {
const next = cells[end];
if (!next) break;
if (next.wide === GHOSTTY_CELL_WIDE.spacerTail) {
end += 1;
continue;
}
if (next.text.length === 0 || !sameStyle(next)) break;
end += 1;
}
return end;
}
function fontForCell(cell: GhosttyCell, fontSize: number, fontFamily: string): string {
const style = cell.italic ? 'italic' : 'normal';
const weight = cell.bold ? '700' : '400';
return `${style} ${weight} ${fontSize}px ${fontFamily}`;
}
export function measureGhosttyCell(
context: GhosttyMeasureContext,
fontSize: number,
fontFamily: string,
): GhosttyCellMetrics {
context.font = `normal 400 ${fontSize}px ${fontFamily}`;
const widthMeasurement = context.measureText('M');
const verticalMeasurement = context.measureText('Mg');
const ascent = verticalMeasurement.actualBoundingBoxAscent || fontSize;
const descent = verticalMeasurement.actualBoundingBoxDescent;
const glyphHeight = ascent + descent;
const height = Math.max(1, Math.round(fontSize * 1.35), Math.ceil(glyphHeight));
return {
width: Math.max(1, widthMeasurement.width),
height,
baseline: Math.round((height - glyphHeight) / 2 + ascent),
};
}
export interface GhosttyGridSize {
readonly cols: number;
readonly rows: number;
}
export function terminalGridSize(
width: number,
height: number,
metrics: GhosttyCellMetrics,
padding: number,
): GhosttyGridSize {
return {
cols: Math.max(1, Math.floor((width - padding * 2) / metrics.width)),
rows: Math.max(1, Math.floor((height - padding * 2) / metrics.height)),
};
}
export function renderGhosttySnapshot(options: {
readonly context: GhosttyRenderContext;
readonly snapshot: GhosttySnapshot;
readonly metrics: GhosttyCellMetrics;
readonly fontSize: number;
readonly fontFamily: string;
readonly padding: number;
readonly forceFull: boolean;
readonly cursorOn: boolean;
readonly previousCursorY?: number | null;
readonly focused?: boolean;
readonly selectionBackground?: string;
readonly hoveredLinkRange?: GhosttyCellRange | null;
/** Vertical origin of row 0; defaults to the horizontal padding. */
readonly originY?: number;
}): void {
const {
context,
snapshot,
metrics,
fontSize,
fontFamily,
padding,
forceFull,
cursorOn,
previousCursorY,
} = options;
const focused = options.focused ?? true;
const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND;
const hoveredLinkRange = options.hoveredLinkRange ?? null;
const originY = options.originY ?? padding;
const rowsToDraw = forceFull
? Array.from({ length: snapshot.rows }, (_, index) => index)
: [...snapshot.dirtyRows];
if (
previousCursorY !== null &&
previousCursorY !== undefined &&
previousCursorY >= 0 &&
!rowsToDraw.includes(previousCursorY)
) {
rowsToDraw.push(previousCursorY);
}
if (snapshot.cursorVisible && snapshot.cursorY >= 0 && !rowsToDraw.includes(snapshot.cursorY)) {
rowsToDraw.push(snapshot.cursorY);
}
if (forceFull) {
context.save();
context.resetTransform();
context.fillStyle = cssColor(snapshot.background);
context.fillRect(0, 0, context.canvas.width, context.canvas.height);
context.restore();
}
context.textBaseline = 'alphabetic';
for (const rowIndex of rowsToDraw) {
const row = snapshot.rowData[rowIndex];
if (!row) continue;
const top = originY + rowIndex * metrics.height;
context.fillStyle = cssColor(snapshot.background);
context.fillRect(padding, top, snapshot.cols * metrics.width, metrics.height);
let backgroundStart = 0;
while (backgroundStart < row.cells.length) {
const first = row.cells[backgroundStart];
if (!first) break;
let backgroundEnd = backgroundStart + 1;
while (backgroundEnd < row.cells.length) {
const next = row.cells[backgroundEnd];
if (
!next ||
next.selected !== first.selected ||
!ghosttyColorsEqual(next.background, first.background)
) {
break;
}
backgroundEnd += 1;
}
if (first.selected || !ghosttyColorsEqual(first.background, snapshot.background)) {
const left = padding + backgroundStart * metrics.width;
const width = (backgroundEnd - backgroundStart) * metrics.width;
if (!ghosttyColorsEqual(first.background, snapshot.background)) {
context.fillStyle = cssColor(first.background);
context.fillRect(left, top, width, metrics.height);
}
if (first.selected) {
context.fillStyle = selectionBackground;
context.fillRect(left, top, width, metrics.height);
}
}
backgroundStart = backgroundEnd;
}
let runStart = 0;
while (runStart < row.cells.length) {
const first = row.cells[runStart];
if (!first) break;
if (first.text.length === 0) {
runStart += 1;
continue;
}
// Borders, bars and block logos are drawn to the exact cell instead of
// through the font, whose glyphs leave a gap at the terminal line height.
if (isBoxDrawingText(first.text)) {
if (!first.invisible) {
drawBoxDrawingGlyph(
context,
first.text,
{ x: padding + runStart * metrics.width, y: top, width: metrics.width, height: metrics.height },
first.foreground,
);
}
runStart += 1;
continue;
}
const runEnd = ghosttyTextRunEnd(
row.cells,
runStart,
(cell) => sameTextStyle(cell, first) && !isBoxDrawingText(cell.text),
);
const text = row.cells
.slice(runStart, runEnd)
.map((cell) => cell.text)
.join('');
if (!first.invisible && text.trim().length > 0) {
context.save();
context.beginPath();
context.rect(
padding + runStart * metrics.width,
top,
(runEnd - runStart) * metrics.width,
metrics.height,
);
context.clip();
context.font = fontForCell(first, fontSize, fontFamily);
context.fillStyle = cssColor(first.foreground);
context.fillText(
text,
padding + runStart * metrics.width,
top + metrics.baseline,
(runEnd - runStart) * metrics.width,
);
context.restore();
}
runStart = runEnd;
}
for (let column = 0; column < row.cells.length; column += 1) {
const cell = row.cells[column];
const hoveredLink =
hoveredLinkRange !== null &&
rowIndex >= hoveredLinkRange.start.y &&
rowIndex <= hoveredLinkRange.end.y &&
(rowIndex > hoveredLinkRange.start.y || column >= hoveredLinkRange.start.x) &&
(rowIndex < hoveredLinkRange.end.y || column <= hoveredLinkRange.end.x);
if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline && !hoveredLink)) {
continue;
}
context.fillStyle = cssColor(cell.foreground);
const left = padding + column * metrics.width;
if (cell.underline || hoveredLink) {
context.fillRect(left, top + metrics.height - 2, metrics.width, 1);
}
if (cell.strikethrough) {
context.fillRect(left, top + Math.floor(metrics.height * 0.55), metrics.width, 1);
}
if (cell.overline) context.fillRect(left, top + 1, metrics.width, 1);
}
}
if (cursorOn && 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);
} else {
context.fillRect(left, top, metrics.width, metrics.height);
const cell = snapshot.rowData[snapshot.cursorY]?.cells[snapshot.cursorX];
if (cell?.text && isBoxDrawingText(cell.text)) {
drawBoxDrawingGlyph(
context,
cell.text,
{ x: left, y: top, width: metrics.width, height: metrics.height },
snapshot.background,
);
} else if (cell?.text) {
context.font = fontForCell(cell, fontSize, fontFamily);
context.fillStyle = cssColor(snapshot.background);
context.fillText(cell.text, left, top + metrics.baseline, metrics.width);
}
}
}
}