@next_term/web 0.1.0-next.3 → 0.1.0-next.6

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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/dist/accessibility.d.ts +2 -4
  3. package/dist/accessibility.d.ts.map +1 -1
  4. package/dist/accessibility.js +2 -7
  5. package/dist/accessibility.js.map +1 -1
  6. package/dist/addons/web-links.d.ts +0 -1
  7. package/dist/addons/web-links.d.ts.map +1 -1
  8. package/dist/addons/web-links.js +0 -4
  9. package/dist/addons/web-links.js.map +1 -1
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/input-handler.d.ts +7 -0
  15. package/dist/input-handler.d.ts.map +1 -1
  16. package/dist/input-handler.js +26 -5
  17. package/dist/input-handler.js.map +1 -1
  18. package/dist/render-worker.d.ts.map +1 -1
  19. package/dist/render-worker.js +186 -95
  20. package/dist/render-worker.js.map +1 -1
  21. package/dist/renderer.d.ts.map +1 -1
  22. package/dist/renderer.js +1 -0
  23. package/dist/renderer.js.map +1 -1
  24. package/dist/shared-context.d.ts +34 -9
  25. package/dist/shared-context.d.ts.map +1 -1
  26. package/dist/shared-context.js +433 -189
  27. package/dist/shared-context.js.map +1 -1
  28. package/dist/web-terminal.d.ts +15 -0
  29. package/dist/web-terminal.d.ts.map +1 -1
  30. package/dist/web-terminal.js +79 -13
  31. package/dist/web-terminal.js.map +1 -1
  32. package/dist/webgl-renderer.d.ts +22 -5
  33. package/dist/webgl-renderer.d.ts.map +1 -1
  34. package/dist/webgl-renderer.js +326 -129
  35. package/dist/webgl-renderer.js.map +1 -1
  36. package/dist/webgl-utils.d.ts +4 -0
  37. package/dist/webgl-utils.d.ts.map +1 -0
  38. package/dist/webgl-utils.js +19 -0
  39. package/dist/webgl-utils.js.map +1 -0
  40. package/dist/worker-bridge.d.ts +3 -0
  41. package/dist/worker-bridge.d.ts.map +1 -1
  42. package/dist/worker-bridge.js +17 -4
  43. package/dist/worker-bridge.js.map +1 -1
  44. package/package.json +6 -6
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { DEFAULT_THEME, normalizeSelection } from "@next_term/core";
10
10
  import { build256Palette, Canvas2DRenderer } from "./renderer.js";
11
+ import { resolveColorFloat } from "./webgl-utils.js";
11
12
  // ---------------------------------------------------------------------------
12
13
  // Attribute bit positions (mirrors renderer.ts / cell-grid.ts)
13
14
  // ---------------------------------------------------------------------------
@@ -19,31 +20,82 @@ const ATTR_INVERSE = 0x40;
19
20
  // ---------------------------------------------------------------------------
20
21
  // Color helpers
21
22
  // ---------------------------------------------------------------------------
