import type { Cursor, Frame, Run } from "./ghostty.ts"; import { COLORS, readable } from "./palette.ts"; export type Metrics = { width: number; height: number; ratio: number; cell: { width: number; height: number }; }; const css = ([r, g, b]: readonly number[]) => `rgb(${r} ${g} ${b})`; const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }); /** A painted cell grid with real text above it for native selection and copying. */ export class Grid { readonly #rows: HTMLElement; readonly #canvas: HTMLCanvasElement; readonly #context: CanvasRenderingContext2D | null; readonly #lines: { element: HTMLElement; runs: { run: Run; element: HTMLElement; start: number; end: number }[]; glyphs: { element: HTMLElement; start: number; end: number }[]; }[] = []; #cursor: Cursor | null = null; #focused = false; #metrics: Metrics = { width: 0, height: 0, ratio: 1, cell: { width: 8, height: 18 } }; constructor(rows: HTMLElement, canvas: HTMLCanvasElement) { this.#rows = rows; this.#canvas = canvas; this.#context = canvas.getContext("2d"); } fit(metrics: Metrics) { const old = this.#metrics; if ( metrics.width === old.width && metrics.height === old.height && metrics.ratio === old.ratio && metrics.cell.width === old.cell.width && metrics.cell.height === old.cell.height ) return; this.#metrics = metrics; this.#canvas.width = Math.max(1, Math.round(metrics.width * metrics.ratio)); this.#canvas.height = Math.max(1, Math.round(metrics.height * metrics.ratio)); this.#canvas.style.width = `${this.#canvas.width / metrics.ratio}px`; this.#canvas.style.height = `${this.#canvas.height / metrics.ratio}px`; for (let y = 0; y < this.#lines.length; y++) this.#position(y); this.#paint(); } update(frame: Frame) { while (this.#lines.length < frame.rows) { this.#lines.push({ element: this.#rows.appendChild(document.createElement("div")), runs: [], glyphs: [], }); } while (this.#lines.length > frame.rows) this.#lines.pop()!.element.remove(); for (const [y, runs] of frame.changed) { this.#text(y, runs); } this.#cursor = frame.cursor; this.#paint(); } focus(focused: boolean) { this.#focused = focused; this.#paint(); } #x(column: number) { return Math.round(column * this.#metrics.cell.width * this.#metrics.ratio); } #y(row: number) { return Math.round(row * this.#metrics.cell.height * this.#metrics.ratio); } #text(y: number, runs: Run[]) { const line = this.#lines[y]; line.runs = []; line.glyphs = []; let column = 0; const spans = runs.map((run) => { const start = column; const span = document.createElement("span"); span.style.color = run.invisible ? "transparent" : css(readable(run.fg ?? COLORS.foreground, run.bg ?? COLORS.background)); if (run.bold) span.style.fontWeight = "bold"; if (run.italic) span.style.fontStyle = "italic"; if (run.faint) span.style.opacity = "0.6"; span.className = "terminal-run"; for (const { segment } of graphemes.segment(run.text)) { const end = column + (run.wide ? 2 : 1); if (run.wide) { const glyph = document.createElement("span"); glyph.className = "terminal-glyph"; glyph.textContent = segment; line.glyphs.push({ element: glyph, start: column, end }); span.append(glyph); } column = end; } if (!run.wide) span.textContent = run.text; line.runs.push({ run, element: span, start, end: column }); return span; }); line.element.replaceChildren(...(spans.length ? spans : [document.createElement("br")])); this.#position(y); } #position(y: number) { const { element, runs, glyphs } = this.#lines[y]; const { ratio } = this.#metrics; // keep rows in flow for native line breaks, without accumulating snapped-height rounding element.style.top = `${this.#y(y) / ratio - y * this.#metrics.cell.height}px`; element.style.height = `${this.#metrics.cell.height}px`; element.style.lineHeight = `${(this.#y(y + 1) - this.#y(y)) / ratio}px`; for (const span of [...runs, ...glyphs]) { span.element.style.width = `${(this.#x(span.end) - this.#x(span.start)) / ratio}px`; } } #paint() { const ctx = this.#context; if (!ctx) return; const { ratio } = this.#metrics; ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.height); for (let y = 0; y < this.#lines.length; y++) { const top = this.#y(y); const bottom = this.#y(y + 1); for (const { run, start, end } of this.#lines[y].runs) { const left = this.#x(start); const right = this.#x(end); if (run.bg) { ctx.fillStyle = css(run.bg); ctx.fillRect(left, top, right - left, bottom - top); } if (!run.invisible && (run.underline || run.strikethrough)) { ctx.save(); ctx.globalAlpha = run.faint ? 0.6 : 1; ctx.strokeStyle = ctx.fillStyle = css( readable(run.fg ?? COLORS.foreground, run.bg ?? COLORS.background), ); const thickness = Math.max(1, Math.round(ratio)); const baseline = bottom - thickness * 2; if (run.strikethrough) { ctx.fillRect(left, Math.round((top + bottom) / 2), right - left, thickness); } if (run.underline === 3) { ctx.lineWidth = thickness; ctx.beginPath(); for (let x = left; x <= right; x++) { const at = baseline + Math.sin((x - left) / (2 * ratio)) * ratio; if (x === left) ctx.moveTo(x, at); else ctx.lineTo(x, at); } ctx.stroke(); } else if (run.underline) { const step = run.underline === 4 ? thickness * 2 : run.underline === 5 ? thickness * 6 : right - left; const length = run.underline === 4 ? thickness : run.underline === 5 ? thickness * 4 : step; for (let x = left; x < right; x += step) { ctx.fillRect(x, baseline, Math.min(length, right - x), thickness); } if (run.underline === 2) { ctx.fillRect(left, baseline - thickness * 2, right - left, thickness); } } ctx.restore(); } } } const at = this.#cursor; if (!at) return; const left = this.#x(at.x); const top = this.#y(at.y); const width = this.#x(at.x + 1) - left; const height = this.#y(at.y + 1) - top; const stroke = Math.max(1, Math.round(ratio)); const thick = Math.max(1, Math.round(2 * ratio)); ctx.fillStyle = css(COLORS.cursor); if (!this.#focused || at.style === "hollow") { ctx.fillRect(left, top, width, stroke); ctx.fillRect(left, top + height - stroke, width, stroke); ctx.fillRect(left, top, stroke, height); ctx.fillRect(left + width - stroke, top, stroke, height); } else if (at.style === "bar") ctx.fillRect(left, top, thick, height); else if (at.style === "underline") ctx.fillRect(left, top + height - thick, width, thick); else { ctx.globalAlpha = 0.7; ctx.fillRect(left, top, width, height); ctx.globalAlpha = 1; } } }