// 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); } } } }