react-x11 2.44.0 → 2.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.44.0",
3
+ "version": "2.45.0",
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",
package/src/cocoa/app.js CHANGED
@@ -1014,8 +1014,11 @@ export class CocoaApp {
1014
1014
  wnd?._shown?.(msg.seq);
1015
1015
  } else if (msg?.type === 'pane-rect') {
1016
1016
  pane.setPaneSize(msg.width, msg.height, msg.scale, msg.screen);
1017
+ if (typeof msg.live === 'boolean') pane.setLive(msg.live);
1017
1018
  this._flushPaneOutbox();
1018
1019
  this._afterInput();
1020
+ } else if (msg?.type === 'pane-live') {
1021
+ pane.setLive(msg.live === true);
1019
1022
  } else if (msg?.type === 'pane-menu-answer') {
1020
1023
  const answer = this._paneMenus?.get(msg.menu);
1021
1024
  this._paneMenus?.delete(msg.menu);
@@ -1687,6 +1690,9 @@ export class CocoaApp {
1687
1690
  const wnd = this._window(ev);
1688
1691
  if (!wnd || wnd.destroyed) return;
1689
1692
  wnd.liveResizing = ev.phase === 'begin';
1693
+ // and what lives in the window without being it hears: a `<Frame>`'s
1694
+ // pane, which has no window of its own to be resized live
1695
+ wnd.emit('liveresize', { live: wnd.liveResizing });
1690
1696
  }
1691
1697
 
1692
1698
  /**
@@ -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 {
@@ -44,6 +50,8 @@ export class CocoaPaneWindow {
44
50
  // polls it through the pane's ref, so both spellings answer
45
51
  this.id = this.windowId;
46
52
  this.windowNumber = this.windowId;
53
+ // the host's word, which the pane has no AppKit to hear (`setLive`)
54
+ this.liveResizing = false;
47
55
  this.scale = app.scale ?? 2;
48
56
  // Device pixels, as every window's attributes are (`windowAttributes`
49
57
  // multiplies the props through once): the size the pane is born at,
@@ -106,6 +114,20 @@ export class CocoaPaneWindow {
106
114
  * a host that does not say. A move alone is a `resize` that `moved`, the
107
115
  * event a window's own move is, so what is anchored here follows it.
108
116
  */
117
+ /**
118
+ * Whether the window the pane is in is being resized live, as the host
119
+ * says (`pane-rect`, `pane-live`, src/frame/index.js): what a window's
120
+ * `liveResizing` is in a process of its own, where AppKit brackets the
121
+ * drag of its edge. Core defers a content floor's measurement while it
122
+ * is (src/nodes/window/size.js), and catches it up the frame after it
123
+ * ends; and it is announced as a window announces its own.
124
+ */
125
+ setLive(live) {
126
+ if (this.destroyed || this.liveResizing === live) return;
127
+ this.liveResizing = live;
128
+ this.emit('liveresize', { live });
129
+ }
130
+
109
131
  setPaneSize(width, height, scale, screen) {
110
132
  if (this.destroyed) return;
111
133
  if (scale) this.scale = scale;
@@ -261,6 +283,8 @@ export class CocoaPaneWindow {
261
283
  let done = false;
262
284
  for (let i = 0; i < this._ring.length; i += 1) {
263
285
  const s = this._ring[i];
286
+ // a slot with no buffer yet gets one, which nothing names
287
+ if (!s) return false;
264
288
  if (i === this._shownIndex || !this._done(s)) continue;
265
289
  if (!this._held(s)) return false;
266
290
  done = true;
@@ -354,6 +378,7 @@ export class CocoaPaneWindow {
354
378
  const release = this._native.releaseSurface;
355
379
  if (typeof release !== 'function') return;
356
380
  for (const s of ring) {
381
+ if (!s) continue;
357
382
  if (s.presentedSeq > this._shownSeq) this._retired.push(s);
358
383
  else release.call(this._native, s.handle);
359
384
  }
@@ -407,6 +432,20 @@ export class CocoaPaneWindow {
407
432
  for (const s of retired) release.call(this._native, s.handle);
408
433
  }
409
434
 
435
+ /**
436
+ * A buffer of the ring, `w` by `h` device px, made the first time a frame
437
+ * takes its slot rather than with the ring. A drag resizes the pane a
438
+ * tick at a time and gives each size one frame, so a ring made whole was
439
+ * three buffers made and cleared for the one drawn into: Zen Garden 101
440
+ * in the browser example spent 4.4 ms of every 12 ms frame of a drag in
441
+ * `_ensureSurface`. Cleared where nothing is copied over it.
442
+ */
443
+ _newBuffer(w, h, clear) {
444
+ const s = this._native.createSurfaceIOSurface(w, h, this.scale, true);
445
+ if (clear) this._native.ctxClearRect(s.handle, 0, 0, w, h);
446
+ return s;
447
+ }
448
+
410
449
  _ensureSurface() {
411
450
  const w = this.width;
412
451
  const h = this.height;
@@ -417,12 +456,10 @@ export class CocoaPaneWindow {
417
456
  ) {
418
457
  const hadSurface = Boolean(this._ring);
419
458
  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
- }
459
+ // the buffer this frame draws into, and the rest of the ring as a
460
+ // frame first takes it (`_takeBack`)
461
+ this._ring = new Array(CocoaPaneWindow.RING).fill(null);
462
+ this._ring[0] = this._newBuffer(w, h, true);
426
463
  this._drawIndex = 0;
427
464
  this._shownIndex = -1;
428
465
  this._native.surfaceLock(this._ring[0].handle);
@@ -478,6 +515,15 @@ export class CocoaPaneWindow {
478
515
  for (let i = 0; i < ring.length; i += 1) {
479
516
  const s = ring[i];
480
517
  if (s === last) continue;
518
+ // a slot no buffer has been made for yet: after one let go of, which
519
+ // costs nothing to take, and before any the WindowServer still reads
520
+ if (!s) {
521
+ if (EMPTY_SLOT < rank) {
522
+ rank = EMPTY_SLOT;
523
+ pick = i;
524
+ }
525
+ continue;
526
+ }
481
527
  // done and let go of, then done, then on its way, then on the layer:
482
528
  // the oldest of the best — but of the done, the one the screen showed
483
529
  // before the host's latest answers last
@@ -494,7 +540,13 @@ export class CocoaPaneWindow {
494
540
  pick = i;
495
541
  }
496
542
  }
497
- const back = ring[pick];
543
+ // what the frame just presented is copied over whole, below, and only a
544
+ // slot with none to copy is cleared
545
+ const back = (ring[pick] ??= this._newBuffer(
546
+ this._surfaceSize.width,
547
+ this._surfaceSize.height,
548
+ !last,
549
+ ));
498
550
  this._drawIndex = pick;
499
551
  this._surface = back.handle;
500
552
  // a different native surface owns the graphics state now — the context
@@ -278,6 +278,12 @@ export class CocoaWindow {
278
278
  let list = this._handlers.get(name);
279
279
  if (!list) this._handlers.set(name, (list = []));
280
280
  list.push(fn);
281
+ // and the way to stop listening, for a listener that goes before the
282
+ // window does (`<Frame>`'s, for `liveresize`)
283
+ return () => {
284
+ const at = list.indexOf(fn);
285
+ if (at >= 0) list.splice(at, 1);
286
+ };
281
287
  }
282
288
 
283
289
  emit(name, ev) {
@@ -574,6 +574,20 @@ function PaneHostView({
574
574
  let acks = false;
575
575
  let inFlight = false;
576
576
  let waiting = null;
577
+ // And whether the window is being resized live — AppKit's drag of its
578
+ // edge, from its begin to its end (`liveResizing`) — which the pane
579
+ // has no window to hear. Core defers measuring a content floor for a
580
+ // drag's length and catches it up after it (src/nodes/window/size.js),
581
+ // and an element puts off what it would rather do once the drag ends:
582
+ // a pane measured its floors at every step, a document a pixel wide
583
+ // among them, and laid a document out whole at every rest of the drag.
584
+ // With every rect, and on its own where only that changed — the end
585
+ // of a drag comes after its last size.
586
+ const hostWindow = node.root?.window;
587
+ const liveNow = () => hostWindow?.liveResizing === true;
588
+ const offLive = hostWindow?.on?.('liveresize', (ev) =>
589
+ s.trySend?.({ type: 'pane-live', live: ev?.live === true }),
590
+ );
577
591
  const transmit = (rect) => {
578
592
  sent = rect;
579
593
  s.trySend?.({
@@ -581,6 +595,7 @@ function PaneHostView({
581
595
  width: rect.width,
582
596
  height: rect.height,
583
597
  scale: rect.scale,
598
+ live: liveNow(),
584
599
  ...(rect.screen && { screen: rect.screen }),
585
600
  });
586
601
  if (!acks) return;
@@ -685,6 +700,7 @@ function PaneHostView({
685
700
  waiting = null;
686
701
  offAnchor?.();
687
702
  offFocus?.();
703
+ if (typeof offLive === 'function') offLive();
688
704
  node.defaultCursor = undefined;
689
705
  node.root?.events?.refreshCursor();
690
706
  node.absolutize = origAbsolutize;
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
package/src/node.d.ts CHANGED
@@ -750,6 +750,20 @@ export declare class Node {
750
750
  * each scroll the pane blits, after layout.
751
751
  */
752
752
  viewportFixedRects(): Rect[] | null;
753
+ /**
754
+ * What this element draws under the viewport of the scroll pane it is in
755
+ * moved by (dx, dy) device pixels, and what the viewport shows is to stay
756
+ * where it is on screen: the pane's offsets move by as much, in the
757
+ * layout pass running now, after it measures what it holds and before it
758
+ * places it (CSS Scroll Anchoring 1) — a document laid out again at
759
+ * another width, whose text above the fold broke into more lines or
760
+ * fewer. Asked of the nearest scroll pane; shifts in one pass add up.
761
+ * Ask from the layout pass (`measureContent`); asked outside one, the
762
+ * shift waits for the next and asks for it. A `scrollTo` made since the
763
+ * last pass drops it, a `scrollIntoView` lands after it, and the result
764
+ * is clamped as any scroll. Returns whether a pane took it.
765
+ */
766
+ anchorScrollBy(dx: number, dy: number): boolean;
753
767
  /**
754
768
  * How far this element's own drawing reaches past its box, in device
755
769
  * pixels on every side — 0, the default, for an element that stays inside
@@ -137,6 +137,36 @@ export class NodeInvalidate {
137
137
  return null;
138
138
  }
139
139
 
140
+ /**
141
+ * What this element draws under the viewport of the scroll pane it is
142
+ * in moved by (dx, dy) device pixels, and what the viewport shows is to
143
+ * stay where it is on screen: the pane's offsets move by as much, in the
144
+ * layout pass running now, after it measures what it holds and before it
145
+ * places it (CSS Scroll Anchoring 1). A document laid out again at
146
+ * another width is the case: its text above the fold breaks into more
147
+ * lines or fewer, and the paragraph at the top of the viewport moves
148
+ * down or up the document by the difference — which, unanswered, scrolls
149
+ * the reader a page away from where they were.
150
+ *
151
+ * Asked of the nearest scroll pane, the one whose viewport the element
152
+ * is seen through; shifts asked of it in one pass add up. Ask from the
153
+ * layout pass — `measureContent`, or what it calls — where the pane
154
+ * places its content after; asked outside one, the shift waits for the
155
+ * next pass, and asks for it. A `scrollTo` the application made since
156
+ * the last pass drops it, since the shift was measured against the
157
+ * offsets before that scroll; a `scrollIntoView` lands after it; and the
158
+ * pane clamps the result to what it holds, as any scroll. Returns
159
+ * whether a pane took it: false where nothing scrolls the element.
160
+ */
161
+ anchorScrollBy(dx, dy) {
162
+ for (let n = this.parent; n; n = n.parent) {
163
+ if (typeof n._anchorScrollBy === 'function' && n.isScroller()) {
164
+ return n._anchorScrollBy(dx, dy);
165
+ }
166
+ }
167
+ return false;
168
+ }
169
+
140
170
  /**
141
171
  * How far this element's own drawing reaches past its box — device
142
172
  * pixels, on every side — or 0, the default, for one that stays inside
@@ -330,6 +330,10 @@ export const Scrollable = (Base) =>
330
330
  // the viewport grew, under it. A browser fires `scroll` for each, so
331
331
  // `onScroll` hears of them too, once the pass is over.
332
332
  const from = { x: this.scrollX, y: this.scrollY };
333
+ // What the content asked to keep in place lands before them all
334
+ // (`anchorScrollBy`): it moved under the viewport in this pass, and
335
+ // a scroll anyone asks for is from where it shows what it did
336
+ this._applyAnchorShift();
333
337
  // A scrollTo held for this pass lands first, so a node asked into view
334
338
  // in the same frame is brought in from where that scroll put the pane:
335
339
  // the order the two have on a laid-out pane, where scrollTo applies at
@@ -607,6 +611,10 @@ export const Scrollable = (Base) =>
607
611
  }
608
612
  this.scrollX = next.x;
609
613
  this.scrollY = next.y;
614
+ // a scroll asked for since the last pass is where the pane goes: a
615
+ // shift the content asks for in the next was measured against the
616
+ // offsets before it (`_applyAnchorShift`)
617
+ this._scrolledSincePass = true;
610
618
  if (moved) this.props.onScroll?.(this._scrollEvent());
611
619
  // A scroll reflows this viewport's contents and nothing else, and the
612
620
  // viewport clips them, so the damage is this node's own rect. It is a
@@ -707,6 +715,47 @@ export const Scrollable = (Base) =>
707
715
  return true;
708
716
  }
709
717
 
718
+ /**
719
+ * The pane's half of `Node.anchorScrollBy`: a shift of the offsets the
720
+ * content asked for, added up until the pass applies it
721
+ * (`_applyAnchorShift`). The pass measures the content again, since
722
+ * what moved under the viewport moved by being laid out; one asked
723
+ * outside a pass asks for one.
724
+ */
725
+ _anchorScrollBy(dx, dy) {
726
+ if (!Number.isFinite(dx) || !Number.isFinite(dy)) return false;
727
+ if (dx === 0 && dy === 0) return true;
728
+ const shift = (this._anchorShift ??= { x: 0, y: 0 });
729
+ shift.x += dx;
730
+ shift.y += dy;
731
+ this._scrollMeasureDirty = true;
732
+ const root = this.root;
733
+ if (root && !root._inFlush) root.invalidate(true, this, 'scroll');
734
+ return true;
735
+ }
736
+
737
+ /**
738
+ * Apply the shift the content asked for (`_anchorScrollBy`), before
739
+ * anything else the pass moves the offsets by: on each axis it scrolls,
740
+ * the clamp after takes it into the extent just measured. Its
741
+ * `onScroll` and its claim of the viewport are the pass's, as for a
742
+ * held `scrollTo`. A scroll the application asked for since the last
743
+ * pass drops it: the shift was measured against the offsets before
744
+ * that scroll, which is where the application wants the pane now.
745
+ */
746
+ _applyAnchorShift() {
747
+ const shift = this._anchorShift;
748
+ const scrolled = this._scrolledSincePass === true;
749
+ this._scrolledSincePass = false;
750
+ if (!shift) return;
751
+ this._anchorShift = null;
752
+ if (scrolled) return;
753
+ // `scrollX` is a distance from the start in either direction, and
754
+ // content that moved towards the end is followed the same way
755
+ this.scrollX += this.direction === 'rtl' ? -shift.x : shift.x;
756
+ this.scrollY += shift.y;
757
+ }
758
+
710
759
  /**
711
760
  * Answer a held request (`_holdScrollTo`) against the extent this pass
712
761
  * has just measured: each held axis's base, clamped, then each step
@@ -533,10 +533,12 @@ export function setMeasuringShrink(node, axis, out) {
533
533
  * It is still laid out, though, and a leaf's answer could reach something
534
534
  * that is read through its size. So it is marked only where it cannot:
535
535
  *
536
- * - every box from the root down to it is as wide as something other than
537
- * its content — the root offered no room, a width it names, a column
538
- * stretching it — so the width the leaf answers is one yoga replaces, and
539
- * nothing above it is sized from it;
536
+ * - every box from `node` down to it is as wide as something other than
537
+ * its content — `node` the width the pass gives it (no room, for the
538
+ * window; its column's, for a spine root; its own, for a box that sizes
539
+ * itself), and below it a width it names or a column stretching it — so
540
+ * the width the leaf answers is one yoga replaces, and nothing above it
541
+ * is sized from it;
540
542
  * - no box is handed a share of space the leaf's height changed, which is
541
543
  * how a height could become somebody's width — an aspect ratio, a column
542
544
  * that wraps, an image sized to the height it is given. Every column from
@@ -656,15 +658,28 @@ function stretchedAlong(node, axis) {
656
658
  * names are numbers — a height, a basis (a height, down a column), a floor or
657
659
  * a ceiling; and `'unknown'` for a share of something (a percentage), which
658
660
  * a pass with no height on offer resolves as it likes.
661
+ *
662
+ * A floor of nothing bounds nothing: a box as tall as its content is at
663
+ * least that tall already, so its height is its content's. It is how a box
664
+ * says it may shrink, and a browser's page has one on every box from the
665
+ * window down to its scroller, so that a page taller than the window
666
+ * scrolls. Read as a height of the box's own, it stopped the walk at the
667
+ * first of them, where the scroller gives way: the whole document in the
668
+ * scroller was set a word to a line each time the tree was measured, 100
669
+ * to 250 ms of a frame for a long Wikipedia article, and nothing read any
670
+ * of it. And it took the box pinned to both edges of such a column for one
671
+ * whose height nothing in flow decides, which it is.
659
672
  */
660
673
  function heightOf(yoga) {
674
+ const floor = yoga.getMinHeight();
661
675
  let own = false;
662
676
  for (const value of [
663
677
  yoga.getHeight(),
664
678
  yoga.getFlexBasis(),
665
- yoga.getMinHeight(),
679
+ floor.value === 0 ? null : floor,
666
680
  yoga.getMaxHeight(),
667
681
  ]) {
682
+ if (value === null) continue;
668
683
  if (value.unit === Yoga.UNIT_PERCENT) return 'unknown';
669
684
  if (value.unit === Yoga.UNIT_POINT) own = true;
670
685
  }
@@ -354,12 +354,28 @@ export class WindowSize {
354
354
  if (axis === 'width') {
355
355
  const shrunk = [];
356
356
  setMeasuringShrink(root, axis, shrunk);
357
+ // Nothing reads a root's own width — only heights travel up a spine
358
+ // (spine.js) — so what it holds is read only where a floor is written
359
+ // from it, as down the window's columns, and a leaf that is the root
360
+ // is laid out at the width its column offers, which is no answer of
361
+ // its own: a document in a scroller is a root when it changes.
362
+ const read = NO_UNREAD_WIDTHS || this._readEveryWidth;
363
+ const unread = [];
364
+ if (!read && root._measureFn) {
365
+ root._widthUnread = true;
366
+ unread.push(root);
367
+ } else if (!read) {
368
+ markUnreadLeaves(root, unread);
369
+ }
357
370
  this._scopedFloorPasses += 1;
358
- yoga.calculateLayout(widths.widthPass, undefined, dir);
359
- const span = contentSpan(root, axis, null, this);
360
- root._floorW = declaresOwnMinimum(root, axis)
361
- ? yoga.getComputedWidth()
362
- : span;
371
+ try {
372
+ yoga.calculateLayout(widths.widthPass, undefined, dir);
373
+ } finally {
374
+ for (const leaf of unread) leaf._widthUnread = false;
375
+ }
376
+ const span = contentSpan(root, axis, null, this, read);
377
+ const own = declaresOwnMinimum(root, axis);
378
+ if (own || read) root._floorW = own ? yoga.getComputedWidth() : span;
363
379
  restoreShrink(shrunk);
364
380
  return;
365
381
  }
@@ -443,9 +459,18 @@ export class WindowSize {
443
459
  if (axis === 'width') {
444
460
  const shrunk = [];
445
461
  setMeasuringShrink(boundary, axis, shrunk);
462
+ // its own extent is its style's, so what it holds is read only where
463
+ // a floor is written from it, as down the window's columns
464
+ const read = NO_UNREAD_WIDTHS || this._readEveryWidth;
465
+ const unread = [];
466
+ if (!read) markUnreadLeaves(boundary, unread);
446
467
  this._scopedFloorPasses += 1;
447
- yoga.calculateLayout(undefined, undefined, dir);
448
- contentSpan(boundary, axis, null, this);
468
+ try {
469
+ yoga.calculateLayout(undefined, undefined, dir);
470
+ } finally {
471
+ for (const leaf of unread) leaf._widthUnread = false;
472
+ }
473
+ contentSpan(boundary, axis, null, this, read);
449
474
  restoreShrink(shrunk);
450
475
  return;
451
476
  }
@@ -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
  }