Open History to see the last commit
1import {
2 GHOSTTY_CELL_WIDE,
3 ghosttyColorsEqual,
4 type GhosttyCell,
5 type GhosttyColor,
6 type GhosttySnapshot,
7} from "./core";
8
9export interface GhosttyCellMetrics {
10 readonly width: number;
11 readonly height: number;
12 readonly baseline: number;
13}
14
15export interface GhosttyCellRange {
16 readonly start: { readonly x: number; readonly y: number };
17 readonly end: { readonly x: number; readonly y: number };
18}
19
20const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)";
21const POWERLINE_SOLID_RIGHT = "\ue0b0";
22const POWERLINE_SOLID_LEFT = "\ue0b2";
23const POWERLINE_ROUND_RIGHT = "\ue0b4";
24const POWERLINE_ROUND_LEFT = "\ue0b6";
25
26function cssColor(color: GhosttyColor): string {
27 return `rgb(${color.r}, ${color.g}, ${color.b})`;
28}
29
30function sameTextStyle(left: GhosttyCell, right: GhosttyCell): boolean {
31 // Selection only tints the background; splitting text runs shifts glyph spacing when advances differ from cell width.
32 return (
33 ghosttyColorsEqual(left.foreground, right.foreground) &&
34 left.bold === right.bold &&
35 left.italic === right.italic &&
36 left.invisible === right.invisible
37 );
38}
39
40function isSolidPowerlineSeparator(text: string): boolean {
41 return (
42 text === POWERLINE_SOLID_RIGHT ||
43 text === POWERLINE_SOLID_LEFT ||
44 text === POWERLINE_ROUND_RIGHT ||
45 text === POWERLINE_ROUND_LEFT
46 );
47}
48
49// Fallback fonts inset or scale these private-use glyphs, leaving a gap where
50// the separator should meet the next cell. Fill the triangle or half-ellipse
51// from the cell's own edges instead, as native terminals do.
52function fillSolidPowerlineSeparator(
53 context: CanvasRenderingContext2D,
54 text: string,
55 left: number,
56 top: number,
57 width: number,
58 height: number,
59 seamOverlap: number,
60): void {
61 const right = left + width;
62 const middle = top + height / 2;
63 if (text === POWERLINE_ROUND_RIGHT || text === POWERLINE_ROUND_LEFT) {
64 const [base, start, inward] =
65 text === POWERLINE_ROUND_RIGHT ? [left, -Math.PI / 2, -1] : [right, Math.PI / 2, 1];
66 context.beginPath();
67 context.ellipse(base, middle, width, height / 2, 0, start, start + Math.PI);
68 context.closePath();
69 // Overlap the segment this cap closes so anti-aliasing at a fractional
70 // cell edge does not leave a hairline seam.
71 context.rect(Math.min(base, base + inward * seamOverlap), top, seamOverlap, height);
72 context.fill();
73 return;
74 }
75 const [base, point] = text === POWERLINE_SOLID_RIGHT ? [left, right] : [right, left];
76 context.beginPath();
77 context.moveTo(base, top);
78 context.lineTo(point, middle);
79 context.lineTo(base, top + height);
80 context.closePath();
81 context.fill();
82}
83
84function ghosttyTextRunEnd(
85 cells: readonly GhosttyCell[],
86 start: number,
87 sameStyle: (cell: GhosttyCell) => boolean,
88): number {
89 let end = start + 1;
90 while (end < cells.length) {
91 const next = cells[end];
92 if (!next) break;
93 if (next.wide === GHOSTTY_CELL_WIDE.spacerTail) {
94 end += 1;
95 continue;
96 }
97 if (next.text.length === 0 || isSolidPowerlineSeparator(next.text) || !sameStyle(next)) break;
98 end += 1;
99 }
100 return end;
101}
102
103// Snapshots decode `wide` only for empty cells, so a wide glyph is known by its spacer tail.
104function isWideGlyph(cells: readonly GhosttyCell[], index: number): boolean {
105 return cells[index + 1]?.wide === GHOSTTY_CELL_WIDE.spacerTail;
106}
107
108// Wide glyphs rarely advance exactly two cells, so each is centred in its own box
109// and the narrow text after it restarts at its own column instead of drifting.
110function fillGhosttyRunText(
111 context: CanvasRenderingContext2D,
112 cells: readonly GhosttyCell[],
113 start: number,
114 end: number,
115 text: string,
116 left: number,
117 baseline: number,
118 cellWidth: number,
119): void {
120 let segment = start;
121 while (segment < end) {
122 let segmentEnd = segment;
123 while (segmentEnd < end && !isWideGlyph(cells, segmentEnd)) segmentEnd += 1;
124 if (segmentEnd > segment) {
125 const segmentText =
126 segment === start && segmentEnd === end
127 ? text
128 : cells
129 .slice(segment, segmentEnd)
130 .map((cell) => cell.text)
131 .join("");
132 context.fillText(
133 segmentText,
134 left + (segment - start) * cellWidth,
135 baseline,
136 (segmentEnd - segment) * cellWidth,
137 );
138 }
139 if (segmentEnd === end) break;
140 context.textAlign = "center";
141 context.fillText(
142 cells[segmentEnd]?.text ?? "",
143 left + (segmentEnd - start + 1) * cellWidth,
144 baseline,
145 cellWidth * 2,
146 );
147 context.textAlign = "start";
148 segment = segmentEnd + 2;
149 }
150}
151
152// Draws in the current fill style, so a block cursor can redraw them in its text colour.
153function fillCellDecorations(
154 context: CanvasRenderingContext2D,
155 cell: GhosttyCell,
156 underline: boolean,
157 left: number,
158 top: number,
159 width: number,
160 metrics: GhosttyCellMetrics,
161): void {
162 if (underline) context.fillRect(left, top + metrics.height - 2, width, 1);
163 if (cell.strikethrough) {
164 context.fillRect(left, top + Math.floor(metrics.height * 0.55), width, 1);
165 }
166 if (cell.overline) context.fillRect(left, top + 1, width, 1);
167}
168
169function fontForCell(cell: GhosttyCell, fontSize: number, fontFamily: string): string {
170 const style = cell.italic ? "italic" : "normal";
171 const weight = cell.bold ? "700" : "400";
172 return `${style} ${weight} ${fontSize}px ${fontFamily}`;
173}
174
175export function measureGhosttyCell(
176 context: CanvasRenderingContext2D,
177 fontSize: number,
178 fontFamily: string,
179): GhosttyCellMetrics {
180 context.font = `normal 400 ${fontSize}px ${fontFamily}`;
181 const widthMeasurement = context.measureText("M");
182 const verticalMeasurement = context.measureText("Mg");
183 const ascent = verticalMeasurement.actualBoundingBoxAscent || fontSize;
184 const descent = verticalMeasurement.actualBoundingBoxDescent;
185 const glyphHeight = ascent + descent;
186 const height = Math.max(1, Math.round(fontSize * 1.35), Math.ceil(glyphHeight));
187 return {
188 width: Math.max(1, widthMeasurement.width),
189 height,
190 baseline: Math.round((height - glyphHeight) / 2 + ascent),
191 };
192}
193
194export function terminalGridSize(
195 width: number,
196 height: number,
197 metrics: GhosttyCellMetrics,
198 padding: number,
199): { cols: number; rows: number } {
200 return {
201 cols: Math.max(1, Math.floor((width - padding * 2) / metrics.width)),
202 rows: Math.max(1, Math.floor((height - padding * 2) / metrics.height)),
203 };
204}
205
206export function renderGhosttySnapshot(options: {
207 readonly context: CanvasRenderingContext2D;
208 readonly snapshot: GhosttySnapshot;
209 readonly metrics: GhosttyCellMetrics;
210 readonly fontSize: number;
211 readonly fontFamily: string;
212 readonly padding: number;
213 readonly forceFull: boolean;
214 readonly cursorOn: boolean;
215 readonly previousCursorY?: number | null;
216 readonly focused?: boolean;
217 readonly selectionBackground?: string;
218 readonly hoveredLinkRange?: GhosttyCellRange | null;
219 readonly originY?: number;
220}): void {
221 const {
222 context,
223 snapshot,
224 metrics,
225 fontSize,
226 fontFamily,
227 padding,
228 forceFull,
229 cursorOn,
230 previousCursorY,
231 } = options;
232 const focused = options.focused ?? true;
233 const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND;
234 const hoveredLinkRange = options.hoveredLinkRange ?? null;
235 const originY = options.originY ?? padding;
236 // Rows start on whole device pixels and clear the full canvas width, so repainting
237 // a row replaces every pixel the last frame drew in it, including a cursor's edges.
238 const transform = context.getTransform();
239 const rowTop = (row: number) =>
240 Math.round((originY + row * metrics.height) * transform.d) / transform.d;
241 const rowWidth = context.canvas.width / transform.a;
242 const rowsToDraw = forceFull
243 ? Array.from({ length: snapshot.rows }, (_, index) => index)
244 : [...snapshot.dirtyRows];
245 if (
246 previousCursorY !== null &&
247 previousCursorY !== undefined &&
248 previousCursorY >= 0 &&
249 !rowsToDraw.includes(previousCursorY)
250 ) {
251 rowsToDraw.push(previousCursorY);
252 }
253 if (snapshot.cursorVisible && snapshot.cursorY >= 0 && !rowsToDraw.includes(snapshot.cursorY)) {
254 rowsToDraw.push(snapshot.cursorY);
255 }
256
257 if (forceFull) {
258 context.save();
259 context.resetTransform();
260 context.fillStyle = cssColor(snapshot.background);
261 context.fillRect(0, 0, context.canvas.width, context.canvas.height);
262 context.restore();
263 }
264
265 context.textBaseline = "alphabetic";
266 for (const rowIndex of rowsToDraw) {
267 const row = snapshot.rowData[rowIndex];
268 if (!row) continue;
269 const top = rowTop(rowIndex);
270 const height = rowTop(rowIndex + 1) - top;
271
272 context.fillStyle = cssColor(snapshot.background);
273 context.fillRect(0, top, rowWidth, height);
274
275 let backgroundStart = 0;
276 while (backgroundStart < row.cells.length) {
277 const first = row.cells[backgroundStart];
278 if (!first) break;
279 let backgroundEnd = backgroundStart + 1;
280 while (backgroundEnd < row.cells.length) {
281 const next = row.cells[backgroundEnd];
282 if (
283 !next ||
284 next.selected !== first.selected ||
285 !ghosttyColorsEqual(next.background, first.background)
286 ) {
287 break;
288 }
289 backgroundEnd += 1;
290 }
291 if (first.selected || !ghosttyColorsEqual(first.background, snapshot.background)) {
292 const left = padding + backgroundStart * metrics.width;
293 const width = (backgroundEnd - backgroundStart) * metrics.width;
294 if (!ghosttyColorsEqual(first.background, snapshot.background)) {
295 context.fillStyle = cssColor(first.background);
296 context.fillRect(left, top, width, height);
297 }
298 if (first.selected) {
299 context.fillStyle = selectionBackground;
300 context.fillRect(left, top, width, height);
301 }
302 }
303 backgroundStart = backgroundEnd;
304 }
305
306 let runStart = 0;
307 while (runStart < row.cells.length) {
308 const first = row.cells[runStart];
309 if (!first) break;
310 if (first.text.length === 0) {
311 runStart += 1;
312 continue;
313 }
314 if (isSolidPowerlineSeparator(first.text)) {
315 if (!first.invisible) {
316 context.fillStyle = cssColor(first.foreground);
317 fillSolidPowerlineSeparator(
318 context,
319 first.text,
320 padding + runStart * metrics.width,
321 top,
322 metrics.width,
323 height,
324 1,
325 );
326 }
327 runStart += 1;
328 continue;
329 }
330 const runEnd = ghosttyTextRunEnd(row.cells, runStart, (cell) => sameTextStyle(cell, first));
331 const text = row.cells
332 .slice(runStart, runEnd)
333 .map((cell) => cell.text)
334 .join("");
335 if (!first.invisible && text.trim().length > 0) {
336 context.save();
337 context.beginPath();
338 context.rect(
339 padding + runStart * metrics.width,
340 top,
341 (runEnd - runStart) * metrics.width,
342 height,
343 );
344 context.clip();
345 context.font = fontForCell(first, fontSize, fontFamily);
346 context.fillStyle = cssColor(first.foreground);
347 fillGhosttyRunText(
348 context,
349 row.cells,
350 runStart,
351 runEnd,
352 text,
353 padding + runStart * metrics.width,
354 top + metrics.baseline,
355 metrics.width,
356 );
357 context.restore();
358 }
359 runStart = runEnd;
360 }
361
362 for (let column = 0; column < row.cells.length; column += 1) {
363 const cell = row.cells[column];
364 if (!cell || cell.invisible) continue;
365 const hoveredLink =
366 hoveredLinkRange !== null &&
367 rowIndex >= hoveredLinkRange.start.y &&
368 rowIndex <= hoveredLinkRange.end.y &&
369 (rowIndex > hoveredLinkRange.start.y || column >= hoveredLinkRange.start.x) &&
370 (rowIndex < hoveredLinkRange.end.y || column <= hoveredLinkRange.end.x);
371 if (!cell.underline && !cell.strikethrough && !cell.overline && !hoveredLink) continue;
372 context.fillStyle = cssColor(cell.foreground);
373 const left = padding + column * metrics.width;
374 fillCellDecorations(
375 context,
376 cell,
377 cell.underline || hoveredLink,
378 left,
379 top,
380 metrics.width,
381 metrics,
382 );
383 }
384 }
385
386 if (cursorOn && snapshot.cursorVisible && snapshot.cursorX >= 0 && snapshot.cursorY >= 0) {
387 const left = padding + snapshot.cursorX * metrics.width;
388 const top = rowTop(snapshot.cursorY);
389 const height = rowTop(snapshot.cursorY + 1) - top;
390 const cells = snapshot.rowData[snapshot.cursorY]?.cells ?? [];
391 const wide = isWideGlyph(cells, snapshot.cursorX);
392 const width = wide ? metrics.width * 2 : metrics.width;
393 context.fillStyle = cssColor(snapshot.cursor);
394 if (!focused || snapshot.cursorStyle === 3) {
395 context.strokeStyle = cssColor(snapshot.cursor);
396 context.strokeRect(left + 0.5, top + 0.5, width - 1, height - 1);
397 } else if (snapshot.cursorStyle === 0) {
398 context.fillRect(left, top, 2, height);
399 } else if (snapshot.cursorStyle === 2) {
400 context.fillRect(left, top + height - 2, width, 2);
401 } else {
402 const cell = cells[snapshot.cursorX];
403 context.fillRect(left, top, width, height);
404 context.fillStyle = cssColor(snapshot.background);
405 if (cell?.text && !cell.invisible) {
406 if (isSolidPowerlineSeparator(cell.text)) {
407 fillSolidPowerlineSeparator(context, cell.text, left, top, metrics.width, height, 0);
408 } else {
409 context.font = fontForCell(cell, fontSize, fontFamily);
410 context.textAlign = wide ? "center" : "start";
411 context.fillText(
412 cell.text,
413 wide ? left + metrics.width : left,
414 top + metrics.baseline,
415 width,
416 );
417 context.textAlign = "start";
418 }
419 }
420 if (cell && !cell.invisible) {
421 fillCellDecorations(context, cell, cell.underline, left, top, width, metrics);
422 }
423 }
424 }
425}