@aurigax-ai/ghostty-web 0.2.0 → 0.3.1

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.
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { drawBoxGlyph, isBoxGlyph } from './box-glyphs';
14
+ import { glyphInk } from './glyph-ink';
14
15
  import type { ITheme } from './interfaces';
15
16
  import {
16
17
  type FrameOverlay,
@@ -21,13 +22,20 @@ import {
21
22
  } from './renderer';
22
23
  import type { GhosttyCell } from './types';
23
24
  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;
25
+ import {
26
+ GLYPH_WORDS,
27
+ type InstanceArrays,
28
+ RECT_WORDS,
29
+ compactGlyphs,
30
+ compactRects,
31
+ instanceArrays,
32
+ } from './webgl-instances';
33
+
34
+ const CURSOR_RECTS = 4;
35
+ const SCROLLBAR_RECTS = 2;
36
+ const OVERLAY_RECTS = CURSOR_RECTS + SCROLLBAR_RECTS;
37
+ const ATLAS_START = 512;
29
38
  const ATLAS_MAX = 4096;
30
- const COLORED_SPREAD = 24;
31
39
 
32
40
  const VERTEX_RECT = `#version 300 es
33
41
  layout(location = 0) in vec2 a_corner;
@@ -86,11 +94,14 @@ void main() {
86
94
  }
87
95
  }`;
88
96
 
97
+ /** A glyph's ink in the atlas; `ox`, `oy` place it within the glyph's padded slot. */
89
98
  interface AtlasEntry {
90
99
  x: number;
91
100
  y: number;
92
101
  w: number;
93
102
  h: number;
103
+ ox: number;
104
+ oy: number;
94
105
  colored: boolean;
95
106
  }
96
107
 
@@ -99,10 +110,15 @@ function pack(r: number, g: number, b: number, a: number): number {
99
110
  return (r | (g << 8) | (b << 16) | (a << 24)) >>> 0;
100
111
  }
101
112
 
102
- /** Glyph atlas: a CPU canvas mirrored into a texture, so growing it keeps every entry. */
113
+ /**
114
+ * Glyph atlas: a texture packed in shelves of one slot height. Each glyph is
115
+ * rasterized in a scratch canvas the size of one slot and uploaded; the
116
+ * texture grows by copying itself on the GPU, so entries stay valid and no
117
+ * CPU copy of the atlas is kept.
118
+ */
103
119
  class GlyphAtlas {
104
120
  size = ATLAS_START;
105
- private mirror: HTMLCanvasElement;
121
+ private readonly scratch: HTMLCanvasElement;
106
122
  private ctx: CanvasRenderingContext2D;
107
123
  private entries = new Map<number | string, AtlasEntry | null>();
108
124
  private shelfX = 0;
@@ -115,31 +131,31 @@ class GlyphAtlas {
115
131
  private readonly gl: WebGL2RenderingContext,
116
132
  private slotHeight: number
117
133
  ) {
118
- this.mirror = document.createElement('canvas');
119
- this.ctx = this.newMirror(this.size);
120
- this.texture = gl.createTexture()!;
121
- this.allocateTexture();
134
+ this.scratch = document.createElement('canvas');
135
+ this.ctx = this.scratchContext(1, slotHeight);
136
+ this.texture = this.newTexture(this.size);
122
137
  }
123
138
 
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');
139
+ private scratchContext(w: number, h: number): CanvasRenderingContext2D {
140
+ if (this.scratch.width < w) this.scratch.width = w;
141
+ if (this.scratch.height !== h) this.scratch.height = h;
142
+ const ctx = this.scratch.getContext('2d', { willReadFrequently: true });
143
+ if (!ctx) throw new Error('Failed to get the glyph scratch context');
129
144
  ctx.textBaseline = 'alphabetic';
130
145
  ctx.textAlign = 'left';
131
146
  return ctx;
132
147
  }
133
148
 
134
- private allocateTexture(): void {
149
+ private newTexture(size: number): WebGLTexture {
135
150
  const gl = this.gl;
136
- gl.bindTexture(gl.TEXTURE_2D, this.texture);
137
- gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
151
+ const texture = gl.createTexture()!;
152
+ gl.bindTexture(gl.TEXTURE_2D, texture);
138
153
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
139
154
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
140
155
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
141
156
  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);
157
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, size, size, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
158
+ return texture;
143
159
  }
144
160
 
145
161
  /** Drops every glyph, for a new font or cell size. */
@@ -148,21 +164,37 @@ class GlyphAtlas {
148
164
  this.entries.clear();
149
165
  this.shelfX = 0;
150
166
  this.shelfY = 0;
151
- this.ctx.clearRect(0, 0, this.size, this.size);
152
- this.allocateTexture();
167
+ this.gl.deleteTexture(this.texture);
168
+ this.size = ATLAS_START;
169
+ this.texture = this.newTexture(this.size);
153
170
  }
154
171
 
155
172
  lookup(key: number | string): AtlasEntry | null | undefined {
156
173
  return this.entries.get(key);
157
174
  }
158
175
 
159
- /** Rasterizes a glyph with `draw` into a free slot w px wide and uploads it. */
176
+ /** Rasterizes a glyph with `draw` (at 0, 0 of a w px wide slot) and uploads it. */
160
177
  add(
161
178
  key: number | string,
162
179
  w: number,
163
180
  draw: (ctx: CanvasRenderingContext2D, x: number, y: number) => void
164
181
  ): AtlasEntry | null {
165
182
  const h = this.slotHeight;
183
+ if (this.scratch.width < w || this.scratch.height !== h) this.ctx = this.scratchContext(w, h);
184
+ const ctx = this.ctx;
185
+ ctx.save();
186
+ ctx.beginPath();
187
+ ctx.rect(0, 0, w, h);
188
+ ctx.clip();
189
+ ctx.clearRect(0, 0, w, h);
190
+ draw(ctx, 0, 0);
191
+ ctx.restore();
192
+ const pixels = ctx.getImageData(0, 0, w, h);
193
+ const ink = glyphInk(pixels.data, w, h);
194
+ if (!ink) {
195
+ this.entries.set(key, null);
196
+ return null;
197
+ }
166
198
  if (this.shelfX + w > this.size) {
167
199
  this.shelfX = 0;
168
200
  this.shelfY += h;
@@ -174,56 +206,42 @@ class GlyphAtlas {
174
206
  const x = this.shelfX;
175
207
  const y = this.shelfY;
176
208
  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
209
  const gl = this.gl;
206
210
  gl.bindTexture(gl.TEXTURE_2D, this.texture);
207
211
  gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
208
212
  gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
209
- const entry = { x, y, w, h, colored };
213
+ const entry = {
214
+ x: x + ink.left,
215
+ y: y + ink.top,
216
+ w: ink.width,
217
+ h: ink.height,
218
+ ox: ink.left,
219
+ oy: ink.top,
220
+ colored: ink.colored,
221
+ };
210
222
  this.entries.set(key, entry);
211
223
  return entry;
212
224
  }
213
225
 
226
+ /** Doubles the texture, copying the old one into its top-left corner on the GPU. */
214
227
  private grow(): boolean {
215
- const limit = Math.min(ATLAS_MAX, this.gl.getParameter(this.gl.MAX_TEXTURE_SIZE) as number);
228
+ const gl = this.gl;
229
+ const limit = Math.min(ATLAS_MAX, gl.getParameter(gl.MAX_TEXTURE_SIZE) as number);
216
230
  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);
231
+ const old = this.texture;
232
+ const oldSize = this.size;
221
233
  this.size *= 2;
222
- this.ctx = this.newMirror(this.size);
223
- this.ctx.drawImage(old, 0, 0);
224
- this.allocateTexture();
234
+ this.texture = this.newTexture(this.size);
235
+ const framebuffer = gl.createFramebuffer();
236
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, framebuffer);
237
+ gl.framebufferTexture2D(gl.READ_FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, old, 0);
238
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
239
+ gl.copyTexSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 0, 0, oldSize, oldSize);
240
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
241
+ gl.deleteFramebuffer(framebuffer);
242
+ gl.deleteTexture(old);
225
243
  this.shelfX = 0;
226
- this.shelfY = old.height;
244
+ this.shelfY = oldSize;
227
245
  return true;
228
246
  }
229
247
 
@@ -244,6 +262,7 @@ export class WebglRenderer extends TerminalRenderer {
244
262
  private readonly glyphBuffer: WebGLBuffer;
245
263
  private readonly overlayRectBuffer: WebGLBuffer;
246
264
  private readonly overlayGlyphBuffer: WebGLBuffer;
265
+ private readonly decoBuffer: WebGLBuffer;
247
266
  private bgVao!: WebGLVertexArrayObject;
248
267
  private decoVao!: WebGLVertexArrayObject;
249
268
  private glyphVao!: WebGLVertexArrayObject;
@@ -262,6 +281,12 @@ export class WebglRenderer extends TerminalRenderer {
262
281
  private rectColors = new Uint32Array(0);
263
282
  private glyphWords = new Float32Array(0);
264
283
  private glyphColors = new Uint32Array(0);
284
+ private packedBg = instanceArrays(0, RECT_WORDS);
285
+ private packedDeco = instanceArrays(0, RECT_WORDS);
286
+ private packedGlyphs = instanceArrays(0, GLYPH_WORDS);
287
+ private bgCount = 0;
288
+ private decoCount = 0;
289
+ private glyphCount = 0;
265
290
  private readonly overlayRects = new Float32Array(OVERLAY_RECTS * RECT_WORDS);
266
291
  private readonly overlayRectColors = new Uint32Array(this.overlayRects.buffer);
267
292
  private readonly overlayGlyph = new Float32Array(GLYPH_WORDS);
@@ -286,6 +311,8 @@ export class WebglRenderer extends TerminalRenderer {
286
311
  selectionBackground: number;
287
312
  selectionForeground: number;
288
313
  link: number;
314
+ match: number;
315
+ activeMatch: number;
289
316
  };
290
317
 
291
318
  /** Called once if the WebGL context is lost; the terminal then switches renderer. */
@@ -327,6 +354,7 @@ export class WebglRenderer extends TerminalRenderer {
327
354
  );
328
355
  this.rectBuffer = gl.createBuffer()!;
329
356
  this.glyphBuffer = gl.createBuffer()!;
357
+ this.decoBuffer = gl.createBuffer()!;
330
358
  this.overlayRectBuffer = gl.createBuffer()!;
331
359
  this.overlayGlyphBuffer = gl.createBuffer()!;
332
360
  gl.bindBuffer(gl.ARRAY_BUFFER, this.overlayRectBuffer);
@@ -335,7 +363,7 @@ export class WebglRenderer extends TerminalRenderer {
335
363
  gl.bufferData(gl.ARRAY_BUFFER, this.overlayGlyph.byteLength, gl.DYNAMIC_DRAW);
336
364
  this.overlayRectVao = this.rectVao(this.overlayRectBuffer, 0);
337
365
  this.overlayGlyphVao = this.glyphVaoFor(this.overlayGlyphBuffer);
338
- this.scrollbarVao = this.rectVao(this.overlayRectBuffer, RECT_WORDS * 4);
366
+ this.scrollbarVao = this.rectVao(this.overlayRectBuffer, CURSOR_RECTS * RECT_WORDS * 4);
339
367
  this.rectResolution = gl.getUniformLocation(this.rectProgram, 'u_resolution');
340
368
  this.glyphResolution = gl.getUniformLocation(this.glyphProgram, 'u_resolution');
341
369
  this.glyphAtlasSize = gl.getUniformLocation(this.glyphProgram, 'u_atlas');
@@ -410,16 +438,22 @@ export class WebglRenderer extends TerminalRenderer {
410
438
  const glyphs = new ArrayBuffer(cells * GLYPH_WORDS * 4);
411
439
  this.glyphWords = new Float32Array(glyphs);
412
440
  this.glyphColors = new Uint32Array(glyphs);
441
+ this.packedBg = instanceArrays(cells, RECT_WORDS);
442
+ this.packedDeco = instanceArrays(cells * 2, RECT_WORDS);
443
+ this.packedGlyphs = instanceArrays(cells, GLYPH_WORDS);
413
444
  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);
445
+ const allocate = (buffer: WebGLBuffer, packed: InstanceArrays) => {
446
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
447
+ gl.bufferData(gl.ARRAY_BUFFER, packed.bits.byteLength, gl.DYNAMIC_DRAW);
448
+ };
449
+ allocate(this.rectBuffer, this.packedBg);
450
+ allocate(this.decoBuffer, this.packedDeco);
451
+ allocate(this.glyphBuffer, this.packedGlyphs);
418
452
  for (const vao of [this.bgVao, this.decoVao, this.glyphVao]) {
419
453
  if (vao) gl.deleteVertexArray(vao);
420
454
  }
421
455
  this.bgVao = this.rectVao(this.rectBuffer, 0);
422
- this.decoVao = this.rectVao(this.rectBuffer, cells * RECT_WORDS * 4);
456
+ this.decoVao = this.rectVao(this.decoBuffer, 0);
423
457
  this.glyphVao = this.glyphVaoFor(this.glyphBuffer);
424
458
  }
425
459
  this.markAllRows();
@@ -443,6 +477,10 @@ export class WebglRenderer extends TerminalRenderer {
443
477
  this.markAllRows();
444
478
  }
445
479
 
480
+ protected searchColorsChanged(): void {
481
+ this.parseTheme();
482
+ }
483
+
446
484
  private markAllRows(): void {
447
485
  this.dirtyFrom = 0;
448
486
  this.dirtyTo = this.rows - 1;
@@ -477,6 +515,8 @@ export class WebglRenderer extends TerminalRenderer {
477
515
  selectionBackground: this.parseColor(t.selectionBackground),
478
516
  selectionForeground: this.parseColor(t.selectionForeground),
479
517
  link: this.parseColor(LINK_COLOR),
518
+ match: this.parseColor(this.highlightColors.match),
519
+ activeMatch: this.parseColor(this.highlightColors.active),
480
520
  };
481
521
  }
482
522
 
@@ -521,8 +561,11 @@ export class WebglRenderer extends TerminalRenderer {
521
561
  const inverse = cell.flags & CellFlags.INVERSE;
522
562
 
523
563
  let bgWord = 0;
564
+ const highlight = selected ? 0 : this.highlightAt(x, y);
524
565
  if (selected) {
525
566
  bgWord = theme.selectionBackground;
567
+ } else if (highlight) {
568
+ bgWord = highlight === 2 ? theme.activeMatch : theme.match;
526
569
  } else {
527
570
  const r = inverse ? cell.fg_r : cell.bg_r;
528
571
  const g = inverse ? cell.fg_g : cell.bg_g;
@@ -538,17 +581,26 @@ export class WebglRenderer extends TerminalRenderer {
538
581
  }
539
582
 
540
583
  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);
584
+ if (selected) {
585
+ fgWord = theme.selectionForeground;
586
+ } else {
587
+ const fg = inverse
588
+ ? (cell.bg_r << 16) | (cell.bg_g << 8) | cell.bg_b
589
+ : (cell.fg_r << 16) | (cell.fg_g << 8) | cell.fg_b;
590
+ const back = inverse
591
+ ? (cell.fg_r << 16) | (cell.fg_g << 8) | cell.fg_b
592
+ : (cell.bg_r << 16) | (cell.bg_g << 8) | cell.bg_b;
593
+ const shown = this.contrastedForeground(fg, back === 0 ? null : back);
594
+ fgWord = pack((shown >> 16) & 0xff, (shown >> 8) & 0xff, shown & 0xff, 255);
595
+ }
544
596
  if (cell.flags & CellFlags.FAINT) fgWord = ((fgWord & 0x00ffffff) | (128 << 24)) >>> 0;
545
597
  if (cell.flags & CellFlags.INVISIBLE) continue;
546
598
 
547
599
  if (!isBlank(cell)) {
548
600
  const entry = this.glyphFor(cell, x, y);
549
601
  if (entry) {
550
- glyphs[glyph] = left - this.padX;
551
- glyphs[glyph + 1] = top - this.padY;
602
+ glyphs[glyph] = left - this.padX + entry.ox;
603
+ glyphs[glyph + 1] = top - this.padY + entry.oy;
552
604
  glyphs[glyph + 2] = entry.x;
553
605
  glyphs[glyph + 3] = entry.y;
554
606
  glyphs[glyph + 4] = entry.w;
@@ -606,10 +658,7 @@ export class WebglRenderer extends TerminalRenderer {
606
658
  }
607
659
 
608
660
  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}`;
661
+ return this.fontString(style, this.fontSize * this.devicePixelRatio);
613
662
  }
614
663
 
615
664
  // ==========================================================================
@@ -650,7 +699,19 @@ export class WebglRenderer extends TerminalRenderer {
650
699
  rects[1] = top;
651
700
  rects[2] = width;
652
701
  rects[3] = height;
653
- if (this.cursorStyle === 'underline') {
702
+ if (!overlay.focused) {
703
+ const t = Math.max(1, Math.round(this.devicePixelRatio));
704
+ const edges = [
705
+ [left, top, width, t],
706
+ [left, top + height - t, width, t],
707
+ [left, top, t, height],
708
+ [left + width - t, top, t, height],
709
+ ];
710
+ edges.forEach((edge, i) => {
711
+ rects.set(edge, i * RECT_WORDS);
712
+ colors[i * RECT_WORDS + 4] = theme.cursor;
713
+ });
714
+ } else if (this.cursorStyle === 'underline') {
654
715
  const h = Math.max(2, Math.floor(height * 0.15));
655
716
  rects[1] = top + height - h;
656
717
  rects[3] = h;
@@ -658,15 +719,15 @@ export class WebglRenderer extends TerminalRenderer {
658
719
  rects[2] = Math.max(2, Math.floor(width * 0.15));
659
720
  }
660
721
  colors[4] = theme.cursor;
661
- key = `${left},${top},${this.cursorStyle}`;
662
- if (this.cursorStyle === 'block') {
722
+ key = `${left},${top},${this.cursorStyle},${overlay.focused}`;
723
+ if (overlay.focused && this.cursorStyle === 'block') {
663
724
  const cell = this.currentBuffer?.getLine(overlay.cursorY)?.[overlay.cursorX];
664
725
  if (cell && !isBlank(cell) && !(cell.flags & CellFlags.INVISIBLE)) {
665
726
  const entry = this.glyphFor(cell, overlay.cursorX, overlay.cursorY);
666
727
  if (entry) {
667
728
  const g = this.overlayGlyph;
668
- g[0] = left - this.padX;
669
- g[1] = top - this.padY;
729
+ g[0] = left - this.padX + entry.ox;
730
+ g[1] = top - this.padY + entry.oy;
670
731
  g[2] = entry.x;
671
732
  g[3] = entry.y;
672
733
  g[4] = entry.w;
@@ -683,13 +744,13 @@ export class WebglRenderer extends TerminalRenderer {
683
744
  const dpr = this.devicePixelRatio;
684
745
  const opacity = overlay.scrollbarOpacity;
685
746
  const base = overlay.viewportY > 0 ? 0.5 : 0.3;
686
- const track = RECT_WORDS;
747
+ const track = CURSOR_RECTS * RECT_WORDS;
687
748
  rects[track] = thumb.x * dpr;
688
749
  rects[track + 1] = thumb.trackY * dpr;
689
750
  rects[track + 2] = thumb.width * dpr;
690
751
  rects[track + 3] = thumb.trackHeight * dpr;
691
752
  colors[track + 4] = pack(128, 128, 128, Math.round(0.1 * opacity * 255));
692
- const bar = RECT_WORDS * 2;
753
+ const bar = (CURSOR_RECTS + 1) * RECT_WORDS;
693
754
  rects[bar] = thumb.x * dpr;
694
755
  rects[bar + 1] = thumb.thumbY * dpr;
695
756
  rects[bar + 2] = thumb.width * dpr;
@@ -704,16 +765,22 @@ export class WebglRenderer extends TerminalRenderer {
704
765
  if (this.dirtyTo < this.dirtyFrom) return;
705
766
  const gl = this.gl;
706
767
  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);
768
+ const slots = { words: this.rectWords, bits: this.rectColors };
769
+ this.bgCount = compactRects(slots, 0, cells, this.packedBg);
770
+ this.decoCount = compactRects(slots, cells, cells * 2, this.packedDeco);
771
+ this.glyphCount = compactGlyphs(
772
+ { words: this.glyphWords, bits: this.glyphColors },
773
+ cells,
774
+ this.packedGlyphs
775
+ );
776
+ const send = (buffer: WebGLBuffer, packed: InstanceArrays, count: number, wordsPer: number) => {
777
+ if (count === 0) return;
778
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
779
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, packed.bits, 0, count * wordsPer);
780
+ };
781
+ send(this.rectBuffer, this.packedBg, this.bgCount, RECT_WORDS);
782
+ send(this.decoBuffer, this.packedDeco, this.decoCount, RECT_WORDS);
783
+ send(this.glyphBuffer, this.packedGlyphs, this.glyphCount, GLYPH_WORDS);
717
784
  this.dirtyFrom = Number.POSITIVE_INFINITY;
718
785
  this.dirtyTo = -1;
719
786
  }
@@ -722,7 +789,6 @@ export class WebglRenderer extends TerminalRenderer {
722
789
  const gl = this.gl;
723
790
  const width = this.glCanvas.width;
724
791
  const height = this.glCanvas.height;
725
- const cells = this.cols * this.rows;
726
792
  gl.viewport(0, 0, width, height);
727
793
  const bg = this.themeWords.background;
728
794
  const a = (bg >>> 24) / 255;
@@ -736,8 +802,10 @@ export class WebglRenderer extends TerminalRenderer {
736
802
 
737
803
  gl.useProgram(this.rectProgram);
738
804
  gl.uniform2f(this.rectResolution, width, height);
739
- gl.bindVertexArray(this.bgVao);
740
- gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, cells);
805
+ if (this.bgCount > 0) {
806
+ gl.bindVertexArray(this.bgVao);
807
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.bgCount);
808
+ }
741
809
 
742
810
  gl.useProgram(this.glyphProgram);
743
811
  gl.uniform2f(this.glyphResolution, width, height);
@@ -745,18 +813,22 @@ export class WebglRenderer extends TerminalRenderer {
745
813
  gl.activeTexture(gl.TEXTURE0);
746
814
  gl.bindTexture(gl.TEXTURE_2D, this.atlas.texture);
747
815
  gl.uniform1i(this.glyphTexture, 0);
748
- gl.bindVertexArray(this.glyphVao);
749
- gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, cells);
816
+ if (this.glyphCount > 0) {
817
+ gl.bindVertexArray(this.glyphVao);
818
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.glyphCount);
819
+ }
750
820
 
751
821
  gl.useProgram(this.rectProgram);
752
- gl.bindVertexArray(this.decoVao);
753
- gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, cells * 2);
822
+ if (this.decoCount > 0) {
823
+ gl.bindVertexArray(this.decoVao);
824
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.decoCount);
825
+ }
754
826
 
755
827
  if (overlayKey) {
756
828
  gl.bindBuffer(gl.ARRAY_BUFFER, this.overlayRectBuffer);
757
829
  gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.overlayRects);
758
830
  gl.bindVertexArray(this.overlayRectVao);
759
- gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, 1);
831
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, CURSOR_RECTS);
760
832
  if (this.overlayGlyph[4] > 0) {
761
833
  gl.bindBuffer(gl.ARRAY_BUFFER, this.overlayGlyphBuffer);
762
834
  gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.overlayGlyph);
@@ -766,7 +838,7 @@ export class WebglRenderer extends TerminalRenderer {
766
838
  gl.useProgram(this.rectProgram);
767
839
  }
768
840
  gl.bindVertexArray(this.scrollbarVao);
769
- gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, OVERLAY_RECTS - 1);
841
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, SCROLLBAR_RECTS);
770
842
  }
771
843
  gl.bindVertexArray(null);
772
844
  }
@@ -832,6 +904,7 @@ export class WebglRenderer extends TerminalRenderer {
832
904
  for (const buffer of [
833
905
  this.quad,
834
906
  this.rectBuffer,
907
+ this.decoBuffer,
835
908
  this.glyphBuffer,
836
909
  this.overlayRectBuffer,
837
910
  this.overlayGlyphBuffer,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aurigax-ai/ghostty-web",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Ghostty's terminal engine (libghostty-vt) in the browser: WebAssembly build, an xterm.js-shaped API and WebGL and canvas renderers. A hard fork of coder/ghostty-web.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.ts",