react-x11 2.15.3 → 2.16.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.
Files changed (67) hide show
  1. package/README.md +37 -0
  2. package/package.json +3 -3
  3. package/src/Reconciler.js +85 -22
  4. package/src/acceleratorhooks.js +40 -6
  5. package/src/anchor.js +79 -19
  6. package/src/capabilities.js +29 -4
  7. package/src/cocoa/app.js +211 -11
  8. package/src/cocoa/context2d.js +23 -0
  9. package/src/cocoa/fonts.js +78 -0
  10. package/src/cocoa/presenter.js +17 -0
  11. package/src/cocoa/promotion.js +20 -0
  12. package/src/cocoa/relaunch.js +8 -3
  13. package/src/cocoa/symbols.js +64 -0
  14. package/src/cocoa/threaded.js +24 -4
  15. package/src/cocoa/window.js +362 -139
  16. package/src/components/ProgressBar.js +1 -1
  17. package/src/components/Slider.js +72 -39
  18. package/src/components/anchor.js +7 -2
  19. package/src/components/index.js +1 -0
  20. package/src/components/theme.js +32 -28
  21. package/src/desktopcapabilityhooks.js +29 -6
  22. package/src/filedialoghooks.js +3 -5
  23. package/src/frame/childmain.js +8 -20
  24. package/src/frame/env.js +2 -10
  25. package/src/icontheme.js +240 -0
  26. package/src/imagesource.js +83 -1
  27. package/src/index.d.ts +10 -1
  28. package/src/index.js +3 -0
  29. package/src/keysymchars.js +47 -0
  30. package/src/keysyms.d.ts +19 -1
  31. package/src/keysyms.js +107 -8
  32. package/src/node.d.ts +7 -0
  33. package/src/nodes/animation.js +17 -47
  34. package/src/nodes/cascade.js +17 -2
  35. package/src/nodes/image.js +63 -1
  36. package/src/nodes/kinds.js +12 -0
  37. package/src/nodes/layout.js +5 -1
  38. package/src/nodes/node.js +17 -3
  39. package/src/nodes/paint.js +117 -0
  40. package/src/nodes/scope.js +259 -0
  41. package/src/nodes/scrollable.js +53 -6
  42. package/src/nodes/text.js +2 -0
  43. package/src/nodes/textarea.js +1 -1
  44. package/src/nodes/textinput.js +1 -1
  45. package/src/nodes/window/anchoring.js +45 -18
  46. package/src/nodes/window/flush.js +6 -5
  47. package/src/nodes/window/popup.js +10 -0
  48. package/src/nodes/window/size.js +40 -2
  49. package/src/nodes/window/window.js +41 -14
  50. package/src/registry.js +2 -1
  51. package/src/screens.js +159 -24
  52. package/src/settings.js +332 -0
  53. package/src/statusnotifier.js +164 -17
  54. package/src/styles.js +212 -8
  55. package/src/symbols.js +200 -0
  56. package/src/testing/mock-app.js +10 -0
  57. package/src/trayhooks.js +21 -5
  58. package/src/types/capabilities.d.ts +13 -1
  59. package/src/types/components.d.ts +33 -0
  60. package/src/types/elements.d.ts +57 -6
  61. package/src/types/events.d.ts +5 -0
  62. package/src/types/filedialog.d.ts +3 -1
  63. package/src/types/style.d.ts +57 -0
  64. package/src/types/system.d.ts +104 -0
  65. package/src/types/tray.d.ts +14 -2
  66. package/src/wayland/xkb.js +170 -59
  67. package/src/windowid.js +62 -20
@@ -40,36 +40,6 @@ const gridContainerMoved = (was, now) =>
40
40
  const gridItemMoved = (was, now) =>
41
41
  GRID_ITEM_PROPS.some((prop) => was[prop] !== now[prop]);
42
42
 
