import { GHOSTTY_CELL_WIDE, ghosttyColorsEqual, type GhosttyCell, type GhosttyColor, type GhosttySnapshot,} from "./core"; 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: CanvasRenderingContext2D, 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 function terminalGridSize( width: number, height: number, metrics: GhosttyCellMetrics, padding: number,): { cols: number; rows: number } { 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: CanvasRenderingContext2D; 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; } const runEnd = ghosttyTextRunEnd(row.cells, runStart, (cell) => sameTextStyle(cell, first)); 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) { context.font = fontForCell(cell, fontSize, fontFamily); context.fillStyle = cssColor(snapshot.background); context.fillText(cell.text, left, top + metrics.baseline, metrics.width); } } }}
import {
GHOSTTY_CELL_WIDE,
ghosttyColorsEqual,
type GhosttyCell,
type GhosttyColor,
type GhosttySnapshot,
} from "./core";
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: CanvasRenderingContext2D,
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 function terminalGridSize(
width: number,
height: number,
metrics: GhosttyCellMetrics,
padding: number,
): { cols: number; rows: number } {
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: CanvasRenderingContext2D;
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;
}
const runEnd = ghosttyTextRunEnd(row.cells, runStart, (cell) => sameTextStyle(cell, first));
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) {
context.font = fontForCell(cell, fontSize, fontFamily);
context.fillStyle = cssColor(snapshot.background);
context.fillText(cell.text, left, top + metrics.baseline, metrics.width);
}
}
}
}