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.
332 lines
11 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|
|
}
|