43
- /**
44
- * The node whose bounds cover where an animating node will be next frame, or
45
- * `null` when that cannot be known and the frame has to repaint everything.
46
- *
47
- * Three cases, and the middle one is the interesting one:
48
- *
49
- * - **paint-only** (a colour, an opacity): the node stays put, so its own
50
- * bounds are the damage.
51
- * - **a layout property on an out-of-flow node** (`position: absolute`, the
52
- * arrangement a sliding thumb uses): the node moves, so its own bounds
53
- * cover where it is going but not where it has been. Its *parent* covers
54
- * both — an absolute child is laid out inside its parent and, being out of
55
- * flow, moves nothing else when it shifts. This is what keeps a `Switch`
56
- * from repainting the window on every frame of its 120ms slide.
57
- * - **a layout property in flow**: a reflow can move any node in the tree,
58
- * including ones that leave stale pixels outside every bound we could name
59
- * here. Nothing to do but repaint in full.
60
- */
61
- function damageForAnimation(node) {
62
- let movesInLayout = false;
63
- for (const prop of node._anim?.keys() ?? []) {
64
- if (isLayoutProp(prop)) movesInLayout = true;
65
- }
66
- if (!movesInLayout) return node;
67
- if (node.style?.position !== 'absolute') return null;
68
- // A window parent bounds nothing useful — its own rect is the whole surface.
69
- const parent = node.parent;
70
- return parent && !parent.isWindow ? parent : null;
71
- }
72
-
73
43
  // Frame timestamps for transitions. Indirected so tests can drive the clock
74
44
  // instead of sleeping through real animations.
75
45
  export let now = () => Date.now();
@@ -129,7 +99,7 @@ export class NodeAnimation {
129
99
  // such presenter, the window's frame clock runs it as it always has.
130
100
  if (this._offload(prop, entry)) {
131
101
  entry.offloaded = true;
132
- this.root?.invalidate(false, damageForAnimation(this), 'animation');
102
+ this.root?.invalidate(false, this, 'animation');
133
103
  } else {
134
104
  // …and one the presenter had must not keep running underneath the
135
105
  // values the clock is about to write
@@ -342,7 +312,7 @@ export class NodeAnimation {
342
312
  }
343
313
  }
344
314
  this.root?._animating.delete(this);
345
- this.root?.invalidate(false, damageForAnimation(this), 'animation');
315
+ this.root?.invalidate(false, this, 'animation');
346
316
  }
347
317
 
348
318
  /**
@@ -608,7 +578,7 @@ export class WindowAnimation {
608
578
  // invalidate anyway, but a React prop change does not — and a transition
609
579
  // no one schedules only runs when something else dirties the window,
610
580
  // by which time its start is stale and it snaps to the end.
611
- this.invalidate(false, damageForAnimation(node), 'animation');
581
+ this.invalidate(false, node, 'animation');
612
582
  }
613
583
 
614
584
  /**
@@ -618,27 +588,27 @@ export class WindowAnimation {
618
588
  */
619
589
  _advanceAnimations(now) {
620
590
  if (this._animating.size === 0) return;
621
- const claims = [];
622
591
  for (const node of [...this._animating]) {
623
592
  if (node.destroyed) {
624
593
  this._animating.delete(node);
625
594
  continue;
626
595
  }
627
- // Decided *before* the tick, deliberately: a tick that finishes deletes
628
- // the property from `_anim`, and after that there is no way to tell a
629
- // layout animation from a paint-only one the node's own bounds would
630
- // be claimed for something that just moved, leaving a trail behind it.
631
- claims.push(damageForAnimation(node));
596
+ // The node where it stands, which is where it *was*: nothing has been
597
+ // laid out yet, so its rect is the one the last pass gave it. Where it
598
+ // goes is the layout pass's to claim. A tick on a layout property asks
599
+ // for one, and that pass claims the old and new rect of every node it
600
+ // moves (`_assignAbs`) — this one, and whatever this one pushed, in
601
+ // flow or out of it. A paint-only property moves nothing, and this
602
+ // claim is the whole frame.
603
+ //
604
+ // Claimed rather than left unbounded because an animation repaints
605
+ // every frame for as long as it runs: eight height loops in a menu-bar
606
+ // popover were a full-window repaint per frame at 120Hz (#603). A node
607
+ // that *finishes* on this tick is claimed too — it just landed on its
608
+ // final value, and this last frame still has to paint it there.
609
+ this.invalidate(false, node, 'animation');
632
610
  if (!node._tickAnimations(now)) this._animating.delete(node);
633
611
  }
634
612
  this.needsPaint = true;
635
- // Claim a region rather than leaving the frame unbounded: an animation is
636
- // a repaint every frame for its whole duration, so this is the difference
637
- // between a 120ms transition costing eight full-window repaints and eight
638
- // repaints of the thing that moved. Nodes that *finished* on this tick are
639
- // claimed too — one just landed on its final value and that last frame
640
- // still has to paint it, which is why every transition used to end with a
641
- // full-window repaint.
642
- for (const claim of claims) this.invalidate(false, claim, 'animation');
643
613
  }
644
614
  }