22
- /** Parse a hex color (#rrggbb or #rgb) to [r, g, b, a] in 0-1 range. */
23
- export function hexToFloat4(hex) {
24
- let r = 0, g = 0, b = 0;
25
- if (hex.startsWith("#")) {
26
- const h = hex.slice(1);
27
- if (h.length === 3) {
28
- r = parseInt(h[0] + h[0], 16) / 255;
29
- g = parseInt(h[1] + h[1], 16) / 255;
30
- b = parseInt(h[2] + h[2], 16) / 255;
23
+ /**
24
+ * Parse any CSS color string to [r, g, b, a] in 0-1 range.
25
+ *
26
+ * Fast path for #rrggbb/#rgb hex (no canvas overhead).
27
+ * All other formats (rgb(), rgba(), hsl(), oklch(), color(), named colors)
28
+ * are resolved by the browser's native CSS engine via a 1x1 canvas.
29
+ */
30
+ // Singleton canvas context for CSS color resolution (works in main thread
31
+ // and Web Workers via OffscreenCanvas)
32
+ let _colorCtx = null;
33
+ let _colorCtxFailed = false;
34
+ function getColorCtx() {
35
+ if (_colorCtx || _colorCtxFailed)
36
+ return _colorCtx;
37
+ try {
38
+ if (typeof OffscreenCanvas !== "undefined") {
39
+ _colorCtx = new OffscreenCanvas(1, 1).getContext("2d");
31
40
  }
32
- else if (h.length === 6) {
33
- r = parseInt(h.slice(0, 2), 16) / 255;
34
- g = parseInt(h.slice(2, 4), 16) / 255;
35
- b = parseInt(h.slice(4, 6), 16) / 255;
41
+ else if (typeof document !== "undefined") {
42
+ const c = document.createElement("canvas");
43
+ c.width = 1;
44
+ c.height = 1;
45
+ _colorCtx = c.getContext("2d");
36
46
  }
37
47
  }
38
- else if (hex.startsWith("rgb(")) {
39
- const m = hex.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
40
- if (m) {
41
- r = parseInt(m[1], 10) / 255;
42
- g = parseInt(m[2], 10) / 255;
43
- b = parseInt(m[3], 10) / 255;
48
+ catch {
49
+ // No canvas available (SSR / test environment)
50
+ }
51
+ if (!_colorCtx)
52
+ _colorCtxFailed = true;
53
+ return _colorCtx;
54
+ }
55
+ export function hexToFloat4(color) {
56
+ // Fast path: #rrggbb (most common — default theme + 256-palette are all hex)
57
+ if (color.length === 7 && color.charCodeAt(0) === 0x23 /* # */) {
58
+ return [
59
+ parseInt(color.slice(1, 3), 16) / 255,
60
+ parseInt(color.slice(3, 5), 16) / 255,
61
+ parseInt(color.slice(5, 7), 16) / 255,
62
+ 1.0,
63
+ ];
64
+ }
65
+ // Fast path: #rgb
66
+ if (color.length === 4 && color.charCodeAt(0) === 0x23) {
67
+ return [
68
+ parseInt(color[1] + color[1], 16) / 255,
69
+ parseInt(color[2] + color[2], 16) / 255,
70
+ parseInt(color[3] + color[3], 16) / 255,
71
+ 1.0,
72
+ ];
73
+ }
74
+ // Universal path: let the browser parse any CSS color
75
+ const ctx = getColorCtx();
76
+ if (ctx) {
77
+ try {
78
+ ctx.clearRect(0, 0, 1, 1);
79
+ ctx.fillStyle = "#000";
80
+ ctx.fillStyle = color;
81
+ ctx.fillRect(0, 0, 1, 1);
82
+ const d = ctx.getImageData(0, 0, 1, 1).data;
83
+ const a = d[3] / 255;
84
+ if (a === 0) {
85
+ // Invalid color or fully transparent — return opaque black
86
+ return [0, 0, 0, 1.0];
87
+ }
88
+ // Unpremultiply RGB (getImageData returns premultiplied on some browsers)
89
+ return a >= 1
90
+ ? [d[0] / 255, d[1] / 255, d[2] / 255, 1.0]
91
+ : [d[0] / 255 / a, d[1] / 255 / a, d[2] / 255 / a, a];
92
+ }
93
+ catch {
94
+ // Partial canvas implementation (e.g., jsdom) — fall through
44
95
  }
45
96
  }
46
- return [r, g, b, 1.0];
97
+ // No canvas fallback — return black
98
+ return [0, 0, 0, 1.0];
47
99
  }
48
100
  /** Build a glyph cache key from codepoint and style flags. */
49
101
  export function glyphCacheKey(codepoint, bold, italic) {
@@ -361,6 +413,7 @@ export class WebGLRenderer {
361
413
  cursor = null;
362
414
  cellWidth = 0;
363
415
  cellHeight = 0;
416
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: read via destructuring in render()
364
417
  baselineOffset = 0;
365
418
  fontSize;
366
419
  fontFamily;
@@ -380,15 +433,36 @@ export class WebGLRenderer {
380
433
  glyphProgram = null;
381
434
  quadVBO = null;
382
435
  quadEBO = null;
383
- bgInstanceVBO = null;
384
- glyphInstanceVBO = null;
436
+ // Double-buffered instance VBOs to avoid GPU read/write conflicts
437
+ bgInstanceVBOs = [null, null];
438
+ glyphInstanceVBOs = [null, null];
439
+ activeBufferIdx = 0;
440
+ // Dedicated overlay VBO for cursor/selection/highlights so we don't
441
+ // overwrite the active bg VBO and neutralize double-buffering.
442
+ overlayVBO = null;
385
443
  bgVAO = null;
386
444
  glyphVAO = null;
445
+ // Cached uniform locations (populated in initGLResources)
446
+ bgResolutionLoc = null;
447
+ bgCellSizeLoc = null;
448
+ glyphResolutionLoc = null;
449
+ glyphCellSizeLoc = null;
450
+ glyphAtlasLoc = null;
387
451
  // Instance data (CPU side)
388
452
  bgInstances;
389
453
  glyphInstances;
390
454
  bgCount = 0;
391
455
  glyphCount = 0;
456
+ // Per-row dirty tracking for incremental instance rebuilds
457
+ rowBgOffsets = []; // starting bgCount index per row
458
+ rowBgCounts = []; // number of bg instances per row
459
+ rowGlyphOffsets = []; // starting glyphCount index per row
460
+ rowGlyphCounts = []; // number of glyph instances per row
461
+ hasRenderedOnce = false;
462
+ // Pre-allocated overlay buffers (reused each frame)
463
+ cursorData = new Float32Array(BG_INSTANCE_FLOATS);
464
+ selBuffer = new Float32Array(256 * BG_INSTANCE_FLOATS);
465
+ hlBuffer = new Float32Array(256 * BG_INSTANCE_FLOATS);
392
466
  // Glyph atlas
393
467
  atlas;
394
468
  // Palette as float arrays (cached for performance)
@@ -421,6 +495,7 @@ export class WebGLRenderer {
421
495
  this.canvas = canvas;
422
496
  this.grid = grid;
423
497
  this.cursor = cursor;
498
+ this.hasRenderedOnce = false;
424
499
  // Get WebGL2 context
425
500
  this.gl = canvas.getContext("webgl2", {
426
501
  alpha: false,
@@ -482,10 +557,38 @@ export class WebGLRenderer {
482
557
  if (!anyDirty) {
483
558
  return;
484
559
  }
485
- // Rebuild instance data
486
- this.bgCount = 0;
487
- this.glyphCount = 0;
560
+ // Flip active double-buffer index
561
+ this.activeBufferIdx = 1 - this.activeBufferIdx;
562
+ // Incremental rebuild — only re-pack dirty rows
563
+ // On first render or if grid dimensions changed, initialize per-row tracking
564
+ if (!this.hasRenderedOnce || this.rowBgOffsets.length !== rows) {
565
+ this.rowBgOffsets = new Array(rows).fill(0);
566
+ this.rowBgCounts = new Array(rows).fill(0);
567
+ this.rowGlyphOffsets = new Array(rows).fill(0);
568
+ this.rowGlyphCounts = new Array(rows).fill(0);
569
+ // Compute fixed offsets: each row has exactly `cols` bg instances
570
+ // Glyph offsets are variable, so on first pass we do a full rebuild
571
+ let bgOff = 0;
572
+ let glyphOff = 0;
573
+ for (let r = 0; r < rows; r++) {
574
+ this.rowBgOffsets[r] = bgOff;
575
+ this.rowBgCounts[r] = cols; // one bg instance per cell
576
+ bgOff += cols;
577
+ // For glyphs, allocate max possible (cols) per row on first pass
578
+ this.rowGlyphOffsets[r] = glyphOff;
579
+ this.rowGlyphCounts[r] = 0;
580
+ glyphOff += cols;
581
+ }
582
+ this.bgCount = bgOff;
583
+ this.glyphCount = 0; // will be summed below
584
+ }
488
585
  for (let row = 0; row < rows; row++) {
586
+ // Skip non-dirty rows — their data persists in the arrays
587
+ if (!grid.isDirty(row))
588
+ continue;
589
+ const bgBase = this.rowBgOffsets[row] * BG_INSTANCE_FLOATS;
590
+ const glyphBase = this.rowGlyphOffsets[row] * GLYPH_INSTANCE_FLOATS;
591
+ let rowGlyphCount = 0;
489
592
  for (let col = 0; col < cols; col++) {
490
593
  const codepoint = grid.getCodepoint(row, col);
491
594
  const fgIdx = grid.getFgIndex(row, col);
@@ -493,9 +596,8 @@ export class WebGLRenderer {
493
596
  const attrs = grid.getAttrs(row, col);
494
597
  const fgIsRGB = grid.isFgRGB(row, col);
495
598
  const bgIsRGB = grid.isBgRGB(row, col);
496
- const isWide = grid.isWide(row, col);
497
- let fg = this.resolveColorFloat(fgIdx, fgIsRGB, grid, col, true);
498
- let bg = this.resolveColorFloat(bgIdx, bgIsRGB, grid, col, false);
599
+ let fg = resolveColorFloat(fgIdx, fgIsRGB, grid, col, true, this.paletteFloat, this.themeFgFloat, this.themeBgFloat);
600
+ let bg = resolveColorFloat(bgIdx, bgIsRGB, grid, col, false, this.paletteFloat, this.themeFgFloat, this.themeBgFloat);
499
601
  // Handle inverse
500
602
  if (attrs & ATTR_INVERSE) {
501
603
  const tmp = fg;
@@ -503,23 +605,39 @@ export class WebGLRenderer {
503
605
  bg = tmp;
504
606
  }
505
607
  // Background instance — emit for all cells to paint default bg too
506
- packBgInstance(this.bgInstances, this.bgCount * BG_INSTANCE_FLOATS, col, row, bg[0], bg[1], bg[2], bg[3]);
507
- this.bgCount++;
608
+ packBgInstance(this.bgInstances, bgBase + col * BG_INSTANCE_FLOATS, col, row, bg[0], bg[1], bg[2], bg[3]);
508
609
  // Glyph instance — skip spaces and control chars
509
610
  if (codepoint > 0x20) {
510
611
  const bold = !!(attrs & ATTR_BOLD);
511
612
  const italic = !!(attrs & ATTR_ITALIC);
512
613
  const glyph = this.atlas.getGlyph(codepoint, bold, italic);
513
614
  if (glyph) {
514
- const glyphPw = isWide ? glyph.pw : glyph.pw;
515
615
  const glyphPh = glyph.ph;
516
- packGlyphInstance(this.glyphInstances, this.glyphCount * GLYPH_INSTANCE_FLOATS, col, row, fg[0], fg[1], fg[2], fg[3], glyph.u, glyph.v, glyph.w, glyph.h, glyphPw, glyphPh);
517
- this.glyphCount++;
616
+ packGlyphInstance(this.glyphInstances, glyphBase + rowGlyphCount * GLYPH_INSTANCE_FLOATS, col, row, fg[0], fg[1], fg[2], fg[3], glyph.u, glyph.v, glyph.w, glyph.h, glyph.pw, glyphPh);
617
+ rowGlyphCount++;
518
618
  }
519
619
  }
520
620
  }
621
+ // Zero out remaining glyph slots for this row (if fewer glyphs than last time)
622
+ const maxGlyphSlots = cols;
623
+ for (let i = rowGlyphCount; i < this.rowGlyphCounts[row]; i++) {
624
+ // Zero the codepoint/color to make invisible (alpha=0 effectively)
625
+ const off = glyphBase + i * GLYPH_INSTANCE_FLOATS;
626
+ for (let j = 0; j < GLYPH_INSTANCE_FLOATS; j++) {
627
+ this.glyphInstances[off + j] = 0;
628
+ }
629
+ }
630
+ // Keep max of old and new count to ensure we still upload zeroed slots
631
+ if (rowGlyphCount > maxGlyphSlots)
632
+ rowGlyphCount = maxGlyphSlots;
633
+ this.rowGlyphCounts[row] = rowGlyphCount;
521
634
  grid.clearDirty(row);
522
635
  }
636
+ this.hasRenderedOnce = true;
637
+ // Both bg and glyph instance arrays are sized for rows * cols slots;
638
+ // data is packed per-row at fixed offsets so we always upload the full region.
639
+ this.bgCount = rows * cols;
640
+ this.glyphCount = rows * cols;
523
641
  // Upload atlas if dirty
524
642
  this.atlas.upload(gl);
525
643
  // Set up GL state
@@ -530,39 +648,54 @@ export class WebGLRenderer {
530
648
  gl.clear(gl.COLOR_BUFFER_BIT);
531
649
  const cellW = this.cellWidth * this.dpr;
532
650
  const cellH = this.cellHeight * this.dpr;
651
+ const _FLOAT = 4;
652
+ const activeBgVBO = this.bgInstanceVBOs[this.activeBufferIdx];
653
+ const activeGlyphVBO = this.glyphInstanceVBOs[this.activeBufferIdx];
533
654
  // --- Background pass ---
534
- if (this.bgCount > 0 && this.bgProgram && this.bgVAO && this.bgInstanceVBO) {
655
+ if (this.bgCount > 0 && this.bgProgram && this.bgVAO && activeBgVBO) {
535
656
  gl.useProgram(this.bgProgram);
536
- gl.uniform2f(gl.getUniformLocation(this.bgProgram, "u_resolution"), canvasWidth, canvasHeight);
537
- gl.uniform2f(gl.getUniformLocation(this.bgProgram, "u_cellSize"), cellW, cellH);
538
- gl.bindBuffer(gl.ARRAY_BUFFER, this.bgInstanceVBO);
539
- gl.bufferData(gl.ARRAY_BUFFER, this.bgInstances.subarray(0, this.bgCount * BG_INSTANCE_FLOATS), gl.DYNAMIC_DRAW);
657
+ // Use cached uniform locations
658
+ gl.uniform2f(this.bgResolutionLoc, canvasWidth, canvasHeight);
659
+ gl.uniform2f(this.bgCellSizeLoc, cellW, cellH);
660
+ // Bind active double-buffered VBO and re-setup instance attrib pointers
661
+ gl.bindBuffer(gl.ARRAY_BUFFER, activeBgVBO);
662
+ gl.bufferData(gl.ARRAY_BUFFER, this.bgInstances.subarray(0, this.bgCount * BG_INSTANCE_FLOATS), gl.STREAM_DRAW);
540
663
  gl.bindVertexArray(this.bgVAO);
664
+ // Rebind instance attribs to the active VBO (VAO captured the old one)
665
+ this.rebindBgInstanceAttribs(gl, activeBgVBO);
541
666
  gl.drawElementsInstanced(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0, this.bgCount);
542
667
  }
543
668
  // --- Glyph pass ---
544
- if (this.glyphCount > 0 && this.glyphProgram && this.glyphVAO && this.glyphInstanceVBO) {
669
+ if (this.glyphCount > 0 && this.glyphProgram && this.glyphVAO && activeGlyphVBO) {
545
670
  gl.enable(gl.BLEND);
546
671
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
547
672
  gl.useProgram(this.glyphProgram);
548
- gl.uniform2f(gl.getUniformLocation(this.glyphProgram, "u_resolution"), canvasWidth, canvasHeight);
549
- gl.uniform2f(gl.getUniformLocation(this.glyphProgram, "u_cellSize"), cellW, cellH);
673
+ // Use cached uniform locations
674
+ gl.uniform2f(this.glyphResolutionLoc, canvasWidth, canvasHeight);
675
+ gl.uniform2f(this.glyphCellSizeLoc, cellW, cellH);
550
676
  // Bind atlas texture
551
677
  gl.activeTexture(gl.TEXTURE0);
552
678
  gl.bindTexture(gl.TEXTURE_2D, this.atlas.getTexture());
553
- gl.uniform1i(gl.getUniformLocation(this.glyphProgram, "u_atlas"), 0);
554
- gl.bindBuffer(gl.ARRAY_BUFFER, this.glyphInstanceVBO);
555
- gl.bufferData(gl.ARRAY_BUFFER, this.glyphInstances.subarray(0, this.glyphCount * GLYPH_INSTANCE_FLOATS), gl.DYNAMIC_DRAW);
679
+ gl.uniform1i(this.glyphAtlasLoc, 0);
680
+ // Bind active double-buffered VBO
681
+ gl.bindBuffer(gl.ARRAY_BUFFER, activeGlyphVBO);
682
+ gl.bufferData(gl.ARRAY_BUFFER, this.glyphInstances.subarray(0, this.glyphCount * GLYPH_INSTANCE_FLOATS), gl.STREAM_DRAW);
556
683
  gl.bindVertexArray(this.glyphVAO);
684
+ // Rebind instance attribs to the active VBO
685
+ this.rebindGlyphInstanceAttribs(gl, activeGlyphVBO);
557
686
  gl.drawElementsInstanced(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0, this.glyphCount);
558
687
  gl.disable(gl.BLEND);
559
688
  }
689
+ // Enable BLEND once for all overlay passes
690
+ gl.enable(gl.BLEND);
691
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
560
692
  // --- Highlights (search results) ---
561
693
  this.drawHighlights();
562
694
  // --- Selection overlay ---
563
695
  this.drawSelection();
564
696
  // --- Cursor ---
565
697
  this.drawCursor();
698
+ gl.disable(gl.BLEND);
566
699
  gl.bindVertexArray(null);
567
700
  }
568
701
  resize(_cols, _rows) {
@@ -631,10 +764,16 @@ export class WebGLRenderer {
631
764
  gl.deleteBuffer(this.quadVBO);
632
765
  if (this.quadEBO)
633
766
  gl.deleteBuffer(this.quadEBO);
634
- if (this.bgInstanceVBO)
635
- gl.deleteBuffer(this.bgInstanceVBO);
636
- if (this.glyphInstanceVBO)
637
- gl.deleteBuffer(this.glyphInstanceVBO);
767
+ if (this.bgInstanceVBOs[0])
768
+ gl.deleteBuffer(this.bgInstanceVBOs[0]);
769
+ if (this.bgInstanceVBOs[1])
770
+ gl.deleteBuffer(this.bgInstanceVBOs[1]);
771
+ if (this.glyphInstanceVBOs[0])
772
+ gl.deleteBuffer(this.glyphInstanceVBOs[0]);
773
+ if (this.glyphInstanceVBOs[1])
774
+ gl.deleteBuffer(this.glyphInstanceVBOs[1]);
775
+ if (this.overlayVBO)
776
+ gl.deleteBuffer(this.overlayVBO);
638
777
  if (this.bgVAO)
639
778
  gl.deleteVertexArray(this.bgVAO);
640
779
  if (this.glyphVAO)
@@ -693,19 +832,40 @@ export class WebGLRenderer {
693
832
  this.quadEBO = gl.createBuffer();
694
833
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.quadEBO);
695
834
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, quadIndices, gl.STATIC_DRAW);
696
- // Instance buffers
697
- this.bgInstanceVBO = gl.createBuffer();
698
- this.glyphInstanceVBO = gl.createBuffer();
699
- // Set up background VAO
835
+ // Double-buffered instance VBOs
836
+ this.bgInstanceVBOs = [gl.createBuffer(), gl.createBuffer()];
837
+ this.glyphInstanceVBOs = [gl.createBuffer(), gl.createBuffer()];
838
+ // Dedicated overlay VBO for cursor/selection/highlights
839
+ this.overlayVBO = gl.createBuffer();
840
+ // Set up background VAO (quad + EBO only; instance buffer bound per-frame)
700
841
  this.bgVAO = gl.createVertexArray();
701
842
  gl.bindVertexArray(this.bgVAO);
702
843
  this.setupBgVAO(gl);
703
844
  gl.bindVertexArray(null);
704
- // Set up glyph VAO
845
+ // Set up glyph VAO (quad + EBO only; instance buffer bound per-frame)
705
846
  this.glyphVAO = gl.createVertexArray();
706
847
  gl.bindVertexArray(this.glyphVAO);
707
848
  this.setupGlyphVAO(gl);
708
849
  gl.bindVertexArray(null);
850
+ // Cache all uniform locations after programs are compiled
851
+ this.bgResolutionLoc = gl.getUniformLocation(this.bgProgram, "u_resolution");
852
+ this.bgCellSizeLoc = gl.getUniformLocation(this.bgProgram, "u_cellSize");
853
+ this.glyphResolutionLoc = gl.getUniformLocation(this.glyphProgram, "u_resolution");
854
+ this.glyphCellSizeLoc = gl.getUniformLocation(this.glyphProgram, "u_cellSize");
855
+ this.glyphAtlasLoc = gl.getUniformLocation(this.glyphProgram, "u_atlas");
856
+ // Cache attribute locations
857
+ this.bgAttribLocs = {
858
+ cellPos: gl.getAttribLocation(this.bgProgram, "a_cellPos"),
859
+ color: gl.getAttribLocation(this.bgProgram, "a_color"),
860
+ };
861
+ this.glyphAttribLocs = {
862
+ cellPos: gl.getAttribLocation(this.glyphProgram, "a_cellPos"),
863
+ color: gl.getAttribLocation(this.glyphProgram, "a_color"),
864
+ texCoord: gl.getAttribLocation(this.glyphProgram, "a_texCoord"),
865
+ glyphSize: gl.getAttribLocation(this.glyphProgram, "a_glyphSize"),
866
+ };
867
+ // Reset dirty-row tracking state on GL reinit
868
+ this.hasRenderedOnce = false;
709
869
  // Recreate atlas texture
710
870
  this.atlas.recreateTexture();
711
871
  }
@@ -721,8 +881,8 @@ export class WebGLRenderer {
721
881
  gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
722
882
  // Element buffer
723
883
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.quadEBO);
724
- // Instance data
725
- gl.bindBuffer(gl.ARRAY_BUFFER, this.bgInstanceVBO);
884
+ // Instance data — bind initial buffer; will be rebound per-frame for double buffering
885
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.bgInstanceVBOs[0]);
726
886
  const stride = BG_INSTANCE_FLOATS * FLOAT;
727
887
  const aCellPos = gl.getAttribLocation(program, "a_cellPos");
728
888
  gl.enableVertexAttribArray(aCellPos);
@@ -745,8 +905,8 @@ export class WebGLRenderer {
745
905
  gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
746
906
  // Element buffer
747
907
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.quadEBO);
748
- // Instance data
749
- gl.bindBuffer(gl.ARRAY_BUFFER, this.glyphInstanceVBO);
908
+ // Instance data — bind initial buffer; will be rebound per-frame for double buffering
909
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.glyphInstanceVBOs[0]);
750
910
  const stride = GLYPH_INSTANCE_FLOATS * FLOAT;
751
911
  const aCellPos = gl.getAttribLocation(program, "a_cellPos");
752
912
  gl.enableVertexAttribArray(aCellPos);
@@ -776,9 +936,11 @@ export class WebGLRenderer {
776
936
  const neededGlyph = totalCells * GLYPH_INSTANCE_FLOATS;
777
937
  if (this.bgInstances.length < neededBg) {
778
938
  this.bgInstances = new Float32Array(neededBg);
939
+ this.hasRenderedOnce = false; // force full rebuild on resize
779
940
  }
780
941
  if (this.glyphInstances.length < neededGlyph) {
781
942
  this.glyphInstances = new Float32Array(neededGlyph);
943
+ this.hasRenderedOnce = false;
782
944
  }
783
945
  }
784
946
  // -----------------------------------------------------------------------
@@ -790,24 +952,6 @@ export class WebGLRenderer {
790
952
  this.themeBgFloat = hexToFloat4(this.theme.background);
791
953
  this.themeCursorFloat = hexToFloat4(this.theme.cursor);
792
954
  }
793
- resolveColorFloat(colorIdx, isRGB, grid, col, isForeground) {
794
- if (isRGB) {
795
- const offset = isForeground ? col : 256 + col;
796
- const rgb = grid.rgbColors[offset];
797
- const r = ((rgb >> 16) & 0xff) / 255;
798
- const g = ((rgb >> 8) & 0xff) / 255;
799
- const b = (rgb & 0xff) / 255;
800
- return [r, g, b, 1.0];
801
- }
802
- if (isForeground && colorIdx === 7)
803
- return this.themeFgFloat;
804
- if (!isForeground && colorIdx === 0)
805
- return this.themeBgFloat;
806
- if (colorIdx >= 0 && colorIdx < 256) {
807
- return this.paletteFloat[colorIdx];
808
- }
809
- return isForeground ? this.themeFgFloat : this.themeBgFloat;
810
- }
811
955
  // -----------------------------------------------------------------------
812
956
  // Cursor
813
957
  // -----------------------------------------------------------------------
@@ -815,33 +959,44 @@ export class WebGLRenderer {
815
959
  if (!this.gl || !this.highlights.length)
816
960
  return;
817
961
  const gl = this.gl;
818
- if (!this.bgProgram || !this.bgVAO || !this.bgInstanceVBO)
962
+ if (!this.bgProgram || !this.bgVAO || !this.overlayVBO)
819
963
  return;
820
- const hlInstances = [];
964
+ // Pack into pre-allocated hlBuffer, growing only if needed
965
+ let hlIdx = 0;
821
966
  for (const hl of this.highlights) {
822
- // Current match: orange, other matches: semi-transparent yellow
823
967
  const r = hl.isCurrent ? 1.0 : 1.0;
824
968
  const g = hl.isCurrent ? 0.647 : 1.0;
825
969
  const b = hl.isCurrent ? 0.0 : 0.0;
826
970
  const a = hl.isCurrent ? 0.5 : 0.3;
827
971
  for (let col = hl.startCol; col <= hl.endCol; col++) {
828
- hlInstances.push(col, hl.row, r, g, b, a);
972
+ const needed = (hlIdx + 1) * BG_INSTANCE_FLOATS;
973
+ if (needed > this.hlBuffer.length) {
974
+ const newBuf = new Float32Array(this.hlBuffer.length * 2);
975
+ newBuf.set(this.hlBuffer);
976
+ this.hlBuffer = newBuf;
977
+ }
978
+ const off = hlIdx * BG_INSTANCE_FLOATS;
979
+ this.hlBuffer[off] = col;
980
+ this.hlBuffer[off + 1] = hl.row;
981
+ this.hlBuffer[off + 2] = r;
982
+ this.hlBuffer[off + 3] = g;
983
+ this.hlBuffer[off + 4] = b;
984
+ this.hlBuffer[off + 5] = a;
985
+ hlIdx++;
829
986
  }
830
987
  }
831
- if (hlInstances.length === 0)
988
+ if (hlIdx === 0)
832
989
  return;
833
- const hlData = new Float32Array(hlInstances);
834
- const hlCount = hlInstances.length / BG_INSTANCE_FLOATS;
835
- gl.enable(gl.BLEND);
836
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
990
+ // BLEND already enabled by caller
837
991
  gl.useProgram(this.bgProgram);
838
- gl.uniform2f(gl.getUniformLocation(this.bgProgram, "u_resolution"), this.canvas?.width ?? 0, this.canvas?.height ?? 0);
839
- gl.uniform2f(gl.getUniformLocation(this.bgProgram, "u_cellSize"), this.cellWidth * this.dpr, this.cellHeight * this.dpr);
840
- gl.bindBuffer(gl.ARRAY_BUFFER, this.bgInstanceVBO);
841
- gl.bufferData(gl.ARRAY_BUFFER, hlData, gl.DYNAMIC_DRAW);
992
+ // Use cached uniform locations
993
+ gl.uniform2f(this.bgResolutionLoc, this.canvas?.width ?? 0, this.canvas?.height ?? 0);
994
+ gl.uniform2f(this.bgCellSizeLoc, this.cellWidth * this.dpr, this.cellHeight * this.dpr);
995
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.overlayVBO);
996
+ gl.bufferData(gl.ARRAY_BUFFER, this.hlBuffer.subarray(0, hlIdx * BG_INSTANCE_FLOATS), gl.STREAM_DRAW);
842
997
  gl.bindVertexArray(this.bgVAO);
843
- gl.drawElementsInstanced(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0, hlCount);
844
- gl.disable(gl.BLEND);
998
+ this.rebindBgInstanceAttribs(gl, this.overlayVBO);
999
+ gl.drawElementsInstanced(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0, hlIdx);
845
1000
  }
846
1001
  drawSelection() {
847
1002
  if (!this.gl || !this.grid || !this.selection)
@@ -854,12 +1009,12 @@ export class WebGLRenderer {
854
1009
  // Skip if selection is empty (same cell)
855
1010
  if (sr === er && sel.startCol === sel.endCol)
856
1011
  return;
857
- if (!this.bgProgram || !this.bgVAO || !this.bgInstanceVBO)
1012
+ if (!this.bgProgram || !this.bgVAO || !this.overlayVBO)
858
1013
  return;
859
1014
  // Parse the selection background color
860
1015
  const selColor = hexToFloat4(this.theme.selectionBackground);
861
- // Build instance data for selection rects
862
- const selInstances = [];
1016
+ // Pack into pre-allocated selBuffer, growing only if needed
1017
+ let selIdx = 0;
863
1018
  for (let row = sr; row <= er; row++) {
864
1019
  let colStart;
865
1020
  let colEnd;
@@ -880,23 +1035,34 @@ export class WebGLRenderer {
880
1035
  colEnd = grid.cols - 1;
881
1036
  }
882
1037
  for (let col = colStart; col <= colEnd; col++) {
883
- selInstances.push(col, row, selColor[0], selColor[1], selColor[2], 0.5);
1038
+ const needed = (selIdx + 1) * BG_INSTANCE_FLOATS;
1039
+ if (needed > this.selBuffer.length) {
1040
+ const newBuf = new Float32Array(this.selBuffer.length * 2);
1041
+ newBuf.set(this.selBuffer);
1042
+ this.selBuffer = newBuf;
1043
+ }
1044
+ const off = selIdx * BG_INSTANCE_FLOATS;
1045
+ this.selBuffer[off] = col;
1046
+ this.selBuffer[off + 1] = row;
1047
+ this.selBuffer[off + 2] = selColor[0];
1048
+ this.selBuffer[off + 3] = selColor[1];
1049
+ this.selBuffer[off + 4] = selColor[2];
1050
+ this.selBuffer[off + 5] = 0.5;
1051
+ selIdx++;
884
1052
  }
885
1053
  }
886
- if (selInstances.length === 0)
1054
+ if (selIdx === 0)
887
1055
  return;
888
- const selData = new Float32Array(selInstances);
889
- const selCount = selInstances.length / BG_INSTANCE_FLOATS;
890
- gl.enable(gl.BLEND);
891
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
1056
+ // BLEND already enabled by caller
892
1057
  gl.useProgram(this.bgProgram);
893
- gl.uniform2f(gl.getUniformLocation(this.bgProgram, "u_resolution"), this.canvas?.width ?? 0, this.canvas?.height ?? 0);
894
- gl.uniform2f(gl.getUniformLocation(this.bgProgram, "u_cellSize"), this.cellWidth * this.dpr, this.cellHeight * this.dpr);
895
- gl.bindBuffer(gl.ARRAY_BUFFER, this.bgInstanceVBO);
896
- gl.bufferData(gl.ARRAY_BUFFER, selData, gl.DYNAMIC_DRAW);
1058
+ // Use cached uniform locations
1059
+ gl.uniform2f(this.bgResolutionLoc, this.canvas?.width ?? 0, this.canvas?.height ?? 0);
1060
+ gl.uniform2f(this.bgCellSizeLoc, this.cellWidth * this.dpr, this.cellHeight * this.dpr);
1061
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.overlayVBO);
1062
+ gl.bufferData(gl.ARRAY_BUFFER, this.selBuffer.subarray(0, selIdx * BG_INSTANCE_FLOATS), gl.STREAM_DRAW);
897
1063
  gl.bindVertexArray(this.bgVAO);
898
- gl.drawElementsInstanced(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0, selCount);
899
- gl.disable(gl.BLEND);
1064
+ this.rebindBgInstanceAttribs(gl, this.overlayVBO);
1065
+ gl.drawElementsInstanced(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0, selIdx);
900
1066
  }
901
1067
  drawCursor() {
902
1068
  if (!this.gl || !this.cursor || !this.cursor.visible)
@@ -907,50 +1073,81 @@ export class WebGLRenderer {
907
1073
  const cellH = this.cellHeight * this.dpr;
908
1074
  const cc = this.themeCursorFloat;
909
1075
  // Use the bg program to draw a simple colored rect for the cursor
910
- if (!this.bgProgram || !this.bgVAO || !this.bgInstanceVBO)
1076
+ if (!this.bgProgram || !this.bgVAO || !this.overlayVBO)
911
1077
  return;
912
- gl.enable(gl.BLEND);
913
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
1078
+ // BLEND already enabled by caller
914
1079
  gl.useProgram(this.bgProgram);
915
- gl.uniform2f(gl.getUniformLocation(this.bgProgram, "u_resolution"), this.canvas?.width ?? 0, this.canvas?.height ?? 0);
916
- gl.uniform2f(gl.getUniformLocation(this.bgProgram, "u_cellSize"), cellW, cellH);
1080
+ // Use cached uniform locations
1081
+ gl.uniform2f(this.bgResolutionLoc, this.canvas?.width ?? 0, this.canvas?.height ?? 0);
1082
+ gl.uniform2f(this.bgCellSizeLoc, cellW, cellH);
1083
+ // Write into pre-allocated cursorData instead of allocating
917
1084
  // For bar and underline styles, we draw a thin rect.
918
1085
  // We abuse cellPos with fractional values to position correctly.
919
- let cursorData;
920
1086
  switch (cursor.style) {
921
1087
  case "block":
922
- cursorData = new Float32Array([
923
- cursor.col,
924
- cursor.row,
925
- cc[0],
926
- cc[1],
927
- cc[2],
928
- 0.5, // 50% alpha for block
929
- ]);
1088
+ this.cursorData[0] = cursor.col;
1089
+ this.cursorData[1] = cursor.row;
1090
+ this.cursorData[2] = cc[0];
1091
+ this.cursorData[3] = cc[1];
1092
+ this.cursorData[4] = cc[2];
1093
+ this.cursorData[5] = 0.5; // 50% alpha for block
930
1094
  break;
931
1095
  case "underline": {
932
1096
  // Draw a thin line at the bottom of the cell
933
- // We position it by adjusting cellPos row to be near bottom
934
1097
  const lineH = Math.max(2 * this.dpr, 1);
935
1098
  const fractionalRow = cursor.row + (cellH - lineH) / cellH;
936
- cursorData = new Float32Array([cursor.col, fractionalRow, cc[0], cc[1], cc[2], cc[3]]);
937
- // We'd need a different cell size for this, but we can approximate
938
- // by using the full cell width and adjusting position
1099
+ this.cursorData[0] = cursor.col;
1100
+ this.cursorData[1] = fractionalRow;
1101
+ this.cursorData[2] = cc[0];
1102
+ this.cursorData[3] = cc[1];
1103
+ this.cursorData[4] = cc[2];
1104
+ this.cursorData[5] = cc[3];
939
1105
  break;
940
1106
  }
941
1107
  case "bar": {
942
- // Draw a thin vertical bar at the left of the cell
943
- cursorData = new Float32Array([cursor.col, cursor.row, cc[0], cc[1], cc[2], cc[3]]);
1108
+ this.cursorData[0] = cursor.col;
1109
+ this.cursorData[1] = cursor.row;
1110
+ this.cursorData[2] = cc[0];
1111
+ this.cursorData[3] = cc[1];
1112
+ this.cursorData[4] = cc[2];
1113
+ this.cursorData[5] = cc[3];
944
1114
  break;
945
1115
  }
946
1116
  default:
947
- cursorData = new Float32Array([cursor.col, cursor.row, cc[0], cc[1], cc[2], 0.5]);
1117
+ this.cursorData[0] = cursor.col;
1118
+ this.cursorData[1] = cursor.row;
1119
+ this.cursorData[2] = cc[0];
1120
+ this.cursorData[3] = cc[1];
1121
+ this.cursorData[4] = cc[2];
1122
+ this.cursorData[5] = 0.5;
948
1123
  }
949
- gl.bindBuffer(gl.ARRAY_BUFFER, this.bgInstanceVBO);
950
- gl.bufferData(gl.ARRAY_BUFFER, cursorData, gl.DYNAMIC_DRAW);
1124
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.overlayVBO);
1125
+ gl.bufferData(gl.ARRAY_BUFFER, this.cursorData, gl.STREAM_DRAW);
951
1126
  gl.bindVertexArray(this.bgVAO);
1127
+ this.rebindBgInstanceAttribs(gl, this.overlayVBO);
952
1128
  gl.drawElementsInstanced(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0, 1);
953
- gl.disable(gl.BLEND);
1129
+ }
1130
+ // -----------------------------------------------------------------------
1131
+ // Instance attribute rebinding helpers for double-buffered VBOs
1132
+ // -----------------------------------------------------------------------
1133
+ // Cached attribute locations (populated in initGLResources)
1134
+ bgAttribLocs = { cellPos: -1, color: -1 };
1135
+ glyphAttribLocs = { cellPos: -1, color: -1, texCoord: -1, glyphSize: -1 };
1136
+ rebindBgInstanceAttribs(gl, vbo) {
1137
+ const FLOAT = 4;
1138
+ const stride = BG_INSTANCE_FLOATS * FLOAT;
1139
+ gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
1140
+ gl.vertexAttribPointer(this.bgAttribLocs.cellPos, 2, gl.FLOAT, false, stride, 0);
1141
+ gl.vertexAttribPointer(this.bgAttribLocs.color, 4, gl.FLOAT, false, stride, 2 * FLOAT);
1142
+ }
1143
+ rebindGlyphInstanceAttribs(gl, vbo) {
1144
+ const FLOAT = 4;
1145
+ const stride = GLYPH_INSTANCE_FLOATS * FLOAT;
1146
+ gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
1147
+ gl.vertexAttribPointer(this.glyphAttribLocs.cellPos, 2, gl.FLOAT, false, stride, 0);
1148
+ gl.vertexAttribPointer(this.glyphAttribLocs.color, 4, gl.FLOAT, false, stride, 2 * FLOAT);
1149
+ gl.vertexAttribPointer(this.glyphAttribLocs.texCoord, 4, gl.FLOAT, false, stride, 6 * FLOAT);
1150
+ gl.vertexAttribPointer(this.glyphAttribLocs.glyphSize, 2, gl.FLOAT, false, stride, 10 * FLOAT);
954
1151
  }
955
1152
  // -----------------------------------------------------------------------
956
1153
  // Internal helpers