react-x11 2.44.0 → 2.44.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.44.0",
3
+ "version": "2.44.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -109,7 +109,7 @@
109
109
  "yoga-layout": "^3.2.1"
110
110
  },
111
111
  "optionalDependencies": {
112
- "@windowkit/appkit": "^0.25.0",
112
+ "@windowkit/appkit": "^0.27.0",
113
113
  "@windowkit/wayland": "^3.1.1",
114
114
  "@windowkit/win32": "^0.0.9",
115
115
  "dbus-native": "^0.16.0",
@@ -77,21 +77,30 @@ class PaneLayer {
77
77
  }
78
78
 
79
79
  /**
80
- * Whether a pane's last frame is shown at its size in its layer until a
81
- * frame of the layer's new size lands, or stretched to it. The host gives
82
- * the layer its new size the moment the window lays out, and the pane's
83
- * frame of that size comes a pane frame later: Core Animation stretches
84
- * the frame it has to the new bounds meanwhile, and a page's left column
85
- * was drawn scaled a little at every step of a window dragged wider, and
86
- * then at its size again. A pane that keeps up has its frame shown at its
87
- * size instead, its edge carried over what it does not cover yet
88
- * (`_edgeProps`); one that falls behind the budget is stretched, which is
89
- * the better picture for the long wait — a pane repainting at three
90
- * frames a second shows a squeezed page, not a page and a smeared strip.
91
- * The budget is the window's live-resize handshake's
92
- * (`createRoot({ cocoa: { resizeWait } })`), how long a frame of a new
93
- * size may take before the old one is shown in its place: 0 stretches
94
- * always, as before.
80
+ * Whether a pane's last frame is shown at its size across its layer until
81
+ * a frame of the layer's new size lands, or stretched across it. The host
82
+ * gives the layer its new size the moment the window lays out, and the
83
+ * pane's frame of that size comes a pane frame later: Core Animation
84
+ * stretches the frame it has to the new bounds meanwhile, and a page's
85
+ * left column was drawn scaled a little at every step of a window dragged
86
+ * wider, and then at its size again. A pane that keeps up has its frame
87
+ * shown at its size instead, its edge carried over what it does not cover
88
+ * yet (`_edgeProps`); one that falls behind the budget is stretched
89
+ * across, which is the better picture for the long wait — a pane
90
+ * repainting at three frames a second shows a squeezed page, not a page
91
+ * and a smeared strip. The budget is the window's live-resize
92
+ * handshake's (`createRoot({ cocoa: { resizeWait } })`), how long a frame
93
+ * of a new size may take before the old one is shown in its place: 0
94
+ * stretches always, both ways, as before.
95
+ *
96
+ * Down the layer the frame is at its size whatever this says. A width
97
+ * moves where a page's lines break, and what comes is a page laid out
98
+ * again, which a squeezed one is nearer to than one cut off with a strip
99
+ * beside it. A height moves nothing a page lays out — its lines are as
100
+ * wide as they were, its blocks where they were — so what comes is the
101
+ * frame as it is with more of the page under it, and a frame stretched
102
+ * down was the page drawn too tall at every refresh of the wait, where
103
+ * at its size only the strip under it is not yet the page's.
95
104
  *
96
105
  * Behind is a pane whose frames come later than the budget, not one with
97
106
  * a size waiting that long. `<Frame>` sends a pane the newest size once
@@ -125,8 +134,11 @@ class PaneLayer {
125
134
  * carried over the rest (`contentsCenter`, the part of the contents that
126
135
  * stretches while everything else keeps its size) — the last column
127
136
  * across a window dragged wider, the last row down one dragged taller,
128
- * and the corner pixel into the corner. Both rects are the whole when
129
- * the frame is stretched, and when it is the layer's size.
137
+ * and the corner pixel into the corner. Across a pane that fell behind
138
+ * the frame is stretched, and down it is still at its size
139
+ * (`_anchoredNow`): the whole of each row stretched to the new width,
140
+ * cropped or with its last row carried down. Both rects are the whole
141
+ * when the frame is the layer's size, and at a budget of 0.
130
142
  *
131
143
  * Anchored by gravity alone, the strip the frame did not cover showed
132
144
  * whatever was under the pane, which is the `<Frame>`'s background: in
@@ -147,15 +159,19 @@ class PaneLayer {
147
159
  const rect = this._rect;
148
160
  let crop = null;
149
161
  let edge = null;
150
- if (frame && rect && this._anchoredNow()) {
151
- const w = Math.min(frame.width, rect.width);
162
+ const budget = this.app._resizeWait ?? RESIZE_WAIT_MS;
163
+ if (frame && rect && budget > 0) {
164
+ // across at its size while the pane keeps up, and down always; an
165
+ // axis stretched is shown whole, the frame's every column across
166
+ const across = this._anchoredNow();
167
+ const w = across ? Math.min(frame.width, rect.width) : frame.width;
152
168
  const h = Math.min(frame.height, rect.height);
153
169
  if (w < frame.width || h < frame.height) {
154
170
  crop = [0, 0, w / frame.width, h / frame.height];
155
171
  }
156
- // in the shown part's own unit square; an axis that did not grow is
157
- // all centre, stretched by one
158
- const wider = rect.width > w;
172
+ // in the shown part's own unit square; an axis that did not grow, or
173
+ // is stretched, is all centre
174
+ const wider = across && rect.width > w;
159
175
  const taller = rect.height > h;
160
176
  if (wider || taller) {
161
177
  edge = [
@@ -30,6 +30,12 @@ import { BackendContext2D } from '../backend/context2d.js';
30
30
  // the pane drawing.
31
31
  const ANSWER_TIMEOUT_MS = 250;
32
32
 
33
+ // Where a ring's slot with no buffer made yet ranks among the buffers a
34
+ // frame may take (`_takeBack`): after every one done with and let go of,
35
+ // ranked by their present's sequence number, and before one the
36
+ // WindowServer still reads, ranked from 2 ** 32 up.
37
+ const EMPTY_SLOT = 2 ** 32 - 1;
38
+
33
39
  let nextPaneId = 1;
34
40
 
35
41
  export class CocoaPaneWindow {
@@ -261,6 +267,8 @@ export class CocoaPaneWindow {
261
267
  let done = false;
262
268
  for (let i = 0; i < this._ring.length; i += 1) {
263
269
  const s = this._ring[i];
270
+ // a slot with no buffer yet gets one, which nothing names
271
+ if (!s) return false;
264
272
  if (i === this._shownIndex || !this._done(s)) continue;
265
273
  if (!this._held(s)) return false;
266
274
  done = true;
@@ -354,6 +362,7 @@ export class CocoaPaneWindow {
354
362
  const release = this._native.releaseSurface;
355
363
  if (typeof release !== 'function') return;
356
364
  for (const s of ring) {
365
+ if (!s) continue;
357
366
  if (s.presentedSeq > this._shownSeq) this._retired.push(s);
358
367
  else release.call(this._native, s.handle);
359
368
  }
@@ -407,6 +416,20 @@ export class CocoaPaneWindow {
407
416
  for (const s of retired) release.call(this._native, s.handle);
408
417
  }
409
418
 
419
+ /**
420
+ * A buffer of the ring, `w` by `h` device px, made the first time a frame
421
+ * takes its slot rather than with the ring. A drag resizes the pane a
422
+ * tick at a time and gives each size one frame, so a ring made whole was
423
+ * three buffers made and cleared for the one drawn into: Zen Garden 101
424
+ * in the browser example spent 4.4 ms of every 12 ms frame of a drag in
425
+ * `_ensureSurface`. Cleared where nothing is copied over it.
426
+ */
427
+ _newBuffer(w, h, clear) {
428
+ const s = this._native.createSurfaceIOSurface(w, h, this.scale, true);
429
+ if (clear) this._native.ctxClearRect(s.handle, 0, 0, w, h);
430
+ return s;
431
+ }
432
+
410
433
  _ensureSurface() {
411
434
  const w = this.width;
412
435
  const h = this.height;
@@ -417,12 +440,10 @@ export class CocoaPaneWindow {
417
440
  ) {
418
441
  const hadSurface = Boolean(this._ring);
419
442
  this._releaseRing();
420
- this._ring = [];
421
- for (let i = 0; i < CocoaPaneWindow.RING; i += 1) {
422
- const s = this._native.createSurfaceIOSurface(w, h, this.scale, true);
423
- this._native.ctxClearRect(s.handle, 0, 0, w, h);
424
- this._ring.push(s);
425
- }
443
+ // the buffer this frame draws into, and the rest of the ring as a
444
+ // frame first takes it (`_takeBack`)
445
+ this._ring = new Array(CocoaPaneWindow.RING).fill(null);
446
+ this._ring[0] = this._newBuffer(w, h, true);
426
447
  this._drawIndex = 0;
427
448
  this._shownIndex = -1;
428
449
  this._native.surfaceLock(this._ring[0].handle);
@@ -478,6 +499,15 @@ export class CocoaPaneWindow {
478
499
  for (let i = 0; i < ring.length; i += 1) {
479
500
  const s = ring[i];
480
501
  if (s === last) continue;
502
+ // a slot no buffer has been made for yet: after one let go of, which
503
+ // costs nothing to take, and before any the WindowServer still reads
504
+ if (!s) {
505
+ if (EMPTY_SLOT < rank) {
506
+ rank = EMPTY_SLOT;
507
+ pick = i;
508
+ }
509
+ continue;
510
+ }
481
511
  // done and let go of, then done, then on its way, then on the layer:
482
512
  // the oldest of the best — but of the done, the one the screen showed
483
513
  // before the host's latest answers last
@@ -494,7 +524,13 @@ export class CocoaPaneWindow {
494
524
  pick = i;
495
525
  }
496
526
  }
497
- const back = ring[pick];
527
+ // what the frame just presented is copied over whole, below, and only a
528
+ // slot with none to copy is cleared
529
+ const back = (ring[pick] ??= this._newBuffer(
530
+ this._surfaceSize.width,
531
+ this._surfaceSize.height,
532
+ !last,
533
+ ));
498
534
  this._drawIndex = pick;
499
535
  this._surface = back.handle;
500
536
  // a different native surface owns the graphics state now — the context
package/src/index.d.ts CHANGED
@@ -292,8 +292,9 @@ export interface RootOptions {
292
292
  * the frame is painted on another thread (50 by default; 0 lets the edge
293
293
  * move without waiting). A `<Frame>` pane's frames of a new size are held
294
294
  * to it as well: within it, the pane's last frame stays at its size while
295
- * the next comes, and past it, it is stretched to the pane's new size, as
296
- * every one is at 0.
295
+ * the next comes, and past it, it is stretched across to the pane's new
296
+ * width and stays at its size down it; at 0 every one is stretched both
297
+ * ways.
297
298
  * `screenPoll` is how often the screen layout is re-read while a
298
299
  * {@link useScreens} subscriber is mounted, in ms — 500 by default, 0 for
299
300
  * never. macOS has no event for a display plugged in, unplugged or
@@ -92,6 +92,9 @@ const SWIZZLE_STRAIGHT = 2; // straight RGBA (ImageData)
92
92
  /** floats per vertex: pos(2) uv(2) color(4) params(4) */
93
93
  const STRIDE = 12;
94
94
  const MAX_VERTS = 6 * 4096;
95
+ /** What a context's vertex array starts at: a surface that draws a handful
96
+ * of quads should not pay for the window's batch. */
97
+ const FIRST_VERTS = 6 * 64;
95
98
  const TRI = [0, 1, 2, 0, 2, 3];
96
99
 
97
100
  const VERT = `
@@ -265,6 +268,63 @@ function parseColorUncached(value) {
265
268
  * use. Interpolated premultiplied, which is what keeps a fade to transparent
266
269
  * from passing through grey.
267
270
  */
271
+ /**
272
+ * The textures made for a gradient or an image are found by the object, in
273
+ * a WeakMap — and a WeakMap cannot say when its key goes, so its entry went
274
+ * with the key and the texture stayed in the driver. A page that makes its
275
+ * gradients as it paints made a texture a paint, and every surface context
276
+ * left behind what it had uploaded. Each is registered here and deleted
277
+ * when its key is collected, unless the context that made it was destroyed
278
+ * first and deleted it then (`made` is that context's set, which is all the
279
+ * registration holds of it).
280
+ */
281
+ const textureReaper = new FinalizationRegistry(({ gl, tex, made }) => {
282
+ if (!made.delete(tex)) return;
283
+ try {
284
+ gl.deleteTexture(tex);
285
+ } catch {
286
+ // the device went first
287
+ }
288
+ });
289
+
290
+ /**
291
+ * What the contexts on one device share (device.js): the program and its
292
+ * locations, the GL buffer a batch goes up through, and the glyph atlas.
293
+ * Each was a context's own, so every offscreen surface compiled and linked
294
+ * the program, allocated a buffer as large as the window's batch and
295
+ * rasterised every glyph it drew into an empty atlas — a few milliseconds a
296
+ * surface, which <Html> made at every width of a resize. Counted by the
297
+ * contexts that hold it; the last one destroyed deletes it.
298
+ *
299
+ * Not the vertex array: the device's owner is the only context with quads
300
+ * buffered, but `releaseDevice` lets a context keep a batch across foreign
301
+ * GL with no owner at all, and another context's quads written into a
302
+ * shared array would land on top of it. Each context keeps its own, grown
303
+ * as it draws. The GL buffer is filled from it immediately before each draw,
304
+ * so sharing that is safe.
305
+ */
306
+ function sharedOf(device, gl) {
307
+ if (device.shared) return device.shared;
308
+ const atlas = new GlyphAtlas(gl);
309
+ const shared = {
310
+ refs: 0,
311
+ program: null,
312
+ loc: null,
313
+ buffer: null,
314
+ atlas,
315
+ /** the contexts with glyph quads buffered against the atlas */
316
+ glyphHolders: new Set(),
317
+ };
318
+ // Quads already buffered against the atlas must be drawn before it lays
319
+ // itself out again under them — every context's, owner or not: drawing
320
+ // one claims the device and puts its own state back first.
321
+ atlas.onBeforeGrow = () => {
322
+ for (const ctx of [...shared.glyphHolders]) ctx._flush();
323
+ };
324
+ device.shared = shared;
325
+ return shared;
326
+ }
327
+
268
328
  class Gradient {
269
329
  constructor(kind, a) {
270
330
  this.kind = kind; // 'linear' | 'radial'
@@ -378,18 +438,16 @@ export class WaylandContext2D {
378
438
  this.fontManager = fontManager;
379
439
  /** @see ./device.js — the state this context shares with its siblings */
380
440
  this._device = glDevice(gl);
441
+ this._shared = sharedOf(this._device, gl);
442
+ this._shared.refs++;
381
443
  this._makeCurrent = makeCurrent ?? null;
382
444
  // `TextLayout.draw` opens with `ctx.window.app.display.Render` and reads
383
445
  // one constant from it. Satisfy the shape rather than fork the layout.
384
446
  this.window = { app: { display: { Render: RENDER } } };
385
- this.atlas = new GlyphAtlas(gl);
386
- // Quads already buffered against the atlas must be drawn before the
387
- // atlas re-lays itself out under them.
388
- this.atlas.onBeforeGrow = () => this._flush();
389
- // Path fills that go through the CPU rasteriser land here as A8 masks,
390
- // drawn like glyphs; emptied every frame, since a mask is one draw.
391
- this.masks = new GlyphAtlas(gl, { size: 256 });
392
- this.masks.onBeforeGrow = () => this._flush();
447
+ /** the device's glyph atlas (`sharedOf`) */
448
+ this.atlas = this._shared.atlas;
449
+ /** this context's mask atlas, made the first time a fill needs one */
450
+ this._masks = null;
393
451
  this._maskSeq = 0;
394
452
  /** the non-rectangular clips in force, device space, outermost first */
395
453
  this._clipPath = null;
@@ -426,7 +484,7 @@ export class WaylandContext2D {
426
484
  this._width = 0;
427
485
  this._height = 0;
428
486
 
429
- this._verts = new Float32Array(MAX_VERTS * STRIDE);
487
+ this._verts = new Float32Array(FIRST_VERTS * STRIDE);
430
488
  this._n = 0;
431
489
  this._mode = MODE_SOLID;
432
490
  this._texture = null;
@@ -439,26 +497,69 @@ export class WaylandContext2D {
439
497
  /** image object -> { tex, w, h } */
440
498
  this._textures = new WeakMap();
441
499
  this._gradTex = new WeakMap();
500
+ /** the textures the two maps above hold, for `destroy()` */
501
+ this._madeTextures = new Set();
502
+ }
503
+
504
+ /**
505
+ * Path fills that go through the CPU rasteriser land here as A8 masks,
506
+ * drawn like glyphs; emptied every frame, since a mask is one draw. A
507
+ * mask's key is this context's own count (`_maskSeq`), so the atlas is
508
+ * this context's — and only made once a fill needs it.
509
+ */
510
+ get masks() {
511
+ if (!this._masks) {
512
+ this._masks = new GlyphAtlas(this.gl, { size: 256 });
513
+ this._masks.onBeforeGrow = () => this._flush();
514
+ }
515
+ return this._masks;
442
516
  }
443
517
 
444
518
  // ---- lifecycle --------------------------------------------------------
445
519
 
446
- /** Compile the program and allocate the vertex buffer. Context must be current. */
520
+ /** Take the device's program and buffer, compiling and allocating them
521
+ * the first time. Context must be current. */
447
522
  init() {
448
- const gl = this.gl;
449
523
  if (this._program) return;
524
+ const shared = this._shared;
525
+ if (!shared.program) this._build(shared);
526
+ this._program = shared.program;
527
+ this._loc = shared.loc;
528
+ this._buffer = shared.buffer;
529
+ }
530
+
531
+ /** Compile the program and allocate the vertex buffer the device shares. */
532
+ _build(shared) {
533
+ const gl = this.gl;
450
534
  const vs = compile(gl, gl.VERTEX_SHADER, VERT, 'vertex');
451
- const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG, 'fragment');
535
+ let fs;
536
+ try {
537
+ fs = compile(gl, gl.FRAGMENT_SHADER, FRAG, 'fragment');
538
+ } catch (err) {
539
+ gl.deleteShader(vs);
540
+ throw err;
541
+ }
452
542
  const p = gl.createProgram();
453
543
  gl.attachShader(p, vs);
454
544
  gl.attachShader(p, fs);
455
545
  gl.linkProgram(p);
456
- if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
457
- throw new Error(`2d program failed to link: ${gl.getProgramInfoLog(p)}`);
546
+ const linked = gl.getProgramParameter(p, gl.LINK_STATUS);
547
+ const log = linked ? '' : gl.getProgramInfoLog(p);
548
+ // The shader objects are ours to delete once the program is linked.
549
+ // Deleting a program detaches its shaders and leaves them (GLES 3.0,
550
+ // 7.3), so every context — a window's, and each offscreen surface's —
551
+ // left two behind, and a resize that makes a surface a frame left two a
552
+ // frame. Deleted while attached, they are flagged and go with the
553
+ // program.
554
+ gl.deleteShader(vs);
555
+ gl.deleteShader(fs);
556
+ if (!linked) {
557
+ gl.deleteProgram(p);
558
+ throw new Error(`2d program failed to link: ${log}`);
458
559
  }
459
- this._program = p;
560
+ shared.program = p;
460
561
  const u = (n) => gl.getUniformLocation(p, n);
461
- this._loc = {
562
+ shared.loc = {
462
563
  aPos: gl.getAttribLocation(p, 'aPos'),
463
564
  aUV: gl.getAttribLocation(p, 'aUV'),
464
565
  aColor: gl.getAttribLocation(p, 'aColor'),
@@ -471,11 +572,16 @@ export class WaylandContext2D {
471
572
  uGradA: u('uGradA'),
472
573
  uGradB: u('uGradB'),
473
574
  };
474
- this._buffer = gl.createBuffer();
475
- gl.bindBuffer(gl.ARRAY_BUFFER, this._buffer);
575
+ shared.buffer = gl.createBuffer();
576
+ gl.bindBuffer(gl.ARRAY_BUFFER, shared.buffer);
476
577
  // The addon's bufferData takes a TypedArray, not the byte-length
477
- // overload WebGL also accepts. Allocated once; frames use bufferSubData.
478
- gl.bufferData(gl.ARRAY_BUFFER, this._verts, gl.DYNAMIC_DRAW);
578
+ // overload WebGL also accepts. Allocated once a device, as large as a
579
+ // batch can be; frames use bufferSubData.
580
+ gl.bufferData(
581
+ gl.ARRAY_BUFFER,
582
+ new Float32Array(MAX_VERTS * STRIDE),
583
+ gl.DYNAMIC_DRAW,
584
+ );
479
585
  }
480
586
 
481
587
  /** Where this context draws. A window's backing, or a surface's target. */
@@ -521,7 +627,7 @@ export class WaylandContext2D {
521
627
  this._clipPath = null;
522
628
  this._stencilling = false;
523
629
  this._maskSeq = 0;
524
- this.masks.reset();
630
+ this._masks?.reset();
525
631
  this._applyState();
526
632
  // scratch for paths and clips: a frame starts with it clean
527
633
  const gl = this.gl;
@@ -1059,6 +1165,7 @@ export class WaylandContext2D {
1059
1165
  const ramp = grad.ramp();
1060
1166
  if (!tex) {
1061
1167
  tex = gl.createTexture();
1168
+ this._keepTexture(grad, tex);
1062
1169
  gl.bindTexture(gl.TEXTURE_2D, tex);
1063
1170
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1064
1171
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
@@ -1631,8 +1738,9 @@ export class WaylandContext2D {
1631
1738
  ? data
1632
1739
  : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
1633
1740
  if (!entry || entry.w !== w || entry.h !== h) {
1634
- if (entry) gl.deleteTexture(entry.tex);
1741
+ if (entry) this._dropTexture(entry.tex);
1635
1742
  const tex = gl.createTexture();
1743
+ this._keepTexture(img, tex);
1636
1744
  gl.bindTexture(gl.TEXTURE_2D, tex);
1637
1745
  gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
1638
1746
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
@@ -1792,13 +1900,12 @@ export class WaylandContext2D {
1792
1900
  const size = run.size ?? font.size ?? 16;
1793
1901
  const color = this._color(run.color ?? this.fillStyle);
1794
1902
  if (color[3] === 0) continue;
1795
- const key = fontKey(font);
1903
+ const table = this.atlas.table(`${fontKey(font)}|${size}`);
1796
1904
  const tex = this.atlas.bind();
1797
1905
  this._setMode(MODE_GLYPH, tex, SWIZZLE_RGBA);
1906
+ this._shared.glyphHolders.add(this);
1798
1907
  for (const g of run.glyphs) {
1799
- const entry = this.atlas.get(`${key}|${size}|${g.id}`, () =>
1800
- font.rasterize(g.id, size),
1801
- );
1908
+ const entry = this.atlas.glyph(table, font, g.id, size);
1802
1909
  if (entry.empty) continue;
1803
1910
  // `left`/`top` are the bitmap's offset from the glyph origin in the
1804
1911
  // rasteriser's **y-down** pixel space: a cap-height glyph has a
@@ -1807,31 +1914,20 @@ export class WaylandContext2D {
1807
1914
  const oy = Math.round(applyY(m, g.x, g.y)) + entry.top;
1808
1915
  // the atlas may have grown mid-run; rebind so the batch names the
1809
1916
  // new texture (the old quads were flushed by onBeforeGrow)
1810
- if (this.atlas.texture !== this._texture)
1917
+ if (this.atlas.texture !== this._texture) {
1811
1918
  this._setMode(MODE_GLYPH, this.atlas.bind(), SWIZZLE_RGBA);
1812
- this._quad(
1813
- [
1814
- ox,
1815
- oy,
1816
- ox + entry.w,
1817
- oy,
1818
- ox + entry.w,
1819
- oy + entry.h,
1820
- ox,
1821
- oy + entry.h,
1822
- ],
1823
- [
1824
- entry.u0,
1825
- entry.v0,
1826
- entry.u1,
1827
- entry.v0,
1828
- entry.u1,
1829
- entry.v1,
1830
- entry.u0,
1831
- entry.v1,
1832
- ],
1919
+ this._shared.glyphHolders.add(this);
1920
+ }
1921
+ this._quadRect(
1922
+ ox,
1923
+ oy,
1924
+ ox + entry.w,
1925
+ oy + entry.h,
1926
+ entry.u0,
1927
+ entry.v0,
1928
+ entry.u1,
1929
+ entry.v1,
1833
1930
  color,
1834
- ZERO4,
1835
1931
  );
1836
1932
  this.shapeStats.glyphs++;
1837
1933
  }
@@ -1992,7 +2088,7 @@ export class WaylandContext2D {
1992
2088
  // `_take` is the only thing that can get it issued before someone reads
1993
2089
  // the pixels it makes. A property compare in the common case.
1994
2090
  this._claim();
1995
- if (this._n + 6 > MAX_VERTS) this._flush();
2091
+ this._room(6);
1996
2092
  const v = this._verts;
1997
2093
  const each = params.length === 16 ? 4 : 0;
1998
2094
  let o = this._n * STRIDE;
@@ -2019,7 +2115,7 @@ export class WaylandContext2D {
2019
2115
  /** One triangle, for the stencil pass. Colour and params are irrelevant. */
2020
2116
  _tri(x0, y0, x1, y1, x2, y2) {
2021
2117
  this._claim();
2022
- if (this._n + 3 > MAX_VERTS) this._flush();
2118
+ this._room(3);
2023
2119
  const v = this._verts;
2024
2120
  let o = this._n * STRIDE;
2025
2121
  const pts = [x0, y0, x1, y1, x2, y2];
@@ -2031,11 +2127,61 @@ export class WaylandContext2D {
2031
2127
  this._n += 3;
2032
2128
  }
2033
2129
 
2130
+ /**
2131
+ * An axis-aligned quad, TL-TR-BR-BL, with no params: `_quad` for a glyph,
2132
+ * written straight into the batch rather than through two arrays of
2133
+ * corners made for it — a glyph's worth of garbage a glyph.
2134
+ */
2135
+ _quadRect(x0, y0, x1, y1, u0, v0, u1, v1, color) {
2136
+ this._claim();
2137
+ this._room(6);
2138
+ const v = this._verts;
2139
+ let o = this._n * STRIDE;
2140
+ const r = color[0];
2141
+ const g = color[1];
2142
+ const b = color[2];
2143
+ const a = color[3];
2144
+ for (let k = 0; k < 6; k++) {
2145
+ const i = TRI[k];
2146
+ const right = i === 1 || i === 2;
2147
+ const bottom = i >= 2;
2148
+ v[o++] = right ? x1 : x0;
2149
+ v[o++] = bottom ? y1 : y0;
2150
+ v[o++] = right ? u1 : u0;
2151
+ v[o++] = bottom ? v1 : v0;
2152
+ v[o++] = r;
2153
+ v[o++] = g;
2154
+ v[o++] = b;
2155
+ v[o++] = a;
2156
+ v[o++] = 0;
2157
+ v[o++] = 0;
2158
+ v[o++] = 0;
2159
+ v[o++] = 0;
2160
+ }
2161
+ this._n += 6;
2162
+ this.shapeStats.quads++;
2163
+ }
2164
+
2165
+ /** Room for `count` more vertices: the batch drawn where it is full, and
2166
+ * the array grown where it is smaller than a batch can be. */
2167
+ _room(count) {
2168
+ if (this._n + count > MAX_VERTS) this._flush();
2169
+ const need = (this._n + count) * STRIDE;
2170
+ if (need <= this._verts.length) return;
2171
+ const grown = new Float32Array(
2172
+ Math.min(MAX_VERTS * STRIDE, Math.max(need, this._verts.length * 2)),
2173
+ );
2174
+ grown.set(this._verts.subarray(0, this._n * STRIDE));
2175
+ this._verts = grown;
2176
+ }
2177
+
2034
2178
  _flush() {
2035
2179
  // Before the early return, not after: `clearRect` and `putImageData`
2036
2180
  // flush, change the blend function and flush again, and the second
2037
2181
  // flush must not be the one that re-takes the device and puts the
2038
2182
  // blending back.
2183
+ // drawn or empty, nothing of this context's is left against the atlas
2184
+ this._shared.glyphHolders.delete(this);
2039
2185
  this._claim();
2040
2186
  if (this._n === 0) return;
2041
2187
  const gl = this.gl;
@@ -2072,7 +2218,8 @@ export class WaylandContext2D {
2072
2218
  // then it samples texels those glyphs never reached: garbled text,
2073
2219
  // kept until that region is next repainted.
2074
2220
  if (this._texture === this.atlas.texture) this.atlas.bind();
2075
- else if (this._texture === this.masks.texture) this.masks.bind();
2221
+ else if (this._masks && this._texture === this._masks.texture)
2222
+ this._masks.bind();
2076
2223
  gl.activeTexture(gl.TEXTURE0);
2077
2224
  gl.bindTexture(gl.TEXTURE_2D, this._texture);
2078
2225
  gl.uniform1i(this._loc.uTex, 0);
@@ -2127,14 +2274,41 @@ export class WaylandContext2D {
2127
2274
  };
2128
2275
  }
2129
2276
 
2277
+ /** A texture made for `key`: deleted when `key` is collected, or when
2278
+ * this context is destroyed, whichever comes first. */
2279
+ _keepTexture(key, tex) {
2280
+ this._madeTextures.add(tex);
2281
+ textureReaper.register(key, {
2282
+ gl: this.gl,
2283
+ tex,
2284
+ made: this._madeTextures,
2285
+ });
2286
+ }
2287
+
2288
+ /** A texture made for a key that is being given another. */
2289
+ _dropTexture(tex) {
2290
+ if (this._madeTextures.delete(tex)) this.gl.deleteTexture(tex);
2291
+ }
2292
+
2130
2293
  destroy() {
2131
2294
  // Nothing of this context's is installed any more, and the device
2132
2295
  // record should not be the one thing still holding on to it.
2296
+ if (this._destroyed) return;
2297
+ this._destroyed = true;
2133
2298
  this._release();
2134
- this.atlas.destroy();
2135
- this.masks.destroy();
2136
- if (this._buffer) this.gl.deleteBuffer(this._buffer);
2137
- if (this._program) this.gl.deleteProgram(this._program);
2299
+ for (const tex of this._madeTextures) this.gl.deleteTexture(tex);
2300
+ this._madeTextures.clear();
2301
+ this._masks?.destroy();
2302
+ this._masks = null;
2303
+ const shared = this._shared;
2304
+ shared.glyphHolders.delete(this);
2305
+ if (--shared.refs === 0) {
2306
+ // the last context on the device: what it shared goes with it
2307
+ shared.atlas.destroy();
2308
+ if (shared.buffer) this.gl.deleteBuffer(shared.buffer);
2309
+ if (shared.program) this.gl.deleteProgram(shared.program);
2310
+ if (this._device.shared === shared) this._device.shared = null;
2311
+ }
2138
2312
  this._buffer = null;
2139
2313
  this._program = null;
2140
2314
  }
@@ -2328,9 +2502,9 @@ function compile(gl, type, src, what) {
2328
2502
  gl.shaderSource(sh, src);
2329
2503
  gl.compileShader(sh);
2330
2504
  if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
2331
- throw new Error(
2332
- `${what} shader failed to compile: ${gl.getShaderInfoLog(sh)}`,
2333
- );
2505
+ const log = gl.getShaderInfoLog(sh);
2506
+ gl.deleteShader(sh);
2507
+ throw new Error(`${what} shader failed to compile: ${log}`);
2334
2508
  }
2335
2509
  return sh;
2336
2510
  }
@@ -34,6 +34,8 @@ export class GlyphAtlas {
34
34
  this.size = size;
35
35
  /** key -> { x, y, w, h, left, top, u0, v0, u1, v1 } */
36
36
  this.entries = new Map();
37
+ /** a face at a size -> (glyph id -> entry), for `glyph()` */
38
+ this._tables = new Map();
37
39
  this._shelfY = PAD;
38
40
  this._shelfH = 0;
39
41
  this._shelfX = PAD;
@@ -58,17 +60,47 @@ export class GlyphAtlas {
58
60
  get(key, raster) {
59
61
  const hit = this.entries.get(key);
60
62
  if (hit) return hit;
63
+ const entry = this._insert(raster());
64
+ if (entry.kept) this.entries.set(key, entry);
65
+ return entry;
66
+ }
67
+
68
+ /**
69
+ * The entries of one face at one size, by glyph id. A run of text looks
70
+ * its table up once, by the string that names the face and the size, and
71
+ * each glyph in it by number (`glyph()`): a string key built and hashed a
72
+ * glyph was most of what drawing text cost once the glyphs were in.
73
+ *
74
+ * A table is emptied in place when the atlas grows or resets, so one held
75
+ * across a `glyph()` that grew the atlas finds its glyphs missing rather
76
+ * than placed where they no longer are.
77
+ */
78
+ table(key) {
79
+ let table = this._tables.get(key);
80
+ if (!table) this._tables.set(key, (table = new Map()));
81
+ return table;
82
+ }
83
+
84
+ /** One glyph of a `table()`, rasterised by `font` on a miss. */
85
+ glyph(table, font, id, size) {
86
+ const hit = table.get(id);
87
+ if (hit) return hit;
88
+ const entry = this._insert(font.rasterize(id, size));
89
+ if (entry.kept) table.set(id, entry);
90
+ return entry;
91
+ }
61
92
 
62
- const g = raster();
93
+ /** Place a rasterised glyph, or say it has no ink. `kept` is whether the
94
+ * answer is worth remembering: a glyph the atlas had no room for is not,
95
+ * so the next frame asks again. */
96
+ _insert(g) {
63
97
  // A space has no ink. Cache the absence so the miss is paid once.
64
98
  if (!g || g.width <= 0 || g.height <= 0) {
65
- const empty = { empty: true, left: g?.left ?? 0, top: g?.top ?? 0 };
66
- this.entries.set(key, empty);
67
- return empty;
99
+ return { empty: true, kept: true, left: g?.left ?? 0, top: g?.top ?? 0 };
68
100
  }
69
101
 
70
102
  const spot = this._place(g.width, g.height);
71
- if (!spot) return { empty: true, left: g.left, top: g.top };
103
+ if (!spot) return { empty: true, kept: false, left: g.left, top: g.top };
72
104
 
73
105
  for (let row = 0; row < g.height; row++) {
74
106
  const src = row * g.width;
@@ -78,8 +110,9 @@ export class GlyphAtlas {
78
110
  this._markDirty(spot.x, spot.y, g.width, g.height);
79
111
 
80
112
  const inv = 1 / this.size;
81
- const entry = {
113
+ return {
82
114
  empty: false,
115
+ kept: true,
83
116
  x: spot.x,
84
117
  y: spot.y,
85
118
  w: g.width,
@@ -91,8 +124,6 @@ export class GlyphAtlas {
91
124
  u1: (spot.x + g.width) * inv,
92
125
  v1: (spot.y + g.height) * inv,
93
126
  };
94
- this.entries.set(key, entry);
95
- return entry;
96
127
  }
97
128
 
98
129
  _markDirty(x, y, w, h) {
@@ -139,6 +170,7 @@ export class GlyphAtlas {
139
170
  this.size *= 2;
140
171
  this._pixels = new Uint8Array(this.size * this.size);
141
172
  this.entries.clear();
173
+ for (const table of this._tables.values()) table.clear();
142
174
  this._shelfX = PAD;
143
175
  this._shelfY = PAD;
144
176
  this._shelfH = 0;
@@ -158,8 +190,9 @@ export class GlyphAtlas {
158
190
  * the CPU copy until new entries overwrite them; nothing names them.
159
191
  */
160
192
  reset() {
161
- if (this.entries.size === 0) return;
193
+ if (this.entries.size === 0 && this._tables.size === 0) return;
162
194
  this.entries.clear();
195
+ for (const table of this._tables.values()) table.clear();
163
196
  this._shelfX = PAD;
164
197
  this._shelfY = PAD;
165
198
  this._shelfH = 0;
@@ -233,5 +266,6 @@ export class GlyphAtlas {
233
266
  if (this.texture) this.gl.deleteTexture(this.texture);
234
267
  this.texture = null;
235
268
  this.entries.clear();
269
+ for (const table of this._tables.values()) table.clear();
236
270
  }
237
271
  }