@@ -190,10 +190,20 @@ export class NodeCascade {
190
190
  * A detached node has no ancestors yet and so cannot see a provider two
191
191
  * levels up; it still resolves, against the base, and `_themeChanged()` on
192
192
  * attach re-resolves it against the real one.
193
+ *
194
+ * A window can take its palette from somewhere other than its parent: a
195
+ * `<ThemeProvider>` above it at the root, which is not its parent because a
196
+ * top-level window has none, or one directly inside the window it is
197
+ * nested in, which passed it on to that window. Either is `_scope`, and it
198
+ * comes first (nodes/scope.js).
193
199
  */
194
200
  get theme() {
195
201
  if (this._theme !== undefined) return this._theme;
196
- const inherited = this.parent ? this.parent.theme : baseTheme();
202
+ const inherited = this._scope
203
+ ? this._scope.theme
204
+ : this.parent
205
+ ? this.parent.theme
206
+ : baseTheme();
197
207
  const own = this.props.theme;
198
208
  this._theme = own ? { ...inherited, ...own } : inherited;
199
209
  return this._theme;
@@ -312,7 +322,12 @@ export class NodeCascade {
312
322
  const size = (inherited.size * to) / from;
313
323
  const cached = this._textScaled;
314
324
  if (cached?.from !== inherited || cached.style.size !== size) {
315
- this._textScaled = { from: inherited, style: { ...inherited, size } };
325
+ const style = { ...inherited, size };
326
+ // the other length that travels, re-expressed the same way
327
+ if (inherited.letterSpacing) {
328
+ style.letterSpacing = (inherited.letterSpacing * to) / from;
329
+ }
330
+ this._textScaled = { from: inherited, style };
316
331
  }
317
332
  return this._textScaled.style;
318
333
  }
@@ -11,6 +11,7 @@ import {
11
11
  isDirectImageSource,
12
12
  isPathImageSource,
13
13
  isRawImageSource,
14
+ isSymbolImageSource,
14
15
  releaseImageSource,
15
16
  toLoadablePath,
16
17
  validateImageProps,
@@ -20,6 +21,7 @@ import {
20
21
  // is a *load-time* SyntaxError, which would take the renderer down rather
21
22
  // than the one feature that needs it.
22
23
  import * as ntk from 'ntk';
24
+ import { symbolWeight, symbolsFor, warnOnce } from '../symbols.js';
23
25
  import { intrinsicSize } from './layout.js';
24
26
  import { Node } from './node.js';
25
27
  import { DEV } from './util.js';
@@ -42,6 +44,8 @@ export class ImageNode extends Node {
42
44
  this._ownedImage = null;
43
45
  /** PictureSource/DrawableSource, when the source is server-side */
44
46
  this._serverSource = null;
47
+ /** `{ symbol, … }`, when the source is a name the platform draws */
48
+ this._symbol = null;
45
49
  // Resolution waits for the first layout/paint: the constructor runs in
46
50
  // the render phase, which React may discard, and resolving here would
47
51
  // start file reads and take cache holds nothing would ever release.
@@ -67,6 +71,25 @@ export class ImageNode extends Node {
67
71
  measureContent(constraints) {
68
72
  this._ensureSource();
69
73
  const s = this.scale;
74
+ if (this._symbol) {
75
+ const name = this._symbol.symbol;
76
+ const size = symbolsFor(this.app).size(name, this._symbolOptions());
77
+ // A name this desktop does not have takes no room and draws nothing,
78
+ // which is right for an app that runs on both and wrong for a typo —
79
+ // development tells the two apart for it.
80
+ if (!size) {
81
+ warnOnce(
82
+ `react-x11: <image src={{ symbol: ${JSON.stringify(name)} }}> is ` +
83
+ 'not a symbol this desktop has, so it takes no room and draws ' +
84
+ 'nothing. SF Symbols are the names on macOS, and the icon ' +
85
+ "theme's names, like 'audio-volume-high', elsewhere.",
86
+ );
87
+ }
88
+ return intrinsicSize(
89
+ { width: (size?.width ?? 0) * s, height: (size?.height ?? 0) * s },
90
+ constraints,
91
+ );
92
+ }
70
93
  return intrinsicSize(
71
94
  {
72
95
  width: (this.image?.width ?? 0) * s,
@@ -96,6 +119,12 @@ export class ImageNode extends Node {
96
119
  return;
97
120
  }
98
121
  if (src == null) return;
122
+ if (isSymbolImageSource(src)) {
123
+ // nothing to load: the platform draws the name at paint, which is also
124
+ // when the text colour it is drawn in is known
125
+ this._symbol = src;
126
+ return;
127
+ }
99
128
  if (isDirectImageSource(src)) {
100
129
  // the caller's object — its upload cache is the dedupe, and it is
101
130
  // never destroyed here
@@ -223,9 +252,29 @@ export class ImageNode extends Node {
223
252
  this._serverSource.destroy?.();
224
253
  this._serverSource = null;
225
254
  }
255
+ this._symbol = null;
226
256
  this.image = null;
227
257
  }
228
258
 
259
+ /**
260
+ * How a symbol is drawn beside the text around it: at that text's size and
261
+ * weight unless the source says otherwise — what SF Symbols are designed
262
+ * for, and what lets a toolbar of them follow a theme's `fontSize` — in its
263
+ * colour, which is `currentColor` for an `<svg>` too. Sizes are logical.
264
+ */
265
+ _symbolOptions() {
266
+ const text = this.resolvedTextStyle();
267
+ const src = this._symbol;
268
+ return {
269
+ pointSize: text.size / this.scale,
270
+ weight: symbolWeight(src.weight ?? text.weight),
271
+ scale: src.scale,
272
+ variableValue: src.variableValue,
273
+ displayScale: this.scale,
274
+ color: text.color,
275
+ };
276
+ }
277
+
229
278
  applyProps(newProps, oldProps) {
230
279
  const before = oldProps ?? this.props;
231
280
  const sourceChanged = imageSourceChanged(newProps, before);
@@ -234,12 +283,16 @@ export class ImageNode extends Node {
234
283
  super.applyProps(newProps, oldProps);
235
284
  if (!sourceChanged) return;
236
285
  const prev = this.image;
286
+ const wasSymbol = this._symbol;
237
287
  this._releaseSource();
238
288
  this._sourceDirty = false;
239
289
  this._resolveSource();
240
290
  // paintChanged already claimed this node's box through super; only a
241
- // new intrinsic size needs more than that
291
+ // new intrinsic size needs more than that — and a symbol's size is the
292
+ // platform's to say, so any change to one is measured again
242
293
  if (
294
+ wasSymbol ||
295
+ this._symbol ||
243
296
  (prev?.width ?? 0) !== (this.image?.width ?? 0) ||
244
297
  (prev?.height ?? 0) !== (this.image?.height ?? 0)
245
298
  ) {
@@ -254,6 +307,15 @@ export class ImageNode extends Node {
254
307
 
255
308
  paintContent(ctx) {
256
309
  this._ensureSource();
310
+ if (this._symbol) {
311
+ symbolsFor(this.app).draw(
312
+ ctx,
313
+ this._symbol.symbol,
314
+ this.contentBox(),
315
+ this._symbolOptions(),
316
+ );
317
+ return;
318
+ }
257
319
  if (!this.image) return;
258
320
  const content = this.contentBox();
259
321
  ctx.drawImage(
@@ -29,3 +29,15 @@ export const CUSTOM_SEMANTIC_NAMES = new Map();
29
29
  * arrangement as above, for the other declaration a scene-drawing element
30
30
  * makes (issue #301). */
31
31
  export const CUSTOM_SELF_DAMAGED = new Map();
32
+
33
+ /**
34
+ * The element `<ThemeProvider>` renders to carry its palette into the node
35
+ * tree. Not one of `HOST_TYPES` and not documented as an element: the
36
+ * provider is the API. What node it becomes depends on where it is written
37
+ * (`createInstance`): inside a window it is a `<box>` that fills its parent —
38
+ * directly inside one, a `ThemeBoxNode` that also hands nested windows on to
39
+ * it — and at the root of the tree, above the windows, where nothing drawn
40
+ * may be, a `ThemeScopeNode` that draws nothing and hands the palette to the
41
+ * windows under it (nodes/scope.js).
42
+ */
43
+ export const THEME_SCOPE = 'themescope';
@@ -284,7 +284,11 @@ export class NodeLayout {
284
284
  // cached unions all the way up with it
285
285
  this._clearHitBounds();
286
286
  if (layoutDiff.sink) {
287
- const grow = this._outlineExtent() + DAMAGE_SLOP;
287
+ // the reach `_ownPaintBounds` names, ring and shadow alike: a card
288
+ // pushed down by a row above it leaves its old shadow on the surface
289
+ // unless the claim for where it was covers that shadow too
290
+ const grow =
291
+ Math.max(this._outlineExtent(), this._shadowExtent()) + DAMAGE_SLOP;
288
292
  const shift = layoutDiff.shift;
289
293
  const had = old.width > 0 && old.height > 0;
290
294
  if (shift) {
package/src/nodes/node.js CHANGED
@@ -34,7 +34,7 @@ import { NO_DAMAGE } from './damage.js';
34
34
  import { NodeHitTest } from './hittest.js';
35
35
  import { installMethods } from './install.js';
36
36
  import { NodeInvalidate } from './invalidate.js';
37
- import { CUSTOM_SELF_DAMAGED } from './kinds.js';
37
+ import { CUSTOM_SELF_DAMAGED, THEME_SCOPE } from './kinds.js';
38
38
  import { NodeLayout } from './layout.js';
39
39
  import { NodeLayoutHost } from './layouthost.js';
40
40
  import { NodePaint } from './paint.js';
@@ -154,6 +154,9 @@ export class Node {
154
154
  // subtree's hit reach, invalidated through _clearHitBounds()
155
155
  this._paintOrderCache = null;
156
156
  this._hitBoundsCache = null;
157
+ // the surface an `opacity` below 1 draws this subtree through, kept from
158
+ // one frame to the next while it fits (`NodePaint._paintGroup`)
159
+ this._groupSurface = null;
157
160
  // a `$token` the theme does not define, held for `commitMount` to throw
158
161
  // on this node's own fiber — see `_tokenProblem`. Strict mode only.
159
162
  // `null` is "commitMount is still to come", `false` is "it has been and
@@ -327,9 +330,19 @@ export class Node {
327
330
  return;
328
331
  }
329
332
  if (child.isWindow) {
333
+ // A `<ThemeProvider>` inside a window is drawn as a box, which the
334
+ // developer never wrote — so the message names what they did write.
335
+ // Directly inside a window it passes a nested window on
336
+ // (nodes/scope.js, `ThemeBoxNode`); this is one further down.
330
337
  throw new Error(
331
- `react-x11: <window> cannot be nested inside <${this.kind}>; ` +
332
- 'windows may only appear at the root or inside another <window>.',
338
+ this._reactFiber?.type === THEME_SCOPE
339
+ ? 'react-x11: a <window> under this <ThemeProvider> cannot be ' +
340
+ 'nested — the provider is inside a <box>, where it is a box ' +
341
+ 'itself, and a window nests only in a window. Put the ' +
342
+ 'provider directly inside the outer <window>, or inside the ' +
343
+ 'nested one.'
344
+ : `react-x11: <window> cannot be nested inside <${this.kind}>; ` +
345
+ 'windows may only appear at the root or inside another <window>.',
333
346
  );
334
347
  }
335
348
  // A registered element that declared childrenAllowed: false says so
@@ -499,6 +512,7 @@ export class Node {
499
512
  * freed by the caller via freeRecursive on the subtree top. */
500
513
  destroySubtree() {
501
514
  this.destroyed = true;
515
+ if (this._groupSurface) this._releaseGroupSurface();
502
516
  // a loop outlives nothing: the window drops it from the set that keeps
503
517
  // its frame clock alive, and stops watching visibility with the last one
504
518
  this.root?._forgetLoopNode(this);
@@ -3,6 +3,7 @@
3
3
  // window, background first.
4
4
 
5
5
  import { isPlaced } from '../layouts.js';
6
+ import { Surface } from '../ntk.js';
6
7
  import { isPaintedColor } from './boxpaint.js';
7
8
  import { DAMAGE_SLOP } from './damage.js';
8
9
  import { DRAWN_KINDS } from './kinds.js';
@@ -97,6 +98,122 @@ export class NodePaint {
97
98
 
98
99
  paint(ctx) {
99
100
  if (this.hidden) return;
101
+ const opacity = this.style.opacity;
102
+ // A retained presenter replaying this node's own paint into a layer puts
103
+ // the opacity on the layer instead (src/cocoa/presenter.js), and a group
104
+ // drawn here as well would fade it twice.
105
+ if (opacity !== undefined && opacity < 1 && !this._ownPaintOnly) {
106
+ // Nothing to see, so nothing to draw — NaN included. The node is still
107
+ // laid out and still hit, as CSS's `opacity: 0` is.
108
+ if (!(opacity > 0)) return;
109
+ this._paintGroup(ctx, opacity);
110
+ return;
111
+ }
112
+ // back to opaque: the surface a fade drew through is not needed any more
113
+ if (this._groupSurface) this._releaseGroupSurface();
114
+ this._paintNode(ctx);
115
+ }
116
+
117
+ /**
118
+ * `opacity` below 1: this node and everything under it drawn **once**, into
119
+ * a surface, and composited at that alpha — so it fades as a group. An icon
120
+ * over its own card does not show the card through the icon, a border does
121
+ * not double up where it meets a background, and text keeps the colour it
122
+ * had. That is CSS's `opacity`, and the reason it is not a `globalAlpha` on
123
+ * each draw.
124
+ *
125
+ * The surface is the subtree's paint bounds inside the window — stable
126
+ * across frames, so a fade reuses it — and only this pass's damage is drawn
127
+ * into it. Where the backend makes no surfaces (the headless mock) the
128
+ * subtree is drawn with the alpha on each draw instead: the same pixels
129
+ * wherever nothing inside it overlaps.
130
+ */
131
+ _paintGroup(ctx, opacity) {
132
+ const rect = this._groupRect();
133
+ if (!rect) return;
134
+ const surface = this._groupSurfaceFor(rect.width, rect.height);
135
+ const alpha = ctx.globalAlpha ?? 1;
136
+ if (!surface) {
137
+ ctx.globalAlpha = alpha * opacity;
138
+ try {
139
+ this._paintNode(ctx);
140
+ } finally {
141
+ ctx.globalAlpha = alpha;
142
+ }
143
+ return;
144
+ }
145
+ surface.render((sctx) => {
146
+ sctx.clearRect(0, 0, rect.width, rect.height);
147
+ sctx.translate(-rect.x, -rect.y);
148
+ const damage = this.root?._paintDamage;
149
+ if (damage) {
150
+ sctx.beginPath();
151
+ sctx.rect(damage.x, damage.y, damage.width, damage.height);
152
+ sctx.clip();
153
+ }
154
+ this._paintNode(sctx);
155
+ });
156
+ ctx.globalAlpha = alpha * opacity;
157
+ try {
158
+ ctx.drawImage(surface, rect.x, rect.y);
159
+ } finally {
160
+ ctx.globalAlpha = alpha;
161
+ }
162
+ }
163
+
164
+ /** Where a group's surface goes: the subtree's paint bounds, on whole
165
+ * pixels, cut to the window — a faded pane scrolled far off the top has
166
+ * nothing of its own down there to keep. Null when none of it is on the
167
+ * window at all. */
168
+ _groupRect() {
169
+ const b = this._subtreeBounds();
170
+ const win = this.root?.abs;
171
+ let x0 = Math.floor(b.x);
172
+ let y0 = Math.floor(b.y);
173
+ let x1 = Math.ceil(b.x + b.width);
174
+ let y1 = Math.ceil(b.y + b.height);
175
+ if (win) {
176
+ x0 = Math.max(x0, 0);
177
+ y0 = Math.max(y0, 0);
178
+ x1 = Math.min(x1, Math.ceil(win.width));
179
+ y1 = Math.min(y1, Math.ceil(win.height));
180
+ }
181
+ if (x1 <= x0 || y1 <= y0) return null;
182
+ return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
183
+ }
184
+
185
+ /** The group surface at this size, kept from the last frame when it fits —
186
+ * a fade asks for the same one every frame. Null where the backend has
187
+ * none to give. */
188
+ _groupSurfaceFor(width, height) {
189
+ const kept = this._groupSurface;
190
+ if (kept && kept.width === width && kept.height === height) return kept;
191
+ if (kept) this._releaseGroupSurface();
192
+ const app = this.app;
193
+ if (!app?.display?.Render && typeof app?.createSurface !== 'function') {
194
+ return null;
195
+ }
196
+ try {
197
+ this._groupSurface = new Surface(app, { width, height });
198
+ } catch {
199
+ // no pixmap to be had: the fallback draws with the alpha instead
200
+ this._groupSurface = null;
201
+ }
202
+ return this._groupSurface;
203
+ }
204
+
205
+ _releaseGroupSurface() {
206
+ const surface = this._groupSurface;
207
+ this._groupSurface = null;
208
+ try {
209
+ surface?.destroy();
210
+ } catch {
211
+ // gone with its connection
212
+ }
213
+ }
214
+
215
+ /** Everything this node draws, and its children: `paint` minus the group. */
216
+ _paintNode(ctx) {
100
217
  // Outside the box and under everything, which is the whole of what makes
101
218
  // a shadow different from a colour: it is drawn before this node's own
102
219
  // background so a translucent background does not sit on top of it, and