@aurigax-ai/ghostty-web 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GHOSTTY_COMMIT +1 -0
- package/LICENSE +22 -0
- package/README.md +94 -2
- package/ghostty-vt.wasm +0 -0
- package/lib/abi.ts +227 -0
- package/lib/addons/fit.ts +242 -0
- package/lib/box-glyphs.ts +278 -0
- package/lib/buffer.ts +402 -0
- package/lib/event-emitter.ts +27 -0
- package/lib/ghostty.ts +1253 -0
- package/lib/index.ts +111 -0
- package/lib/input-handler.ts +1116 -0
- package/lib/interfaces.ts +224 -0
- package/lib/link-detector.ts +215 -0
- package/lib/marker.ts +55 -0
- package/lib/providers/osc8-link-provider.ts +144 -0
- package/lib/providers/url-regex-provider.ts +150 -0
- package/lib/renderer.ts +820 -0
- package/lib/selection-manager.ts +1057 -0
- package/lib/terminal.ts +2054 -0
- package/lib/types.ts +648 -0
- package/lib/webgl-renderer.ts +870 -0
- package/package.json +70 -4
package/lib/renderer.ts
ADDED
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal renderers.
|
|
3
|
+
*
|
|
4
|
+
* `TerminalRenderer` decides what changed in a frame (dirty rows, cursor,
|
|
5
|
+
* selection, hovered links, scrolling) and owns the state every renderer
|
|
6
|
+
* shares; `CanvasRenderer` draws with Canvas 2D and `WebglRenderer`
|
|
7
|
+
* (webgl-renderer.ts) with WebGL 2. Both draw box drawing and block elements
|
|
8
|
+
* from geometry (box-glyphs.ts) so they fill their cells.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { drawBoxGlyph, isBoxGlyph } from './box-glyphs';
|
|
12
|
+
import type { ITheme } from './interfaces';
|
|
13
|
+
import type { SelectionManager } from './selection-manager';
|
|
14
|
+
import type { GhosttyCell } from './types';
|
|
15
|
+
import { CellFlags } from './types';
|
|
16
|
+
|
|
17
|
+
// Interface for objects that can be rendered
|
|
18
|
+
export interface IRenderable {
|
|
19
|
+
getLine(y: number): GhosttyCell[] | null;
|
|
20
|
+
getCursor(): { x: number; y: number; visible: boolean };
|
|
21
|
+
getDimensions(): { cols: number; rows: number };
|
|
22
|
+
isRowDirty(y: number): boolean;
|
|
23
|
+
/** Returns true if a full redraw is needed (e.g., screen change) */
|
|
24
|
+
needsFullRedraw?(): boolean;
|
|
25
|
+
clearDirty(): void;
|
|
26
|
+
/**
|
|
27
|
+
* Get the full grapheme string for a cell at (row, col).
|
|
28
|
+
* For cells with grapheme_len > 0, this returns all codepoints combined.
|
|
29
|
+
* For simple cells, returns the single character.
|
|
30
|
+
*/
|
|
31
|
+
getGraphemeString?(row: number, col: number): string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IScrollbackProvider {
|
|
35
|
+
getScrollbackLine(offset: number): GhosttyCell[] | null;
|
|
36
|
+
getScrollbackLength(): number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const LINK_COLOR = '#4A90E2';
|
|
40
|
+
export const TEXT_STYLE_FLAGS = CellFlags.BOLD | CellFlags.ITALIC | CellFlags.FAINT;
|
|
41
|
+
|
|
42
|
+
export function isBlank(cell: GhosttyCell): boolean {
|
|
43
|
+
return (cell.codepoint === 0 || cell.codepoint === 32) && cell.grapheme_len === 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isPlainAscii(cell: GhosttyCell): boolean {
|
|
47
|
+
return cell.width === 1 && cell.grapheme_len === 0 && cell.codepoint > 32 && cell.codepoint < 127;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// Type Definitions
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
export interface RendererOptions {
|
|
55
|
+
fontSize?: number; // Default: 15
|
|
56
|
+
fontFamily?: string; // Default: 'monospace'
|
|
57
|
+
cursorStyle?: 'block' | 'underline' | 'bar'; // Default: 'block'
|
|
58
|
+
cursorBlink?: boolean; // Default: false
|
|
59
|
+
theme?: ITheme;
|
|
60
|
+
devicePixelRatio?: number; // Default: window.devicePixelRatio
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FontMetrics {
|
|
64
|
+
width: number; // Character cell width in CSS pixels
|
|
65
|
+
height: number; // Character cell height in CSS pixels
|
|
66
|
+
baseline: number; // Distance from top to text baseline
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ============================================================================
|
|
70
|
+
// Default Theme
|
|
71
|
+
// ============================================================================
|
|
72
|
+
|
|
73
|
+
export const DEFAULT_THEME: Required<ITheme> = {
|
|
74
|
+
foreground: '#d4d4d4',
|
|
75
|
+
background: '#1e1e1e',
|
|
76
|
+
cursor: '#ffffff',
|
|
77
|
+
cursorAccent: '#1e1e1e',
|
|
78
|
+
// Selection colors: solid colors that replace cell bg/fg when selected
|
|
79
|
+
// Using Ghostty's approach: selection bg = default fg, selection fg = default bg
|
|
80
|
+
selectionBackground: '#d4d4d4',
|
|
81
|
+
selectionForeground: '#1e1e1e',
|
|
82
|
+
black: '#000000',
|
|
83
|
+
red: '#cd3131',
|
|
84
|
+
green: '#0dbc79',
|
|
85
|
+
yellow: '#e5e510',
|
|
86
|
+
blue: '#2472c8',
|
|
87
|
+
magenta: '#bc3fbc',
|
|
88
|
+
cyan: '#11a8cd',
|
|
89
|
+
white: '#e5e5e5',
|
|
90
|
+
brightBlack: '#666666',
|
|
91
|
+
brightRed: '#f14c4c',
|
|
92
|
+
brightGreen: '#23d18b',
|
|
93
|
+
brightYellow: '#f5f543',
|
|
94
|
+
brightBlue: '#3b8eea',
|
|
95
|
+
brightMagenta: '#d670d6',
|
|
96
|
+
brightCyan: '#29b8db',
|
|
97
|
+
brightWhite: '#ffffff',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type CursorStyle = 'block' | 'underline' | 'bar';
|
|
101
|
+
|
|
102
|
+
export interface LinkRange {
|
|
103
|
+
startX: number;
|
|
104
|
+
startY: number;
|
|
105
|
+
endX: number;
|
|
106
|
+
endY: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** What a renderer draws over the rows once they are up to date. */
|
|
110
|
+
export interface FrameOverlay {
|
|
111
|
+
cursorX: number;
|
|
112
|
+
cursorY: number;
|
|
113
|
+
/** Cursor shown: at the bottom of the scrollback, visible, and in the blink's on phase. */
|
|
114
|
+
showCursor: boolean;
|
|
115
|
+
viewportY: number;
|
|
116
|
+
scrollbackLength: number;
|
|
117
|
+
cols: number;
|
|
118
|
+
rows: number;
|
|
119
|
+
/** Scrollbar opacity, 0 when there is no scrollback provider. */
|
|
120
|
+
scrollbarOpacity: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Measures the cell box of a font: width of 'M' and height from its ascent and descent. */
|
|
124
|
+
export function measureCell(fontSize: number, fontFamily: string): FontMetrics {
|
|
125
|
+
const canvas = document.createElement('canvas');
|
|
126
|
+
const ctx = canvas.getContext('2d')!;
|
|
127
|
+
ctx.font = `${fontSize}px ${fontFamily}`;
|
|
128
|
+
const widthMetrics = ctx.measureText('M');
|
|
129
|
+
const width = Math.ceil(widthMetrics.width);
|
|
130
|
+
const ascent = widthMetrics.actualBoundingBoxAscent || fontSize * 0.8;
|
|
131
|
+
const descent = widthMetrics.actualBoundingBoxDescent || fontSize * 0.2;
|
|
132
|
+
const height = Math.ceil(ascent + descent) + 2;
|
|
133
|
+
const baseline = Math.ceil(ascent) + 1;
|
|
134
|
+
return { width, height, baseline };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ============================================================================
|
|
138
|
+
// TerminalRenderer: what to draw each frame
|
|
139
|
+
// ============================================================================
|
|
140
|
+
|
|
141
|
+
export abstract class TerminalRenderer {
|
|
142
|
+
protected readonly canvas: HTMLCanvasElement;
|
|
143
|
+
protected fontSize: number;
|
|
144
|
+
protected fontFamily: string;
|
|
145
|
+
protected cursorStyle: CursorStyle;
|
|
146
|
+
protected cursorBlink: boolean;
|
|
147
|
+
protected theme: Required<ITheme>;
|
|
148
|
+
protected devicePixelRatio: number;
|
|
149
|
+
protected metrics: FontMetrics;
|
|
150
|
+
|
|
151
|
+
protected cursorVisible = true;
|
|
152
|
+
private cursorBlinkInterval?: number;
|
|
153
|
+
|
|
154
|
+
/** Called when the renderer needs another frame (cursor blink), so an idle render loop wakes. */
|
|
155
|
+
public onNeedsFrame?: () => void;
|
|
156
|
+
protected lastCursorPosition: { x: number; y: number } = { x: 0, y: 0 };
|
|
157
|
+
private lastViewportY = 0;
|
|
158
|
+
private fullRedrawPending = false;
|
|
159
|
+
|
|
160
|
+
/** The buffer of the frame being drawn, for grapheme lookups. */
|
|
161
|
+
protected currentBuffer: IRenderable | null = null;
|
|
162
|
+
|
|
163
|
+
protected selectionManager?: SelectionManager;
|
|
164
|
+
/** Selection of the frame being drawn, viewport-relative. */
|
|
165
|
+
protected currentSelectionCoords: {
|
|
166
|
+
startCol: number;
|
|
167
|
+
startRow: number;
|
|
168
|
+
endCol: number;
|
|
169
|
+
endRow: number;
|
|
170
|
+
} | null = null;
|
|
171
|
+
|
|
172
|
+
protected hoveredHyperlinkId = 0;
|
|
173
|
+
private previousHoveredHyperlinkId = 0;
|
|
174
|
+
protected hoveredLinkRange: LinkRange | null = null;
|
|
175
|
+
private previousHoveredLinkRange: LinkRange | null = null;
|
|
176
|
+
|
|
177
|
+
/** Whether a moved or blinking cursor needs its row redrawn (false when the cursor is an overlay). */
|
|
178
|
+
protected abstract readonly cursorInRows: boolean;
|
|
179
|
+
|
|
180
|
+
constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) {
|
|
181
|
+
this.canvas = canvas;
|
|
182
|
+
this.fontSize = options.fontSize ?? 15;
|
|
183
|
+
this.fontFamily = options.fontFamily ?? 'monospace';
|
|
184
|
+
this.cursorStyle = options.cursorStyle ?? 'block';
|
|
185
|
+
this.cursorBlink = options.cursorBlink ?? false;
|
|
186
|
+
this.theme = { ...DEFAULT_THEME, ...options.theme };
|
|
187
|
+
this.devicePixelRatio = options.devicePixelRatio ?? window.devicePixelRatio ?? 1;
|
|
188
|
+
this.metrics = measureCell(this.fontSize, this.fontFamily);
|
|
189
|
+
if (this.cursorBlink) this.startCursorBlink();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Draws one viewport row. */
|
|
193
|
+
protected abstract drawLine(line: GhosttyCell[], y: number, cols: number): void;
|
|
194
|
+
/** Whether the drawing surface no longer matches cols × rows. */
|
|
195
|
+
protected abstract surfaceMismatch(cols: number, rows: number): boolean;
|
|
196
|
+
/** Draws the cursor and scrollbar and presents the frame. */
|
|
197
|
+
protected abstract finishFrame(overlay: FrameOverlay): void;
|
|
198
|
+
/** Resize the drawing surface to cols × rows cells. */
|
|
199
|
+
public abstract resize(cols: number, rows: number): void;
|
|
200
|
+
/** Clear the whole surface to the theme background. */
|
|
201
|
+
public abstract clear(): void;
|
|
202
|
+
/** Called after the font or its metrics changed. */
|
|
203
|
+
protected abstract fontChanged(): void;
|
|
204
|
+
|
|
205
|
+
public render(
|
|
206
|
+
buffer: IRenderable,
|
|
207
|
+
forceAll = false,
|
|
208
|
+
viewportY = 0,
|
|
209
|
+
scrollbackProvider?: IScrollbackProvider,
|
|
210
|
+
scrollbarOpacity = 1
|
|
211
|
+
): void {
|
|
212
|
+
this.currentBuffer = buffer;
|
|
213
|
+
const cursor = buffer.getCursor();
|
|
214
|
+
const dims = buffer.getDimensions();
|
|
215
|
+
const scrollbackLength = scrollbackProvider ? scrollbackProvider.getScrollbackLength() : 0;
|
|
216
|
+
|
|
217
|
+
if (buffer.needsFullRedraw?.()) forceAll = true;
|
|
218
|
+
if (this.fullRedrawPending) {
|
|
219
|
+
forceAll = true;
|
|
220
|
+
this.fullRedrawPending = false;
|
|
221
|
+
}
|
|
222
|
+
if (this.surfaceMismatch(dims.cols, dims.rows)) {
|
|
223
|
+
this.resize(dims.cols, dims.rows);
|
|
224
|
+
forceAll = true;
|
|
225
|
+
}
|
|
226
|
+
if (viewportY !== this.lastViewportY) {
|
|
227
|
+
forceAll = true;
|
|
228
|
+
this.lastViewportY = viewportY;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const cursorMoved =
|
|
232
|
+
cursor.x !== this.lastCursorPosition.x || cursor.y !== this.lastCursorPosition.y;
|
|
233
|
+
if (this.cursorInRows && (cursorMoved || this.cursorBlink)) {
|
|
234
|
+
if (!forceAll && !buffer.isRowDirty(cursor.y)) {
|
|
235
|
+
const line = buffer.getLine(cursor.y);
|
|
236
|
+
if (line) this.drawLine(line, cursor.y, dims.cols);
|
|
237
|
+
}
|
|
238
|
+
if (cursorMoved && this.lastCursorPosition.y !== cursor.y) {
|
|
239
|
+
if (!forceAll && !buffer.isRowDirty(this.lastCursorPosition.y)) {
|
|
240
|
+
const line = buffer.getLine(this.lastCursorPosition.y);
|
|
241
|
+
if (line) this.drawLine(line, this.lastCursorPosition.y, dims.cols);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const selectionRows = new Set<number>();
|
|
247
|
+
const hasSelection = this.selectionManager?.hasSelection();
|
|
248
|
+
this.currentSelectionCoords = hasSelection ? this.selectionManager!.getSelectionCoords() : null;
|
|
249
|
+
if (this.currentSelectionCoords) {
|
|
250
|
+
const coords = this.currentSelectionCoords;
|
|
251
|
+
for (let row = coords.startRow; row <= coords.endRow; row++) selectionRows.add(row);
|
|
252
|
+
}
|
|
253
|
+
if (this.selectionManager) {
|
|
254
|
+
const dirtyRows = this.selectionManager.getDirtySelectionRows();
|
|
255
|
+
if (dirtyRows.size > 0) {
|
|
256
|
+
for (const row of dirtyRows) selectionRows.add(row);
|
|
257
|
+
this.selectionManager.clearDirtySelectionRows();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const lineAt = (y: number): GhosttyCell[] | null => {
|
|
262
|
+
if (viewportY > 0) {
|
|
263
|
+
if (y < viewportY && scrollbackProvider) {
|
|
264
|
+
return scrollbackProvider.getScrollbackLine(scrollbackLength - Math.floor(viewportY) + y);
|
|
265
|
+
}
|
|
266
|
+
return buffer.getLine(y - Math.floor(viewportY));
|
|
267
|
+
}
|
|
268
|
+
return buffer.getLine(y);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const hyperlinkRows = new Set<number>();
|
|
272
|
+
if (this.hoveredHyperlinkId !== this.previousHoveredHyperlinkId) {
|
|
273
|
+
for (let y = 0; y < dims.rows; y++) {
|
|
274
|
+
const line = lineAt(y);
|
|
275
|
+
if (!line) continue;
|
|
276
|
+
for (const cell of line) {
|
|
277
|
+
if (
|
|
278
|
+
cell.hyperlink_id === this.hoveredHyperlinkId ||
|
|
279
|
+
cell.hyperlink_id === this.previousHoveredHyperlinkId
|
|
280
|
+
) {
|
|
281
|
+
hyperlinkRows.add(y);
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
this.previousHoveredHyperlinkId = this.hoveredHyperlinkId;
|
|
287
|
+
}
|
|
288
|
+
if (!sameRange(this.hoveredLinkRange, this.previousHoveredLinkRange)) {
|
|
289
|
+
for (const range of [this.previousHoveredLinkRange, this.hoveredLinkRange]) {
|
|
290
|
+
if (!range) continue;
|
|
291
|
+
for (let y = range.startY; y <= range.endY; y++) hyperlinkRows.add(y);
|
|
292
|
+
}
|
|
293
|
+
this.previousHoveredLinkRange = this.hoveredLinkRange;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const rowsToRender = new Set<number>();
|
|
297
|
+
for (let y = 0; y < dims.rows; y++) {
|
|
298
|
+
const needsRender =
|
|
299
|
+
viewportY > 0 ||
|
|
300
|
+
forceAll ||
|
|
301
|
+
buffer.isRowDirty(y) ||
|
|
302
|
+
selectionRows.has(y) ||
|
|
303
|
+
hyperlinkRows.has(y);
|
|
304
|
+
if (needsRender) {
|
|
305
|
+
rowsToRender.add(y);
|
|
306
|
+
if (y > 0) rowsToRender.add(y - 1);
|
|
307
|
+
if (y < dims.rows - 1) rowsToRender.add(y + 1);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
for (let y = 0; y < dims.rows; y++) {
|
|
311
|
+
if (!rowsToRender.has(y)) continue;
|
|
312
|
+
const line = lineAt(y);
|
|
313
|
+
if (line) this.drawLine(line, y, dims.cols);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
this.finishFrame({
|
|
317
|
+
cursorX: cursor.x,
|
|
318
|
+
cursorY: cursor.y,
|
|
319
|
+
showCursor: viewportY === 0 && cursor.visible && this.cursorVisible,
|
|
320
|
+
viewportY,
|
|
321
|
+
scrollbackLength,
|
|
322
|
+
cols: dims.cols,
|
|
323
|
+
rows: dims.rows,
|
|
324
|
+
scrollbarOpacity: scrollbackProvider ? scrollbarOpacity : 0,
|
|
325
|
+
});
|
|
326
|
+
this.lastCursorPosition = { x: cursor.x, y: cursor.y };
|
|
327
|
+
buffer.clearDirty();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ==========================================================================
|
|
331
|
+
// Shared helpers
|
|
332
|
+
// ==========================================================================
|
|
333
|
+
|
|
334
|
+
/** Makes the next frame redraw every row. */
|
|
335
|
+
protected requestFullRedraw(): void {
|
|
336
|
+
this.fullRedrawPending = true;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The text of a cell: its grapheme cluster when it has one, else its codepoint. */
|
|
340
|
+
protected cellText(cell: GhosttyCell, x: number, y: number): string {
|
|
341
|
+
if (cell.grapheme_len > 0 && this.currentBuffer?.getGraphemeString) {
|
|
342
|
+
return this.currentBuffer.getGraphemeString(y, x);
|
|
343
|
+
}
|
|
344
|
+
return String.fromCodePoint(cell.codepoint || 32);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
protected isInSelection(x: number, y: number): boolean {
|
|
348
|
+
const sel = this.currentSelectionCoords;
|
|
349
|
+
if (!sel) return false;
|
|
350
|
+
const { startCol, startRow, endCol, endRow } = sel;
|
|
351
|
+
if (startRow === endRow) return y === startRow && x >= startCol && x <= endCol;
|
|
352
|
+
if (y === startRow) return x >= startCol;
|
|
353
|
+
if (y === endRow) return x <= endCol;
|
|
354
|
+
return y > startRow && y < endRow;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Whether the cell has a hovered link under it (OSC 8 id or a detected URL range). */
|
|
358
|
+
protected linkHovered(cell: GhosttyCell, x: number, y: number): boolean {
|
|
359
|
+
if (cell.hyperlink_id > 0 && cell.hyperlink_id === this.hoveredHyperlinkId) return true;
|
|
360
|
+
const range = this.hoveredLinkRange;
|
|
361
|
+
if (!range) return false;
|
|
362
|
+
return (
|
|
363
|
+
(y === range.startY && x >= range.startX && (y < range.endY || x <= range.endX)) ||
|
|
364
|
+
(y > range.startY && y < range.endY) ||
|
|
365
|
+
(y === range.endY && x <= range.endX && (y > range.startY || x >= range.startX))
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Scrollbar geometry in CSS pixels, or null when there is nothing to draw. */
|
|
370
|
+
protected scrollbarThumb(overlay: FrameOverlay): {
|
|
371
|
+
x: number;
|
|
372
|
+
trackY: number;
|
|
373
|
+
trackHeight: number;
|
|
374
|
+
thumbY: number;
|
|
375
|
+
thumbHeight: number;
|
|
376
|
+
width: number;
|
|
377
|
+
} | null {
|
|
378
|
+
if (overlay.scrollbarOpacity <= 0 || overlay.scrollbackLength === 0) return null;
|
|
379
|
+
const height = overlay.rows * this.metrics.height;
|
|
380
|
+
const width = 8;
|
|
381
|
+
const x = overlay.cols * this.metrics.width - width - 4;
|
|
382
|
+
const padding = 4;
|
|
383
|
+
const trackHeight = height - padding * 2;
|
|
384
|
+
const total = overlay.scrollbackLength + overlay.rows;
|
|
385
|
+
const thumbHeight = Math.max(20, (overlay.rows / total) * trackHeight);
|
|
386
|
+
const position = overlay.viewportY / overlay.scrollbackLength;
|
|
387
|
+
const thumbY = padding + (trackHeight - thumbHeight) * (1 - position);
|
|
388
|
+
return { x, trackY: padding, trackHeight, thumbY, thumbHeight, width };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ==========================================================================
|
|
392
|
+
// Cursor blinking
|
|
393
|
+
// ==========================================================================
|
|
394
|
+
|
|
395
|
+
private startCursorBlink(): void {
|
|
396
|
+
this.cursorBlinkInterval = window.setInterval(() => {
|
|
397
|
+
this.cursorVisible = !this.cursorVisible;
|
|
398
|
+
this.onNeedsFrame?.();
|
|
399
|
+
}, 530);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private stopCursorBlink(): void {
|
|
403
|
+
if (this.cursorBlinkInterval !== undefined) {
|
|
404
|
+
clearInterval(this.cursorBlinkInterval);
|
|
405
|
+
this.cursorBlinkInterval = undefined;
|
|
406
|
+
}
|
|
407
|
+
this.cursorVisible = true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ==========================================================================
|
|
411
|
+
// Public API
|
|
412
|
+
// ==========================================================================
|
|
413
|
+
|
|
414
|
+
public remeasureFont(): void {
|
|
415
|
+
this.metrics = measureCell(this.fontSize, this.fontFamily);
|
|
416
|
+
this.fontChanged();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
public setTheme(theme: ITheme): void {
|
|
420
|
+
this.theme = { ...DEFAULT_THEME, ...theme };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
public setFontSize(size: number): void {
|
|
424
|
+
this.fontSize = size;
|
|
425
|
+
this.remeasureFont();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
public setFontFamily(family: string): void {
|
|
429
|
+
this.fontFamily = family;
|
|
430
|
+
this.remeasureFont();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
public setCursorStyle(style: CursorStyle): void {
|
|
434
|
+
this.cursorStyle = style;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
public setCursorBlink(enabled: boolean): void {
|
|
438
|
+
if (enabled && !this.cursorBlink) {
|
|
439
|
+
this.cursorBlink = true;
|
|
440
|
+
this.startCursorBlink();
|
|
441
|
+
} else if (!enabled && this.cursorBlink) {
|
|
442
|
+
this.cursorBlink = false;
|
|
443
|
+
this.stopCursorBlink();
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
public getMetrics(): FontMetrics {
|
|
448
|
+
return { ...this.metrics };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The canvas that takes pointer events and defines the terminal's box. */
|
|
452
|
+
public getCanvas(): HTMLCanvasElement {
|
|
453
|
+
return this.canvas;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
public setSelectionManager(manager: SelectionManager): void {
|
|
457
|
+
this.selectionManager = manager;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
public setHoveredHyperlinkId(hyperlinkId: number): void {
|
|
461
|
+
this.hoveredHyperlinkId = hyperlinkId;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
public getHoveredHyperlinkId(): number {
|
|
465
|
+
return this.hoveredHyperlinkId;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Set the hovered range of a detected URL, or null to clear it. */
|
|
469
|
+
public setHoveredLinkRange(range: LinkRange | null): void {
|
|
470
|
+
this.hoveredLinkRange = range;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
public get charWidth(): number {
|
|
474
|
+
return this.metrics.width;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
public get charHeight(): number {
|
|
478
|
+
return this.metrics.height;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
public dispose(): void {
|
|
482
|
+
this.stopCursorBlink();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function sameRange(a: LinkRange | null, b: LinkRange | null): boolean {
|
|
487
|
+
if (a === b) return true;
|
|
488
|
+
if (!a || !b) return false;
|
|
489
|
+
return a.startX === b.startX && a.startY === b.startY && a.endX === b.endX && a.endY === b.endY;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ============================================================================
|
|
493
|
+
// CanvasRenderer
|
|
494
|
+
// ============================================================================
|
|
495
|
+
|
|
496
|
+
export class CanvasRenderer extends TerminalRenderer {
|
|
497
|
+
protected readonly cursorInRows = true;
|
|
498
|
+
private readonly ctx: CanvasRenderingContext2D;
|
|
499
|
+
private readonly batchText: boolean;
|
|
500
|
+
private readonly colorStrings = new Map<number, string>();
|
|
501
|
+
private fonts: string[] = [];
|
|
502
|
+
private readonly advances = new Map<string, number>();
|
|
503
|
+
private currentFill = '';
|
|
504
|
+
private currentFont = '';
|
|
505
|
+
|
|
506
|
+
constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) {
|
|
507
|
+
super(canvas, options);
|
|
508
|
+
const ctx = canvas.getContext('2d', { alpha: true });
|
|
509
|
+
if (!ctx) throw new Error('Failed to get 2D rendering context');
|
|
510
|
+
this.ctx = ctx;
|
|
511
|
+
this.batchText = typeof ctx.letterSpacing === 'string';
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
protected surfaceMismatch(cols: number, rows: number): boolean {
|
|
515
|
+
return (
|
|
516
|
+
this.canvas.width !== cols * this.metrics.width * this.devicePixelRatio ||
|
|
517
|
+
this.canvas.height !== rows * this.metrics.height * this.devicePixelRatio
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
public resize(cols: number, rows: number): void {
|
|
522
|
+
const cssWidth = cols * this.metrics.width;
|
|
523
|
+
const cssHeight = rows * this.metrics.height;
|
|
524
|
+
this.canvas.style.width = `${cssWidth}px`;
|
|
525
|
+
this.canvas.style.height = `${cssHeight}px`;
|
|
526
|
+
this.canvas.width = cssWidth * this.devicePixelRatio;
|
|
527
|
+
this.canvas.height = cssHeight * this.devicePixelRatio;
|
|
528
|
+
this.resetContextCache();
|
|
529
|
+
this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio);
|
|
530
|
+
this.ctx.textBaseline = 'alphabetic';
|
|
531
|
+
this.ctx.textAlign = 'left';
|
|
532
|
+
this.ctx.fillStyle = this.theme.background;
|
|
533
|
+
this.ctx.fillRect(0, 0, cssWidth, cssHeight);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
public clear(): void {
|
|
537
|
+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
538
|
+
this.ctx.fillStyle = this.theme.background;
|
|
539
|
+
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
|
540
|
+
this.resetContextCache();
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
protected fontChanged(): void {
|
|
544
|
+
this.fonts = [];
|
|
545
|
+
this.advances.clear();
|
|
546
|
+
this.currentFont = '';
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
protected finishFrame(overlay: FrameOverlay): void {
|
|
550
|
+
if (overlay.showCursor) this.renderCursor(overlay.cursorX, overlay.cursorY);
|
|
551
|
+
if (overlay.scrollbarOpacity > 0) this.renderScrollbar(overlay);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Draws a row in two passes, backgrounds then text, so glyphs that extend
|
|
556
|
+
* past their cell are not covered by a neighbour's background. Backgrounds
|
|
557
|
+
* of one color are one rect; runs of plain ASCII in one style are one
|
|
558
|
+
* fillText, spaced to the cell grid with letterSpacing.
|
|
559
|
+
*/
|
|
560
|
+
protected drawLine(line: GhosttyCell[], y: number, cols: number): void {
|
|
561
|
+
const ctx = this.ctx;
|
|
562
|
+
const cellW = this.metrics.width;
|
|
563
|
+
const cellH = this.metrics.height;
|
|
564
|
+
const lineY = y * cellH;
|
|
565
|
+
|
|
566
|
+
ctx.clearRect(0, lineY, cols * cellW, cellH);
|
|
567
|
+
this.setFill(this.theme.background);
|
|
568
|
+
ctx.fillRect(0, lineY, cols * cellW, cellH);
|
|
569
|
+
|
|
570
|
+
let runStart = 0;
|
|
571
|
+
let runEnd = 0;
|
|
572
|
+
let runColor: string | null = null;
|
|
573
|
+
for (let x = 0; x < line.length; x++) {
|
|
574
|
+
const cell = line[x];
|
|
575
|
+
if (cell.width === 0) continue;
|
|
576
|
+
const color = this.backgroundColor(cell, x, y);
|
|
577
|
+
if (color !== runColor) {
|
|
578
|
+
if (runColor) {
|
|
579
|
+
this.setFill(runColor);
|
|
580
|
+
ctx.fillRect(runStart * cellW, lineY, (runEnd - runStart) * cellW, cellH);
|
|
581
|
+
}
|
|
582
|
+
runColor = color;
|
|
583
|
+
runStart = x;
|
|
584
|
+
}
|
|
585
|
+
runEnd = x + cell.width;
|
|
586
|
+
}
|
|
587
|
+
if (runColor) {
|
|
588
|
+
this.setFill(runColor);
|
|
589
|
+
ctx.fillRect(runStart * cellW, lineY, (runEnd - runStart) * cellW, cellH);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
let x = 0;
|
|
593
|
+
while (x < line.length) {
|
|
594
|
+
const cell = line[x];
|
|
595
|
+
if (cell.width === 0 || cell.flags & CellFlags.INVISIBLE) {
|
|
596
|
+
x++;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
const color = this.foregroundColor(cell, this.isInSelection(x, y));
|
|
600
|
+
if (isBlank(cell)) {
|
|
601
|
+
this.renderDecorations(cell, x, y, color);
|
|
602
|
+
x++;
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if (!this.batchText || !isPlainAscii(cell)) {
|
|
606
|
+
this.renderCellText(cell, x, y, color);
|
|
607
|
+
x++;
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const style = cell.flags & TEXT_STYLE_FLAGS;
|
|
611
|
+
let text = String.fromCharCode(cell.codepoint);
|
|
612
|
+
let end = x + 1;
|
|
613
|
+
let lastGlyph = x;
|
|
614
|
+
this.renderDecorations(cell, x, y, color);
|
|
615
|
+
while (end < line.length) {
|
|
616
|
+
const next = line[end];
|
|
617
|
+
if (next.width !== 1 || next.flags & CellFlags.INVISIBLE) break;
|
|
618
|
+
const nextColor = this.foregroundColor(next, this.isInSelection(end, y));
|
|
619
|
+
if (isBlank(next)) {
|
|
620
|
+
this.renderDecorations(next, end, y, nextColor);
|
|
621
|
+
text += ' ';
|
|
622
|
+
end++;
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
if (!isPlainAscii(next)) break;
|
|
626
|
+
if ((next.flags & TEXT_STYLE_FLAGS) !== style || nextColor !== color) break;
|
|
627
|
+
this.renderDecorations(next, end, y, nextColor);
|
|
628
|
+
text += String.fromCharCode(next.codepoint);
|
|
629
|
+
lastGlyph = end;
|
|
630
|
+
end++;
|
|
631
|
+
}
|
|
632
|
+
this.drawText(text.slice(0, lastGlyph - x + 1), x, y, style, color);
|
|
633
|
+
x = end;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private backgroundColor(cell: GhosttyCell, x: number, y: number): string | null {
|
|
638
|
+
if (this.isInSelection(x, y)) return this.theme.selectionBackground;
|
|
639
|
+
const inverse = cell.flags & CellFlags.INVERSE;
|
|
640
|
+
const r = inverse ? cell.fg_r : cell.bg_r;
|
|
641
|
+
const g = inverse ? cell.fg_g : cell.bg_g;
|
|
642
|
+
const b = inverse ? cell.fg_b : cell.bg_b;
|
|
643
|
+
if (r === 0 && g === 0 && b === 0) return null;
|
|
644
|
+
return this.cssColor(r, g, b);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private foregroundColor(cell: GhosttyCell, selected: boolean): string {
|
|
648
|
+
if (selected) return this.theme.selectionForeground;
|
|
649
|
+
if (cell.flags & CellFlags.INVERSE) return this.cssColor(cell.bg_r, cell.bg_g, cell.bg_b);
|
|
650
|
+
return this.cssColor(cell.fg_r, cell.fg_g, cell.fg_b);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private cssColor(r: number, g: number, b: number): string {
|
|
654
|
+
const key = (r << 16) | (g << 8) | b;
|
|
655
|
+
let color = this.colorStrings.get(key);
|
|
656
|
+
if (color === undefined) {
|
|
657
|
+
color = `rgb(${r}, ${g}, ${b})`;
|
|
658
|
+
this.colorStrings.set(key, color);
|
|
659
|
+
}
|
|
660
|
+
return color;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private setFill(color: string): void {
|
|
664
|
+
if (this.currentFill !== color) {
|
|
665
|
+
this.ctx.fillStyle = color;
|
|
666
|
+
this.currentFill = color;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
private setFont(style: number): void {
|
|
671
|
+
const font = this.fontFor(style);
|
|
672
|
+
if (this.currentFont !== font) {
|
|
673
|
+
this.ctx.font = font;
|
|
674
|
+
this.currentFont = font;
|
|
675
|
+
if (this.batchText) this.ctx.letterSpacing = `${this.metrics.width - this.advanceOf(font)}px`;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
private fontFor(style: number): string {
|
|
680
|
+
let font = this.fonts[style];
|
|
681
|
+
if (font === undefined) {
|
|
682
|
+
let prefix = '';
|
|
683
|
+
if (style & CellFlags.ITALIC) prefix += 'italic ';
|
|
684
|
+
if (style & CellFlags.BOLD) prefix += 'bold ';
|
|
685
|
+
font = `${prefix}${this.fontSize}px ${this.fontFamily}`;
|
|
686
|
+
this.fonts[style] = font;
|
|
687
|
+
}
|
|
688
|
+
return font;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private advanceOf(font: string): number {
|
|
692
|
+
let advance = this.advances.get(font);
|
|
693
|
+
if (advance === undefined) {
|
|
694
|
+
const spacing = this.ctx.letterSpacing;
|
|
695
|
+
this.ctx.letterSpacing = '0px';
|
|
696
|
+
advance = this.ctx.measureText('M').width;
|
|
697
|
+
this.ctx.letterSpacing = spacing;
|
|
698
|
+
this.advances.set(font, advance);
|
|
699
|
+
}
|
|
700
|
+
return advance;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private resetContextCache(): void {
|
|
704
|
+
this.currentFill = '';
|
|
705
|
+
this.currentFont = '';
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private drawText(text: string, x: number, y: number, style: number, color: string): void {
|
|
709
|
+
this.setFont(style);
|
|
710
|
+
this.setFill(color);
|
|
711
|
+
const faint = style & CellFlags.FAINT;
|
|
712
|
+
if (faint) this.ctx.globalAlpha = 0.5;
|
|
713
|
+
this.ctx.fillText(
|
|
714
|
+
text,
|
|
715
|
+
x * this.metrics.width,
|
|
716
|
+
y * this.metrics.height + this.metrics.baseline
|
|
717
|
+
);
|
|
718
|
+
if (faint) this.ctx.globalAlpha = 1.0;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** One cell's glyph and decorations, for cells a run cannot hold and the glyph under a block cursor. */
|
|
722
|
+
private renderCellText(cell: GhosttyCell, x: number, y: number, color: string): void {
|
|
723
|
+
if (cell.flags & CellFlags.INVISIBLE) return;
|
|
724
|
+
if (cell.grapheme_len === 0 && isBoxGlyph(cell.codepoint)) {
|
|
725
|
+
this.setFill(color);
|
|
726
|
+
const faint = cell.flags & CellFlags.FAINT;
|
|
727
|
+
if (faint) this.ctx.globalAlpha = 0.5;
|
|
728
|
+
drawBoxGlyph(
|
|
729
|
+
this.ctx,
|
|
730
|
+
cell.codepoint,
|
|
731
|
+
x * this.metrics.width,
|
|
732
|
+
y * this.metrics.height,
|
|
733
|
+
this.metrics.width * cell.width,
|
|
734
|
+
this.metrics.height
|
|
735
|
+
);
|
|
736
|
+
if (faint) this.ctx.globalAlpha = 1.0;
|
|
737
|
+
this.resetContextCache();
|
|
738
|
+
} else {
|
|
739
|
+
const spacing = this.batchText ? this.ctx.letterSpacing : '';
|
|
740
|
+
if (this.batchText) this.ctx.letterSpacing = '0px';
|
|
741
|
+
this.drawText(this.cellText(cell, x, y), x, y, cell.flags & TEXT_STYLE_FLAGS, color);
|
|
742
|
+
if (this.batchText) this.ctx.letterSpacing = spacing;
|
|
743
|
+
}
|
|
744
|
+
this.renderDecorations(cell, x, y, color);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private renderDecorations(cell: GhosttyCell, x: number, y: number, color: string): void {
|
|
748
|
+
const underline = cell.flags & CellFlags.UNDERLINE;
|
|
749
|
+
const strike = cell.flags & CellFlags.STRIKETHROUGH;
|
|
750
|
+
const link = (cell.hyperlink_id > 0 || this.hoveredLinkRange) && this.linkHovered(cell, x, y);
|
|
751
|
+
if (!underline && !strike && !link) return;
|
|
752
|
+
const cellX = x * this.metrics.width;
|
|
753
|
+
const cellY = y * this.metrics.height;
|
|
754
|
+
const cellWidth = this.metrics.width * cell.width;
|
|
755
|
+
const underlineY = cellY + this.metrics.baseline + 2;
|
|
756
|
+
if (underline) this.strokeLine(cellX, underlineY, cellWidth, color);
|
|
757
|
+
if (strike) this.strokeLine(cellX, cellY + this.metrics.height / 2, cellWidth, color);
|
|
758
|
+
if (link) this.strokeLine(cellX, underlineY, cellWidth, LINK_COLOR);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
private strokeLine(x: number, y: number, width: number, color: string): void {
|
|
762
|
+
const ctx = this.ctx;
|
|
763
|
+
ctx.strokeStyle = color;
|
|
764
|
+
ctx.lineWidth = 1;
|
|
765
|
+
ctx.beginPath();
|
|
766
|
+
ctx.moveTo(x, y);
|
|
767
|
+
ctx.lineTo(x + width, y);
|
|
768
|
+
ctx.stroke();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private renderCursor(x: number, y: number): void {
|
|
772
|
+
const cursorX = x * this.metrics.width;
|
|
773
|
+
const cursorY = y * this.metrics.height;
|
|
774
|
+
this.setFill(this.theme.cursor);
|
|
775
|
+
if (this.cursorStyle === 'block') {
|
|
776
|
+
this.ctx.fillRect(cursorX, cursorY, this.metrics.width, this.metrics.height);
|
|
777
|
+
const line = this.currentBuffer?.getLine(y);
|
|
778
|
+
if (line?.[x]) {
|
|
779
|
+
this.ctx.save();
|
|
780
|
+
this.ctx.beginPath();
|
|
781
|
+
this.ctx.rect(cursorX, cursorY, this.metrics.width, this.metrics.height);
|
|
782
|
+
this.ctx.clip();
|
|
783
|
+
this.renderCellText(line[x], x, y, this.theme.cursorAccent);
|
|
784
|
+
this.ctx.restore();
|
|
785
|
+
this.resetContextCache();
|
|
786
|
+
}
|
|
787
|
+
} else if (this.cursorStyle === 'underline') {
|
|
788
|
+
const height = Math.max(2, Math.floor(this.metrics.height * 0.15));
|
|
789
|
+
this.ctx.fillRect(
|
|
790
|
+
cursorX,
|
|
791
|
+
cursorY + this.metrics.height - height,
|
|
792
|
+
this.metrics.width,
|
|
793
|
+
height
|
|
794
|
+
);
|
|
795
|
+
} else {
|
|
796
|
+
const width = Math.max(2, Math.floor(this.metrics.width * 0.15));
|
|
797
|
+
this.ctx.fillRect(cursorX, cursorY, width, this.metrics.height);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private renderScrollbar(overlay: FrameOverlay): void {
|
|
802
|
+
const ctx = this.ctx;
|
|
803
|
+
const canvasHeight = this.canvas.height / this.devicePixelRatio;
|
|
804
|
+
const canvasWidth = this.canvas.width / this.devicePixelRatio;
|
|
805
|
+
const scrollbarX = canvasWidth - 8 - 4;
|
|
806
|
+
ctx.clearRect(scrollbarX - 2, 0, 8 + 6, canvasHeight);
|
|
807
|
+
ctx.fillStyle = this.theme.background;
|
|
808
|
+
ctx.fillRect(scrollbarX - 2, 0, 8 + 6, canvasHeight);
|
|
809
|
+
this.resetContextCache();
|
|
810
|
+
const thumb = this.scrollbarThumb(overlay);
|
|
811
|
+
if (!thumb) return;
|
|
812
|
+
const opacity = overlay.scrollbarOpacity;
|
|
813
|
+
ctx.fillStyle = `rgba(128, 128, 128, ${0.1 * opacity})`;
|
|
814
|
+
ctx.fillRect(scrollbarX, thumb.trackY, thumb.width, thumb.trackHeight);
|
|
815
|
+
const baseOpacity = overlay.viewportY > 0 ? 0.5 : 0.3;
|
|
816
|
+
ctx.fillStyle = `rgba(128, 128, 128, ${baseOpacity * opacity})`;
|
|
817
|
+
ctx.fillRect(scrollbarX, thumb.thumbY, thumb.width, thumb.thumbHeight);
|
|
818
|
+
this.resetContextCache();
|
|
819
|
+
}
|
|
820
|
+
}
|