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.
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user