@aurigax-ai/ghostty-web 0.1.0 → 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.
@@ -0,0 +1,870 @@
1
+ /**
2
+ * WebGL 2 renderer.
3
+ *
4
+ * Glyphs are rasterized once into an atlas texture (box drawing and block
5
+ * elements from geometry, so they fill their cells); each frame only the rows
6
+ * that changed are rewritten into instance buffers, and the whole grid is
7
+ * drawn with a handful of instanced draw calls. Drawing goes to a canvas of
8
+ * its own laid over the terminal's canvas, which keeps taking pointer events,
9
+ * so a terminal can drop back to `CanvasRenderer` on the same canvas when the
10
+ * context is lost.
11
+ */
12
+
13
+ import { drawBoxGlyph, isBoxGlyph } from './box-glyphs';
14
+ import type { ITheme } from './interfaces';
15
+ import {
16
+ type FrameOverlay,
17
+ LINK_COLOR,
18
+ type RendererOptions,
19
+ TerminalRenderer,
20
+ isBlank,
21
+ } from './renderer';
22
+ import type { GhosttyCell } from './types';
23
+ import { CellFlags } from './types';
24
+
25
+ const RECT_WORDS = 5;
26
+ const GLYPH_WORDS = 8;
27
+ const OVERLAY_RECTS = 4;
28
+ const ATLAS_START = 1024;
29
+ const ATLAS_MAX = 4096;
30
+ const COLORED_SPREAD = 24;
31
+
32
+ const VERTEX_RECT = `#version 300 es
33
+ layout(location = 0) in vec2 a_corner;
34
+ layout(location = 1) in vec4 a_rect;
35
+ layout(location = 2) in vec4 a_color;
36
+ uniform vec2 u_resolution;
37
+ out vec4 v_color;
38
+ void main() {
39
+ vec2 p = a_rect.xy + a_corner * a_rect.zw;
40
+ vec2 clip = p / u_resolution * 2.0 - 1.0;
41
+ gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
42
+ v_color = vec4(a_color.rgb * a_color.a, a_color.a);
43
+ }`;
44
+
45
+ const FRAGMENT_RECT = `#version 300 es
46
+ precision mediump float;
47
+ in vec4 v_color;
48
+ out vec4 outColor;
49
+ void main() {
50
+ outColor = v_color;
51
+ }`;
52
+
53
+ const VERTEX_GLYPH = `#version 300 es
54
+ layout(location = 0) in vec2 a_corner;
55
+ layout(location = 1) in vec2 a_pos;
56
+ layout(location = 2) in vec4 a_tex;
57
+ layout(location = 3) in vec4 a_color;
58
+ layout(location = 4) in float a_colored;
59
+ uniform vec2 u_resolution;
60
+ uniform vec2 u_atlas;
61
+ out vec2 v_uv;
62
+ out vec4 v_color;
63
+ out float v_colored;
64
+ void main() {
65
+ vec2 p = a_pos + a_corner * a_tex.zw;
66
+ vec2 clip = p / u_resolution * 2.0 - 1.0;
67
+ gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
68
+ v_uv = (a_tex.xy + a_corner * a_tex.zw) / u_atlas;
69
+ v_color = a_color;
70
+ v_colored = a_colored;
71
+ }`;
72
+
73
+ const FRAGMENT_GLYPH = `#version 300 es
74
+ precision mediump float;
75
+ uniform sampler2D u_texture;
76
+ in vec2 v_uv;
77
+ in vec4 v_color;
78
+ in float v_colored;
79
+ out vec4 outColor;
80
+ void main() {
81
+ vec4 texel = texture(u_texture, v_uv);
82
+ if (v_colored > 0.5) {
83
+ outColor = texel * v_color.a;
84
+ } else {
85
+ outColor = vec4(v_color.rgb, 1.0) * texel.a * v_color.a;
86
+ }
87
+ }`;
88
+
89
+ interface AtlasEntry {
90
+ x: number;
91
+ y: number;
92
+ w: number;
93
+ h: number;
94
+ colored: boolean;
95
+ }
96
+
97
+ /** Packs RGBA bytes into one little-endian word, as the GPU reads UNSIGNED_BYTE × 4. */
98
+ function pack(r: number, g: number, b: number, a: number): number {
99
+ return (r | (g << 8) | (b << 16) | (a << 24)) >>> 0;
100
+ }
101
+
102
+ /** Glyph atlas: a CPU canvas mirrored into a texture, so growing it keeps every entry. */
103
+ class GlyphAtlas {
104
+ size = ATLAS_START;
105
+ private mirror: HTMLCanvasElement;
106
+ private ctx: CanvasRenderingContext2D;
107
+ private entries = new Map<number | string, AtlasEntry | null>();
108
+ private shelfX = 0;
109
+ private shelfY = 0;
110
+ texture: WebGLTexture;
111
+ /** Set when the atlas had to be cleared: every entry handed out before is gone. */
112
+ cleared = false;
113
+
114
+ constructor(
115
+ private readonly gl: WebGL2RenderingContext,
116
+ private slotHeight: number
117
+ ) {
118
+ this.mirror = document.createElement('canvas');
119
+ this.ctx = this.newMirror(this.size);
120
+ this.texture = gl.createTexture()!;
121
+ this.allocateTexture();
122
+ }
123
+
124
+ private newMirror(size: number): CanvasRenderingContext2D {
125
+ this.mirror.width = size;
126
+ this.mirror.height = size;
127
+ const ctx = this.mirror.getContext('2d', { willReadFrequently: true });
128
+ if (!ctx) throw new Error('Failed to get the glyph atlas context');
129
+ ctx.textBaseline = 'alphabetic';
130
+ ctx.textAlign = 'left';
131
+ return ctx;
132
+ }
133
+
134
+ private allocateTexture(): void {
135
+ const gl = this.gl;
136
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
137
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
138
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
139
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
140
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
141
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
142
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.mirror);
143
+ }
144
+
145
+ /** Drops every glyph, for a new font or cell size. */
146
+ reset(slotHeight: number): void {
147
+ this.slotHeight = slotHeight;
148
+ this.entries.clear();
149
+ this.shelfX = 0;
150
+ this.shelfY = 0;
151
+ this.ctx.clearRect(0, 0, this.size, this.size);
152
+ this.allocateTexture();
153
+ }
154
+
155
+ lookup(key: number | string): AtlasEntry | null | undefined {
156
+ return this.entries.get(key);
157
+ }
158
+
159
+ /** Rasterizes a glyph with `draw` into a free slot w px wide and uploads it. */
160
+ add(
161
+ key: number | string,
162
+ w: number,
163
+ draw: (ctx: CanvasRenderingContext2D, x: number, y: number) => void
164
+ ): AtlasEntry | null {
165
+ const h = this.slotHeight;
166
+ if (this.shelfX + w > this.size) {
167
+ this.shelfX = 0;
168
+ this.shelfY += h;
169
+ }
170
+ if (this.shelfY + h > this.size && !this.grow()) {
171
+ this.reset(h);
172
+ this.cleared = true;
173
+ }
174
+ const x = this.shelfX;
175
+ const y = this.shelfY;
176
+ this.shelfX += w;
177
+ const ctx = this.ctx;
178
+ ctx.save();
179
+ ctx.beginPath();
180
+ ctx.rect(x, y, w, h);
181
+ ctx.clip();
182
+ ctx.clearRect(x, y, w, h);
183
+ draw(ctx, x, y);
184
+ ctx.restore();
185
+ const pixels = ctx.getImageData(x, y, w, h);
186
+ const data = pixels.data;
187
+ let inked = false;
188
+ let colored = false;
189
+ for (let i = 0; i < data.length; i += 4) {
190
+ if (data[i + 3] === 0) continue;
191
+ inked = true;
192
+ const r = data[i];
193
+ const g = data[i + 1];
194
+ const b = data[i + 2];
195
+ if (Math.max(r, g, b) - Math.min(r, g, b) > COLORED_SPREAD) {
196
+ colored = true;
197
+ break;
198
+ }
199
+ }
200
+ if (!inked) {
201
+ this.shelfX = x;
202
+ this.entries.set(key, null);
203
+ return null;
204
+ }
205
+ const gl = this.gl;
206
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
207
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
208
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
209
+ const entry = { x, y, w, h, colored };
210
+ this.entries.set(key, entry);
211
+ return entry;
212
+ }
213
+
214
+ private grow(): boolean {
215
+ const limit = Math.min(ATLAS_MAX, this.gl.getParameter(this.gl.MAX_TEXTURE_SIZE) as number);
216
+ if (this.size * 2 > limit) return false;
217
+ const old = document.createElement('canvas');
218
+ old.width = this.size;
219
+ old.height = this.size;
220
+ old.getContext('2d')!.drawImage(this.mirror, 0, 0);
221
+ this.size *= 2;
222
+ this.ctx = this.newMirror(this.size);
223
+ this.ctx.drawImage(old, 0, 0);
224
+ this.allocateTexture();
225
+ this.shelfX = 0;
226
+ this.shelfY = old.height;
227
+ return true;
228
+ }
229
+
230
+ dispose(): void {
231
+ this.gl.deleteTexture(this.texture);
232
+ this.entries.clear();
233
+ }
234
+ }
235
+
236
+ export class WebglRenderer extends TerminalRenderer {
237
+ protected readonly cursorInRows = false;
238
+ private readonly glCanvas: HTMLCanvasElement;
239
+ private readonly gl: WebGL2RenderingContext;
240
+ private readonly rectProgram: WebGLProgram;
241
+ private readonly glyphProgram: WebGLProgram;
242
+ private readonly quad: WebGLBuffer;
243
+ private readonly rectBuffer: WebGLBuffer;
244
+ private readonly glyphBuffer: WebGLBuffer;
245
+ private readonly overlayRectBuffer: WebGLBuffer;
246
+ private readonly overlayGlyphBuffer: WebGLBuffer;
247
+ private bgVao!: WebGLVertexArrayObject;
248
+ private decoVao!: WebGLVertexArrayObject;
249
+ private glyphVao!: WebGLVertexArrayObject;
250
+ private overlayRectVao!: WebGLVertexArrayObject;
251
+ private overlayGlyphVao!: WebGLVertexArrayObject;
252
+ private scrollbarVao!: WebGLVertexArrayObject;
253
+ private readonly rectResolution: WebGLUniformLocation | null;
254
+ private readonly glyphResolution: WebGLUniformLocation | null;
255
+ private readonly glyphAtlasSize: WebGLUniformLocation | null;
256
+ private readonly glyphTexture: WebGLUniformLocation | null;
257
+ private readonly atlas: GlyphAtlas;
258
+
259
+ private cols = 0;
260
+ private rows = 0;
261
+ private rectWords = new Float32Array(0);
262
+ private rectColors = new Uint32Array(0);
263
+ private glyphWords = new Float32Array(0);
264
+ private glyphColors = new Uint32Array(0);
265
+ private readonly overlayRects = new Float32Array(OVERLAY_RECTS * RECT_WORDS);
266
+ private readonly overlayRectColors = new Uint32Array(this.overlayRects.buffer);
267
+ private readonly overlayGlyph = new Float32Array(GLYPH_WORDS);
268
+ private readonly overlayGlyphColors = new Uint32Array(this.overlayGlyph.buffer);
269
+ private dirtyFrom = Number.POSITIVE_INFINITY;
270
+ private dirtyTo = -1;
271
+ private needsPresent = true;
272
+ private lastOverlay = '';
273
+ private lost = false;
274
+
275
+ private cellW = 0;
276
+ private cellH = 0;
277
+ private padX = 0;
278
+ private padY = 0;
279
+ private readonly parsedColors = new Map<string, number>();
280
+ private readonly colorProbe: CanvasRenderingContext2D;
281
+ private themeWords!: {
282
+ background: number;
283
+ foreground: number;
284
+ cursor: number;
285
+ cursorAccent: number;
286
+ selectionBackground: number;
287
+ selectionForeground: number;
288
+ link: number;
289
+ };
290
+
291
+ /** Called once if the WebGL context is lost; the terminal then switches renderer. */
292
+ public onContextLost?: () => void;
293
+
294
+ constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) {
295
+ super(canvas, options);
296
+ const glCanvas = document.createElement('canvas');
297
+ glCanvas.style.position = 'absolute';
298
+ glCanvas.style.pointerEvents = 'none';
299
+ glCanvas.style.display = 'block';
300
+ glCanvas.setAttribute('aria-hidden', 'true');
301
+ const gl = glCanvas.getContext('webgl2', {
302
+ alpha: true,
303
+ antialias: false,
304
+ depth: false,
305
+ stencil: false,
306
+ premultipliedAlpha: true,
307
+ preserveDrawingBuffer: false,
308
+ });
309
+ if (!gl) throw new Error('WebGL 2 is not available');
310
+ this.glCanvas = glCanvas;
311
+ this.gl = gl;
312
+ glCanvas.addEventListener('webglcontextlost', this.handleContextLost);
313
+
314
+ const probe = document.createElement('canvas');
315
+ probe.width = 1;
316
+ probe.height = 1;
317
+ this.colorProbe = probe.getContext('2d', { willReadFrequently: true })!;
318
+
319
+ this.rectProgram = linkProgram(gl, VERTEX_RECT, FRAGMENT_RECT);
320
+ this.glyphProgram = linkProgram(gl, VERTEX_GLYPH, FRAGMENT_GLYPH);
321
+ this.quad = gl.createBuffer()!;
322
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
323
+ gl.bufferData(
324
+ gl.ARRAY_BUFFER,
325
+ new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]),
326
+ gl.STATIC_DRAW
327
+ );
328
+ this.rectBuffer = gl.createBuffer()!;
329
+ this.glyphBuffer = gl.createBuffer()!;
330
+ this.overlayRectBuffer = gl.createBuffer()!;
331
+ this.overlayGlyphBuffer = gl.createBuffer()!;
332
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.overlayRectBuffer);
333
+ gl.bufferData(gl.ARRAY_BUFFER, this.overlayRects.byteLength, gl.DYNAMIC_DRAW);
334
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.overlayGlyphBuffer);
335
+ gl.bufferData(gl.ARRAY_BUFFER, this.overlayGlyph.byteLength, gl.DYNAMIC_DRAW);
336
+ this.overlayRectVao = this.rectVao(this.overlayRectBuffer, 0);
337
+ this.overlayGlyphVao = this.glyphVaoFor(this.overlayGlyphBuffer);
338
+ this.scrollbarVao = this.rectVao(this.overlayRectBuffer, RECT_WORDS * 4);
339
+ this.rectResolution = gl.getUniformLocation(this.rectProgram, 'u_resolution');
340
+ this.glyphResolution = gl.getUniformLocation(this.glyphProgram, 'u_resolution');
341
+ this.glyphAtlasSize = gl.getUniformLocation(this.glyphProgram, 'u_atlas');
342
+ this.glyphTexture = gl.getUniformLocation(this.glyphProgram, 'u_texture');
343
+
344
+ this.measureCellPixels();
345
+ this.atlas = new GlyphAtlas(gl, this.slotHeight());
346
+ this.parseTheme();
347
+ gl.enable(gl.BLEND);
348
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
349
+ }
350
+
351
+ private readonly handleContextLost = (event: Event): void => {
352
+ event.preventDefault();
353
+ if (this.lost) return;
354
+ this.lost = true;
355
+ this.onContextLost?.();
356
+ };
357
+
358
+ /** Whether the context was lost; such a renderer never draws again. */
359
+ get contextLost(): boolean {
360
+ return this.lost;
361
+ }
362
+
363
+ // ==========================================================================
364
+ // Geometry
365
+ // ==========================================================================
366
+
367
+ private measureCellPixels(): void {
368
+ const dpr = this.devicePixelRatio;
369
+ this.cellW = this.metrics.width * dpr;
370
+ this.cellH = this.metrics.height * dpr;
371
+ this.padX = Math.ceil(this.cellW / 2);
372
+ this.padY = Math.ceil(this.cellH / 4);
373
+ }
374
+
375
+ private slotHeight(): number {
376
+ return Math.ceil(this.cellH) + 2 * this.padY;
377
+ }
378
+
379
+ protected surfaceMismatch(cols: number, rows: number): boolean {
380
+ return (
381
+ cols !== this.cols ||
382
+ rows !== this.rows ||
383
+ this.glCanvas.width !== Math.round(cols * this.cellW) ||
384
+ this.glCanvas.height !== Math.round(rows * this.cellH)
385
+ );
386
+ }
387
+
388
+ public resize(cols: number, rows: number): void {
389
+ const cssWidth = cols * this.metrics.width;
390
+ const cssHeight = rows * this.metrics.height;
391
+ this.canvas.style.width = `${cssWidth}px`;
392
+ this.canvas.style.height = `${cssHeight}px`;
393
+ this.canvas.width = cssWidth;
394
+ this.canvas.height = cssHeight;
395
+ if (!this.glCanvas.isConnected) this.canvas.after(this.glCanvas);
396
+ this.glCanvas.style.left = `${this.canvas.offsetLeft}px`;
397
+ this.glCanvas.style.top = `${this.canvas.offsetTop}px`;
398
+ this.glCanvas.style.width = `${cssWidth}px`;
399
+ this.glCanvas.style.height = `${cssHeight}px`;
400
+ this.glCanvas.width = Math.round(cols * this.cellW);
401
+ this.glCanvas.height = Math.round(rows * this.cellH);
402
+
403
+ if (cols !== this.cols || rows !== this.rows) {
404
+ this.cols = cols;
405
+ this.rows = rows;
406
+ const cells = cols * rows;
407
+ const rects = new ArrayBuffer(cells * 3 * RECT_WORDS * 4);
408
+ this.rectWords = new Float32Array(rects);
409
+ this.rectColors = new Uint32Array(rects);
410
+ const glyphs = new ArrayBuffer(cells * GLYPH_WORDS * 4);
411
+ this.glyphWords = new Float32Array(glyphs);
412
+ this.glyphColors = new Uint32Array(glyphs);
413
+ const gl = this.gl;
414
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.rectBuffer);
415
+ gl.bufferData(gl.ARRAY_BUFFER, rects.byteLength, gl.DYNAMIC_DRAW);
416
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.glyphBuffer);
417
+ gl.bufferData(gl.ARRAY_BUFFER, glyphs.byteLength, gl.DYNAMIC_DRAW);
418
+ for (const vao of [this.bgVao, this.decoVao, this.glyphVao]) {
419
+ if (vao) gl.deleteVertexArray(vao);
420
+ }
421
+ this.bgVao = this.rectVao(this.rectBuffer, 0);
422
+ this.decoVao = this.rectVao(this.rectBuffer, cells * RECT_WORDS * 4);
423
+ this.glyphVao = this.glyphVaoFor(this.glyphBuffer);
424
+ }
425
+ this.markAllRows();
426
+ }
427
+
428
+ public clear(): void {
429
+ this.rectWords.fill(0);
430
+ this.glyphWords.fill(0);
431
+ this.markAllRows();
432
+ }
433
+
434
+ protected fontChanged(): void {
435
+ this.measureCellPixels();
436
+ this.atlas.reset(this.slotHeight());
437
+ this.markAllRows();
438
+ }
439
+
440
+ public setTheme(theme: ITheme): void {
441
+ super.setTheme(theme);
442
+ this.parseTheme();
443
+ this.markAllRows();
444
+ }
445
+
446
+ private markAllRows(): void {
447
+ this.dirtyFrom = 0;
448
+ this.dirtyTo = this.rows - 1;
449
+ this.needsPresent = true;
450
+ }
451
+
452
+ // ==========================================================================
453
+ // Colors
454
+ // ==========================================================================
455
+
456
+ private parseColor(css: string): number {
457
+ let word = this.parsedColors.get(css);
458
+ if (word !== undefined) return word;
459
+ const ctx = this.colorProbe;
460
+ ctx.clearRect(0, 0, 1, 1);
461
+ ctx.fillStyle = '#000';
462
+ ctx.fillStyle = css;
463
+ ctx.fillRect(0, 0, 1, 1);
464
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
465
+ word = pack(r, g, b, a);
466
+ this.parsedColors.set(css, word);
467
+ return word;
468
+ }
469
+
470
+ private parseTheme(): void {
471
+ const t = this.theme;
472
+ this.themeWords = {
473
+ background: this.parseColor(t.background),
474
+ foreground: this.parseColor(t.foreground),
475
+ cursor: this.parseColor(t.cursor),
476
+ cursorAccent: this.parseColor(t.cursorAccent),
477
+ selectionBackground: this.parseColor(t.selectionBackground),
478
+ selectionForeground: this.parseColor(t.selectionForeground),
479
+ link: this.parseColor(LINK_COLOR),
480
+ };
481
+ }
482
+
483
+ // ==========================================================================
484
+ // Rows
485
+ // ==========================================================================
486
+
487
+ protected drawLine(line: GhosttyCell[], y: number, cols: number): void {
488
+ if (y < 0 || y >= this.rows) return;
489
+ const cells = this.cols * this.rows;
490
+ const rects = this.rectWords;
491
+ const rectColors = this.rectColors;
492
+ const glyphs = this.glyphWords;
493
+ const glyphColors = this.glyphColors;
494
+ const cellW = this.cellW;
495
+ const cellH = this.cellH;
496
+ const top = Math.round(y * cellH);
497
+ const bottom = Math.round((y + 1) * cellH);
498
+ const dpr = this.devicePixelRatio;
499
+ const underlineY = Math.round(y * cellH + (this.metrics.baseline + 2) * dpr);
500
+ const strikeY = Math.round(y * cellH + cellH / 2);
501
+ const thickness = Math.max(1, Math.round(dpr));
502
+ const theme = this.themeWords;
503
+ const width = Math.min(cols, this.cols);
504
+
505
+ for (let x = 0; x < this.cols; x++) {
506
+ const index = y * this.cols + x;
507
+ const bg = index * RECT_WORDS;
508
+ const deco1 = (cells + index) * RECT_WORDS;
509
+ const deco2 = (cells * 2 + index) * RECT_WORDS;
510
+ const glyph = index * GLYPH_WORDS;
511
+ rects[bg + 2] = 0;
512
+ rects[deco1 + 2] = 0;
513
+ rects[deco2 + 2] = 0;
514
+ glyphs[glyph + 4] = 0;
515
+ const cell = x < width ? line[x] : undefined;
516
+ if (!cell || cell.width === 0) continue;
517
+
518
+ const left = Math.round(x * cellW);
519
+ const right = Math.round((x + cell.width) * cellW);
520
+ const selected = this.isInSelection(x, y);
521
+ const inverse = cell.flags & CellFlags.INVERSE;
522
+
523
+ let bgWord = 0;
524
+ if (selected) {
525
+ bgWord = theme.selectionBackground;
526
+ } else {
527
+ const r = inverse ? cell.fg_r : cell.bg_r;
528
+ const g = inverse ? cell.fg_g : cell.bg_g;
529
+ const b = inverse ? cell.fg_b : cell.bg_b;
530
+ if (r !== 0 || g !== 0 || b !== 0) bgWord = pack(r, g, b, 255);
531
+ }
532
+ if (bgWord !== 0) {
533
+ rects[bg] = left;
534
+ rects[bg + 1] = top;
535
+ rects[bg + 2] = right - left;
536
+ rects[bg + 3] = bottom - top;
537
+ rectColors[bg + 4] = bgWord;
538
+ }
539
+
540
+ let fgWord: number;
541
+ if (selected) fgWord = theme.selectionForeground;
542
+ else if (inverse) fgWord = pack(cell.bg_r, cell.bg_g, cell.bg_b, 255);
543
+ else fgWord = pack(cell.fg_r, cell.fg_g, cell.fg_b, 255);
544
+ if (cell.flags & CellFlags.FAINT) fgWord = ((fgWord & 0x00ffffff) | (128 << 24)) >>> 0;
545
+ if (cell.flags & CellFlags.INVISIBLE) continue;
546
+
547
+ if (!isBlank(cell)) {
548
+ const entry = this.glyphFor(cell, x, y);
549
+ if (entry) {
550
+ glyphs[glyph] = left - this.padX;
551
+ glyphs[glyph + 1] = top - this.padY;
552
+ glyphs[glyph + 2] = entry.x;
553
+ glyphs[glyph + 3] = entry.y;
554
+ glyphs[glyph + 4] = entry.w;
555
+ glyphs[glyph + 5] = entry.h;
556
+ glyphColors[glyph + 6] = fgWord;
557
+ glyphs[glyph + 7] = entry.colored ? 1 : 0;
558
+ }
559
+ }
560
+
561
+ const link = (cell.hyperlink_id > 0 || this.hoveredLinkRange) && this.linkHovered(cell, x, y);
562
+ if (link || cell.flags & CellFlags.UNDERLINE) {
563
+ rects[deco1] = left;
564
+ rects[deco1 + 1] = underlineY;
565
+ rects[deco1 + 2] = right - left;
566
+ rects[deco1 + 3] = thickness;
567
+ rectColors[deco1 + 4] = link ? theme.link : fgWord;
568
+ }
569
+ if (cell.flags & CellFlags.STRIKETHROUGH) {
570
+ rects[deco2] = left;
571
+ rects[deco2 + 1] = strikeY;
572
+ rects[deco2 + 2] = right - left;
573
+ rects[deco2 + 3] = thickness;
574
+ rectColors[deco2 + 4] = fgWord;
575
+ }
576
+ }
577
+ if (y < this.dirtyFrom) this.dirtyFrom = y;
578
+ if (y > this.dirtyTo) this.dirtyTo = y;
579
+ this.needsPresent = true;
580
+ }
581
+
582
+ private glyphFor(cell: GhosttyCell, x: number, y: number): ReturnType<GlyphAtlas['lookup']> {
583
+ const style = cell.flags & (CellFlags.BOLD | CellFlags.ITALIC);
584
+ const variant = (cell.flags & CellFlags.BOLD ? 1 : 0) | (cell.flags & CellFlags.ITALIC ? 2 : 0);
585
+ const simple = cell.grapheme_len === 0;
586
+ const key: number | string = simple
587
+ ? cell.codepoint * 16 + variant * 4 + cell.width
588
+ : `${this.cellText(cell, x, y)}\u0000${variant}\u0000${cell.width}`;
589
+ const cached = this.atlas.lookup(key);
590
+ if (cached !== undefined) return cached;
591
+ const slotW = Math.ceil(this.cellW * cell.width) + 2 * this.padX;
592
+ const glyphW = this.cellW * cell.width;
593
+ const font = this.atlasFont(style);
594
+ const text = simple ? String.fromCodePoint(cell.codepoint) : this.cellText(cell, x, y);
595
+ const box = simple && isBoxGlyph(cell.codepoint);
596
+ const baseline = this.metrics.baseline * this.devicePixelRatio;
597
+ return this.atlas.add(key, slotW, (ctx, sx, sy) => {
598
+ ctx.fillStyle = '#ffffff';
599
+ if (box) {
600
+ drawBoxGlyph(ctx, cell.codepoint, sx + this.padX, sy + this.padY, glyphW, this.cellH);
601
+ } else {
602
+ ctx.font = font;
603
+ ctx.fillText(text, sx + this.padX, sy + this.padY + baseline);
604
+ }
605
+ });
606
+ }
607
+
608
+ private atlasFont(style: number): string {
609
+ let prefix = '';
610
+ if (style & CellFlags.ITALIC) prefix += 'italic ';
611
+ if (style & CellFlags.BOLD) prefix += 'bold ';
612
+ return `${prefix}${this.fontSize * this.devicePixelRatio}px ${this.fontFamily}`;
613
+ }
614
+
615
+ // ==========================================================================
616
+ // Frame
617
+ // ==========================================================================
618
+
619
+ protected finishFrame(overlay: FrameOverlay): void {
620
+ if (this.lost) return;
621
+ if (this.atlas.cleared) {
622
+ this.atlas.cleared = false;
623
+ this.markAllRows();
624
+ this.requestFullRedraw();
625
+ this.onNeedsFrame?.();
626
+ return;
627
+ }
628
+ const overlayKey = this.buildOverlay(overlay);
629
+ if (!this.needsPresent && overlayKey === this.lastOverlay) return;
630
+ this.lastOverlay = overlayKey;
631
+ this.needsPresent = false;
632
+ this.upload();
633
+ this.present(overlayKey);
634
+ }
635
+
636
+ /** Fills the overlay buffers and returns a key that changes whenever they do. */
637
+ private buildOverlay(overlay: FrameOverlay): string {
638
+ const rects = this.overlayRects;
639
+ const colors = this.overlayRectColors;
640
+ rects.fill(0);
641
+ this.overlayGlyph.fill(0);
642
+ const theme = this.themeWords;
643
+ let key = '';
644
+ if (overlay.showCursor && overlay.cursorX >= 0 && overlay.cursorY >= 0) {
645
+ const left = Math.round(overlay.cursorX * this.cellW);
646
+ const top = Math.round(overlay.cursorY * this.cellH);
647
+ const width = Math.round((overlay.cursorX + 1) * this.cellW) - left;
648
+ const height = Math.round((overlay.cursorY + 1) * this.cellH) - top;
649
+ rects[0] = left;
650
+ rects[1] = top;
651
+ rects[2] = width;
652
+ rects[3] = height;
653
+ if (this.cursorStyle === 'underline') {
654
+ const h = Math.max(2, Math.floor(height * 0.15));
655
+ rects[1] = top + height - h;
656
+ rects[3] = h;
657
+ } else if (this.cursorStyle === 'bar') {
658
+ rects[2] = Math.max(2, Math.floor(width * 0.15));
659
+ }
660
+ colors[4] = theme.cursor;
661
+ key = `${left},${top},${this.cursorStyle}`;
662
+ if (this.cursorStyle === 'block') {
663
+ const cell = this.currentBuffer?.getLine(overlay.cursorY)?.[overlay.cursorX];
664
+ if (cell && !isBlank(cell) && !(cell.flags & CellFlags.INVISIBLE)) {
665
+ const entry = this.glyphFor(cell, overlay.cursorX, overlay.cursorY);
666
+ if (entry) {
667
+ const g = this.overlayGlyph;
668
+ g[0] = left - this.padX;
669
+ g[1] = top - this.padY;
670
+ g[2] = entry.x;
671
+ g[3] = entry.y;
672
+ g[4] = entry.w;
673
+ g[5] = entry.h;
674
+ this.overlayGlyphColors[6] = theme.cursorAccent;
675
+ g[7] = entry.colored ? 1 : 0;
676
+ key += `,${entry.x},${entry.y}`;
677
+ }
678
+ }
679
+ }
680
+ }
681
+ const thumb = this.scrollbarThumb(overlay);
682
+ if (thumb) {
683
+ const dpr = this.devicePixelRatio;
684
+ const opacity = overlay.scrollbarOpacity;
685
+ const base = overlay.viewportY > 0 ? 0.5 : 0.3;
686
+ const track = RECT_WORDS;
687
+ rects[track] = thumb.x * dpr;
688
+ rects[track + 1] = thumb.trackY * dpr;
689
+ rects[track + 2] = thumb.width * dpr;
690
+ rects[track + 3] = thumb.trackHeight * dpr;
691
+ colors[track + 4] = pack(128, 128, 128, Math.round(0.1 * opacity * 255));
692
+ const bar = RECT_WORDS * 2;
693
+ rects[bar] = thumb.x * dpr;
694
+ rects[bar + 1] = thumb.thumbY * dpr;
695
+ rects[bar + 2] = thumb.width * dpr;
696
+ rects[bar + 3] = thumb.thumbHeight * dpr;
697
+ colors[bar + 4] = pack(128, 128, 128, Math.round(base * opacity * 255));
698
+ key += `|${thumb.thumbY},${thumb.thumbHeight},${opacity},${base}`;
699
+ }
700
+ return key;
701
+ }
702
+
703
+ private upload(): void {
704
+ if (this.dirtyTo < this.dirtyFrom) return;
705
+ const gl = this.gl;
706
+ const cells = this.cols * this.rows;
707
+ const from = this.dirtyFrom * this.cols;
708
+ const count = (this.dirtyTo - this.dirtyFrom + 1) * this.cols;
709
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.rectBuffer);
710
+ for (let segment = 0; segment < 3; segment++) {
711
+ const start = (segment * cells + from) * RECT_WORDS;
712
+ gl.bufferSubData(gl.ARRAY_BUFFER, start * 4, this.rectWords, start, count * RECT_WORDS);
713
+ }
714
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.glyphBuffer);
715
+ const start = from * GLYPH_WORDS;
716
+ gl.bufferSubData(gl.ARRAY_BUFFER, start * 4, this.glyphWords, start, count * GLYPH_WORDS);
717
+ this.dirtyFrom = Number.POSITIVE_INFINITY;
718
+ this.dirtyTo = -1;
719
+ }
720
+
721
+ private present(overlayKey: string): void {
722
+ const gl = this.gl;
723
+ const width = this.glCanvas.width;
724
+ const height = this.glCanvas.height;
725
+ const cells = this.cols * this.rows;
726
+ gl.viewport(0, 0, width, height);
727
+ const bg = this.themeWords.background;
728
+ const a = (bg >>> 24) / 255;
729
+ gl.clearColor(
730
+ ((bg & 0xff) / 255) * a,
731
+ (((bg >>> 8) & 0xff) / 255) * a,
732
+ (((bg >>> 16) & 0xff) / 255) * a,
733
+ a
734
+ );
735
+ gl.clear(gl.COLOR_BUFFER_BIT);
736
+
737
+ gl.useProgram(this.rectProgram);
738
+ gl.uniform2f(this.rectResolution, width, height);
739
+ gl.bindVertexArray(this.bgVao);
740
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, cells);
741
+
742
+ gl.useProgram(this.glyphProgram);
743
+ gl.uniform2f(this.glyphResolution, width, height);
744
+ gl.uniform2f(this.glyphAtlasSize, this.atlas.size, this.atlas.size);
745
+ gl.activeTexture(gl.TEXTURE0);
746
+ gl.bindTexture(gl.TEXTURE_2D, this.atlas.texture);
747
+ gl.uniform1i(this.glyphTexture, 0);
748
+ gl.bindVertexArray(this.glyphVao);
749
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, cells);
750
+
751
+ gl.useProgram(this.rectProgram);
752
+ gl.bindVertexArray(this.decoVao);
753
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, cells * 2);
754
+
755
+ if (overlayKey) {
756
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.overlayRectBuffer);
757
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.overlayRects);
758
+ gl.bindVertexArray(this.overlayRectVao);
759
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, 1);
760
+ if (this.overlayGlyph[4] > 0) {
761
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.overlayGlyphBuffer);
762
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.overlayGlyph);
763
+ gl.useProgram(this.glyphProgram);
764
+ gl.bindVertexArray(this.overlayGlyphVao);
765
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, 1);
766
+ gl.useProgram(this.rectProgram);
767
+ }
768
+ gl.bindVertexArray(this.scrollbarVao);
769
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, OVERLAY_RECTS - 1);
770
+ }
771
+ gl.bindVertexArray(null);
772
+ }
773
+
774
+ // ==========================================================================
775
+ // GL plumbing
776
+ // ==========================================================================
777
+
778
+ private rectVao(buffer: WebGLBuffer, byteOffset: number): WebGLVertexArrayObject {
779
+ const gl = this.gl;
780
+ const vao = gl.createVertexArray()!;
781
+ gl.bindVertexArray(vao);
782
+ this.bindQuad();
783
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
784
+ const stride = RECT_WORDS * 4;
785
+ gl.enableVertexAttribArray(1);
786
+ gl.vertexAttribPointer(1, 4, gl.FLOAT, false, stride, byteOffset);
787
+ gl.vertexAttribDivisor(1, 1);
788
+ gl.enableVertexAttribArray(2);
789
+ gl.vertexAttribPointer(2, 4, gl.UNSIGNED_BYTE, true, stride, byteOffset + 16);
790
+ gl.vertexAttribDivisor(2, 1);
791
+ gl.bindVertexArray(null);
792
+ return vao;
793
+ }
794
+
795
+ private glyphVaoFor(buffer: WebGLBuffer): WebGLVertexArrayObject {
796
+ const gl = this.gl;
797
+ const vao = gl.createVertexArray()!;
798
+ gl.bindVertexArray(vao);
799
+ this.bindQuad();
800
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
801
+ const stride = GLYPH_WORDS * 4;
802
+ gl.enableVertexAttribArray(1);
803
+ gl.vertexAttribPointer(1, 2, gl.FLOAT, false, stride, 0);
804
+ gl.vertexAttribDivisor(1, 1);
805
+ gl.enableVertexAttribArray(2);
806
+ gl.vertexAttribPointer(2, 4, gl.FLOAT, false, stride, 8);
807
+ gl.vertexAttribDivisor(2, 1);
808
+ gl.enableVertexAttribArray(3);
809
+ gl.vertexAttribPointer(3, 4, gl.UNSIGNED_BYTE, true, stride, 24);
810
+ gl.vertexAttribDivisor(3, 1);
811
+ gl.enableVertexAttribArray(4);
812
+ gl.vertexAttribPointer(4, 1, gl.FLOAT, false, stride, 28);
813
+ gl.vertexAttribDivisor(4, 1);
814
+ gl.bindVertexArray(null);
815
+ return vao;
816
+ }
817
+
818
+ private bindQuad(): void {
819
+ const gl = this.gl;
820
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
821
+ gl.enableVertexAttribArray(0);
822
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
823
+ }
824
+
825
+ public dispose(): void {
826
+ super.dispose();
827
+ this.glCanvas.removeEventListener('webglcontextlost', this.handleContextLost);
828
+ this.glCanvas.remove();
829
+ if (!this.lost) {
830
+ const gl = this.gl;
831
+ this.atlas.dispose();
832
+ for (const buffer of [
833
+ this.quad,
834
+ this.rectBuffer,
835
+ this.glyphBuffer,
836
+ this.overlayRectBuffer,
837
+ this.overlayGlyphBuffer,
838
+ ]) {
839
+ gl.deleteBuffer(buffer);
840
+ }
841
+ gl.deleteProgram(this.rectProgram);
842
+ gl.deleteProgram(this.glyphProgram);
843
+ gl.getExtension('WEBGL_lose_context')?.loseContext();
844
+ }
845
+ }
846
+ }
847
+
848
+ function linkProgram(gl: WebGL2RenderingContext, vertex: string, fragment: string): WebGLProgram {
849
+ const compile = (type: number, source: string) => {
850
+ const shader = gl.createShader(type)!;
851
+ gl.shaderSource(shader, source);
852
+ gl.compileShader(shader);
853
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
854
+ throw new Error(`Shader failed to compile: ${gl.getShaderInfoLog(shader)}`);
855
+ }
856
+ return shader;
857
+ };
858
+ const program = gl.createProgram()!;
859
+ const vs = compile(gl.VERTEX_SHADER, vertex);
860
+ const fs = compile(gl.FRAGMENT_SHADER, fragment);
861
+ gl.attachShader(program, vs);
862
+ gl.attachShader(program, fs);
863
+ gl.linkProgram(program);
864
+ gl.deleteShader(vs);
865
+ gl.deleteShader(fs);
866
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
867
+ throw new Error(`Shader program failed to link: ${gl.getProgramInfoLog(program)}`);
868
+ }
869
+ return program;
870
+ }