react-x11 2.27.0 → 2.29.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.27.0",
3
+ "version": "2.29.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -108,7 +108,7 @@
108
108
  "yoga-layout": "^3.2.1"
109
109
  },
110
110
  "optionalDependencies": {
111
- "@windowkit/appkit": "^0.18.0",
111
+ "@windowkit/appkit": "^0.19.0",
112
112
  "@windowkit/wayland": "^3.1.1",
113
113
  "@windowkit/win32": "^0.0.6",
114
114
  "dbus-native": "^0.16.0",
@@ -356,6 +356,9 @@ const ANIMATED_KEY_PATHS = Object.freeze({
356
356
  borderColor: { keyPath: 'borderColor', colour: true },
357
357
  borderWidth: { keyPath: 'borderWidth', scaled: true },
358
358
  borderRadius: { keyPath: 'cornerRadius', scaled: true },
359
+ // the subtree's, as CSS has it: a layer under an opacity is drawn with
360
+ // its sublayers and faded as one (`layerOpacity`)
361
+ opacity: { keyPath: 'opacity' },
359
362
  });
360
363
 
361
364
  // the ids the bridge reports an animation's end under: unique per process,
@@ -487,9 +490,11 @@ export function propBoxProps(node, app, scale, parentOrigin, order) {
487
490
  }
488
491
 
489
492
  /**
490
- * `opacity` as a layer's own. On the layer presenter a node's children are
491
- * its sublayers, so this fades the subtree with it; the bitmap paths draw
492
- * the group themselves and never promote a faded box.
493
+ * `opacity` as a layer's own. A node's children are its layer's sublayers —
494
+ * each its own on the layer presenter, one raster on a promoted box — and
495
+ * Core Animation draws a layer under an opacity below 1 with its sublayers
496
+ * and fades them as one (`allowsGroupOpacity`, YES by default on macOS): the
497
+ * group `NodePaint._paintGroup` draws on the bitmap.
493
498
  */
494
499
  export function layerOpacity(style) {
495
500
  const value = style?.opacity;
@@ -24,11 +24,12 @@
24
24
  // promoted only when nothing painted after it in the walk reaches into its
25
25
  // bounds — no later sibling at any level, no ancestor's border ring or
26
26
  // focus ring, no scrollbar — and only when every clipping ancestor holds
27
- // the whole of it, because the layer would not be clipped. Declining is
27
+ // the whole of it, because the layer would not be clipped, and no ancestor
28
+ // fades, because the layer would not be faded with it. Declining is
28
29
  // always safe: the frame clock runs the animation exactly as it does
29
30
  // without this file. And the same test runs again every frame, so a node
30
- // that becomes overlapped, hidden, clipped or non-plain returns to the
31
- // bitmap in the frame that finds it, the animation handed back to the
31
+ // that becomes overlapped, hidden, clipped, faded or non-plain returns to
32
+ // the bitmap in the frame that finds it, the animation handed back to the
32
33
  // clock. Overlays, toasts, drag ghosts, spinners and floating cards pass by
33
34
  // construction; a hover fade on a row in the middle of a list does not,
34
35
  // and stays on the clock. docs/macos.md §"Layer promotion" is the account.
@@ -44,6 +45,7 @@ import {
44
45
  propBoxProps,
45
46
  stylePaintsPlain,
46
47
  } from './presenter.js';
48
+ import { SpriteLayers } from './sprites.js';
47
49
 
48
50
  const ORIGIN = Object.freeze({ x: 0, y: 0 });
49
51
 
@@ -60,6 +62,17 @@ const IDLE_GRACE_MS = 1000;
60
62
  // the repaint they save (the layer presenter's rule, per raster).
61
63
  const MAX_DIRTY_RECTS = 16;
62
64
 
65
+ /** Paint order, for entries carrying a `_paintKey`: a key the walk would
66
+ * not reach sorts last. */
67
+ function byPaintKey(a, b) {
68
+ if (!a.key || !b.key) return (a.key ? 1 : 0) - (b.key ? 1 : 0);
69
+ const n = Math.min(a.key.length, b.key.length);
70
+ for (let i = 0; i < n; i++) {
71
+ if (a.key[i] !== b.key[i]) return a.key[i] - b.key[i];
72
+ }
73
+ return a.key.length - b.key.length;
74
+ }
75
+
63
76
  const rectsOverlap = (a, b) =>
64
77
  a.x < b.x + b.width &&
65
78
  b.x < a.x + a.width &&
@@ -157,12 +170,14 @@ function insideGlArea(node) {
157
170
  const faded = (style) => style?.opacity !== undefined && !(style.opacity >= 1);
158
171
 
159
172
  /**
160
- * A box whose `opacity` is below 1, now or on its way there, is a group the
161
- * bitmap composites (`NodePaint._paintGroup`) — the box and everything in it
162
- * drawn once and faded as one. On a layer of its own the box and its content
163
- * would be separate layers under the one opacity, fading one over the other,
164
- * and an opacity the frame clock is still animating would never reach the
165
- * layer at all.
173
+ * A box whose `opacity` is below 1, now or on its way there, is a group:
174
+ * the box and everything in it drawn once and faded as one. The bitmap
175
+ * composites one (`NodePaint._paintGroup`), and so does Core Animation — a
176
+ * layer under an opacity below 1 is drawn with its sublayers and faded as
177
+ * one (`allowsGroupOpacity`, YES by default on macOS), and a promoted box's
178
+ * children are a sublayer of its layer. So a faded box may be promoted;
179
+ * what may not is a box inside one, whose layer would be flat on the window
180
+ * root, outside the group and at full strength (`_clear`).
166
181
  */
167
182
  function fadesAsGroup(node) {
168
183
  return (
@@ -182,7 +197,6 @@ function fadesAsGroup(node) {
182
197
  function promotableNode(node) {
183
198
  if (node.destroyed || !plainBox(node)) return false;
184
199
  if (insideGlArea(node)) return false;
185
- if (fadesAsGroup(node)) return false;
186
200
  if (!stylePaintsPlain(node, node._targetStyle ?? node.style)) return false;
187
201
  if (node.isScroller?.()) return false;
188
202
  return !paintsOutline(node);
@@ -241,6 +255,9 @@ export class CocoaPromotion {
241
255
  layerOf: (node) => this.promoted.get(node)?.visual.layer ?? null,
242
256
  onIdle: (node) => this._idle(node),
243
257
  });
258
+ // the parts of elements' drawing they offer (`Node.sprites()`), each on
259
+ // a layer of its own under the same rules (src/cocoa/sprites.js)
260
+ this.sprites = new SpriteLayers(this);
244
261
  this._claiming = false;
245
262
  }
246
263
 
@@ -388,8 +405,19 @@ export class CocoaPromotion {
388
405
  */
389
406
  frame(root, layoutRan) {
390
407
  if (layoutRan) this.layoutGen++;
391
- if (this.promoted.size === 0 && this.candidates.size === 0) return;
408
+ // what elements offer, asked before anything is decided: an answer is
409
+ // about the element's own drawing, not about the layers
410
+ const offers = this.sprites.ask(root);
411
+ if (
412
+ this.promoted.size === 0 &&
413
+ this.candidates.size === 0 &&
414
+ offers.size === 0 &&
415
+ this.sprites.hosts.size === 0
416
+ ) {
417
+ return;
418
+ }
392
419
  const native = this.native;
420
+ let lifted = null;
393
421
  native.txBegin({ disableActions: true });
394
422
  try {
395
423
  // Later-painted first: a node painted after this one that is coming
@@ -414,13 +442,26 @@ export class CocoaPromotion {
414
442
  }
415
443
  }
416
444
  }
445
+ // Then the parts, against the layers this frame keeps: a node
446
+ // promoted over a part is a layer above it, not ink on it.
447
+ lifted = this.sprites.decide(root, offers);
448
+ // One order for every layer above the bitmap, nodes and parts alike.
417
449
  const order = this._inPaintOrder([...this.promoted.keys()]);
450
+ for (const state of this.sprites.entries()) {
451
+ order.push({ state, key: state.order });
452
+ }
453
+ order.sort(byPaintKey);
418
454
  for (let i = 0; i < order.length; i++) {
419
- this._sync(order[i].node, i, root, layoutRan);
455
+ const { node, state } = order[i];
456
+ if (node) this._sync(node, i, root, layoutRan);
457
+ else this.sprites.sync(state, i);
420
458
  }
421
459
  } finally {
422
460
  native.txCommit();
423
461
  }
462
+ // An element told which of its parts are on layers now may claim,
463
+ // restyle or ask for frames; the frame has not taken its damage yet.
464
+ if (lifted?.size) this.sprites.notify(lifted);
424
465
  }
425
466
 
426
467
  _mayStay(node, key) {
@@ -438,7 +479,9 @@ export class CocoaPromotion {
438
479
  dirty: { all: true, rects: [] },
439
480
  });
440
481
  node._promoted = true;
441
- // the bitmap under it repaints without it, from this frame on
482
+ // the bitmap under it repaints without it, from this frame on — and so
483
+ // does the raster of a promoted node it is in, which the claim that
484
+ // handed us its animation reached (`noteInvalidate`)
442
485
  this._claim(root, node.paintBounds());
443
486
  }
444
487
 
@@ -457,7 +500,32 @@ export class CocoaPromotion {
457
500
  this.animations.drop(node, reclaim && !node.destroyed);
458
501
  this._dropContent(p);
459
502
  p.visual.destroy();
460
- if (!node.destroyed && node.abs) this._claim(root, node.paintBounds());
503
+ if (!node.destroyed && node.abs) {
504
+ this._repaintHolder(node);
505
+ this._claim(root, node.paintBounds());
506
+ }
507
+ }
508
+
509
+ /**
510
+ * A node coming off its layer inside a promoted node: the bitmap holds a
511
+ * hole there, and the node's pixels belong in that node's raster — so the
512
+ * raster repaints where the node reaches, taking it back, in this frame.
513
+ * Nothing else would ask: `_claim` keeps our claims off our own books, a
514
+ * raster's layout key is compared only in a frame that ran layout, and
515
+ * what gives a node back claims its own layer (the grace ran out) or
516
+ * nothing (an ancestor began to fade, a sibling came over it). Going onto
517
+ * a layer needs no such call — the claim that handed us the animation
518
+ * reached the raster. The nearest promoted node alone, since a raster
519
+ * leaves out the whole subtree of a promoted child.
520
+ */
521
+ _repaintHolder(node) {
522
+ for (let n = node.parent; n && !n.isWindow; n = n.parent) {
523
+ const holder = this.promoted.get(n);
524
+ if (holder) {
525
+ this._dirtyRect(holder, node.paintBounds());
526
+ return;
527
+ }
528
+ }
461
529
  }
462
530
 
463
531
  _dropContent(p) {
@@ -499,29 +567,36 @@ export class CocoaPromotion {
499
567
 
500
568
  _inPaintOrder(nodes) {
501
569
  const keyed = nodes.map((node) => ({ node, key: this._paintKey(node) }));
502
- keyed.sort((a, b) => {
503
- if (!a.key || !b.key) return (a.key ? 1 : 0) - (b.key ? 1 : 0);
504
- const n = Math.min(a.key.length, b.key.length);
505
- for (let i = 0; i < n; i++) {
506
- if (a.key[i] !== b.key[i]) return a.key[i] - b.key[i];
507
- }
508
- return a.key.length - b.key.length;
509
- });
570
+ keyed.sort(byPaintKey);
510
571
  return keyed;
511
572
  }
512
573
 
513
574
  /**
514
- * Is nothing painted over this node, and is all of it visible? Walked up
515
- * the chain: at every level, what the parent paints after the child on
516
- * the way to this node — later siblings, then the parent's own border and
517
- * ring, then its bars — must keep out of the node's reach, and the
518
- * parent's clip, if it has one, must hold the whole of it.
575
+ * Where an element offering parts stands in the paint order, or null when
576
+ * none of them may go on a layer: hidden, inside a `<glarea>`, or itself
577
+ * faded — its group on the bitmap would leave a part's layer out of it. A
578
+ * faded ancestor is `_clear`'s to find, as it is for a node.
579
+ */
580
+ _spritePlace(host) {
581
+ if (host.destroyed || host.hidden) return null;
582
+ if (insideGlArea(host) || fadesAsGroup(host)) return null;
583
+ return this._paintKey(host);
584
+ }
585
+
586
+ /**
587
+ * Is nothing painted over this node, is all of it visible, and is it in
588
+ * no faded group? Walked up the chain: at every level, what the parent
589
+ * paints after the child on the way to this node — later siblings, then
590
+ * the parent's own border and ring, then its bars — must keep out of the
591
+ * node's reach, the parent's clip, if it has one, must hold the whole of
592
+ * it, and the parent must not fade: its group is drawn on the bitmap or on
593
+ * its own layer, and either way a layer of the node's would be outside it.
519
594
  */
520
- _clear(node) {
595
+ _clear(node, bounds = node._subtreeBounds()) {
521
596
  // the exact reach, not `paintBounds()`: that one carries the damage
522
597
  // model's pixel of slop, and a section laid out flush under a card
523
- // would read as reaching into it
524
- const bounds = node._subtreeBounds();
598
+ // would read as reaching into it. An element's part is asked about at
599
+ // everywhere it can draw (src/cocoa/sprites.js).
525
600
  for (let n = node; !n.isWindow; n = n.parent) {
526
601
  const parent = n.parent;
527
602
  if (!parent) return false;
@@ -530,6 +605,7 @@ export class CocoaPromotion {
530
605
  if (this._reaches(order[j], bounds)) return false;
531
606
  }
532
607
  if (!parent.isWindow) {
608
+ if (fadesAsGroup(parent)) return false;
533
609
  const border = borderReach(parent);
534
610
  if (
535
611
  border > 0 &&
@@ -701,6 +777,7 @@ export class CocoaPromotion {
701
777
 
702
778
  /** Everything off the root layer and freed: the window is going. */
703
779
  destroy() {
780
+ this.sprites.destroy();
704
781
  for (const timer of this.idle.values()) clearTimeout(timer);
705
782
  this.idle.clear();
706
783
  this.releasing.clear();
@@ -0,0 +1,562 @@
1
+ // Sprites on the surface presenter (issue #819): the parts of an element's
2
+ // drawing it offers (`Node.sprites()`, src/nodes/sprites.js), each on a
3
+ // CALayer of its own above the window's bitmap, with the animations it
4
+ // carries run by the render server — a CSS animation inside `<Html>`, say,
5
+ // at no JavaScript frame at all.
6
+ //
7
+ // It is layer promotion with an element's part in place of a node, and it
8
+ // is promotion's to run (src/cocoa/promotion.js): the same z-order test,
9
+ // asked of the element at everywhere the part can draw (`_clear`), the same
10
+ // claim of the bitmap under it on the way onto a layer and off it, one order
11
+ // among every layer above the bitmap, and the same rule that declining is
12
+ // always safe — a part the frame refuses is one the element draws itself,
13
+ // as it would anywhere else. What is the element's and nobody else's is its
14
+ // inside: it offers only a part nothing it draws after the part overlaps,
15
+ // and it leaves a hole where a lifted one is (`spritesLifted`).
16
+ //
17
+ // A part's content is a raster the presenter paints by calling the part's
18
+ // own `paint`, at opacity 1 and untransformed, and paints again when its
19
+ // `version` changes; its opacity and its transform are the layer's, and so
20
+ // are its animations, keyframes the bridge hands to Core Animation. A
21
+ // transform is CSS's matrix, which the bridge takes from
22
+ // `@windowkit/appkit` 0.19 (`transformForms()`) along with the negative
23
+ // delay a part joined half way through needs, so that is the bridge sprites
24
+ // ask for: on an older one nothing is asked and every part is drawn by its
25
+ // element.
26
+
27
+ import { RASTER_PAD, RasterState } from './presenter.js';
28
+ import { DEV } from '../nodes/util.js';
29
+
30
+ // Past this many device pixels on a side a part is drawn by its element: a
31
+ // layer's contents are a bitmap, and a page-sized one is a page-sized
32
+ // upload for every new version.
33
+ const MAX_SIDE = 8192;
34
+
35
+ // Sorted after the element's children, which its own drawing comes after
36
+ // (`Node.paint` paints the children first), and before its next sibling.
37
+ const AFTER_CHILDREN = Number.MAX_SAFE_INTEGER;
38
+
39
+ const IDENTITY = Object.freeze([1, 0, 0, 1, 0, 0]);
40
+
41
+ // what a part's raster holds before its first paint
42
+ const UNPAINTED = Symbol('unpainted');
43
+
44
+ // the ids the bridge reports an animation's end under: unique per process,
45
+ // looked up per app (`_animationEnds`), like LayerAnimations'
46
+ let spriteSeq = 0;
47
+
48
+ const finite = (v) => typeof v === 'number' && Number.isFinite(v);
49
+
50
+ const isRect = (r) =>
51
+ r != null &&
52
+ finite(r.x) &&
53
+ finite(r.y) &&
54
+ finite(r.width) &&
55
+ finite(r.height) &&
56
+ r.width > 0 &&
57
+ r.height > 0;
58
+
59
+ const isMatrix = (m) => Array.isArray(m) && m.length === 6 && m.every(finite);
60
+
61
+ const isCurve = (t) =>
62
+ typeof t === 'string' ||
63
+ (Array.isArray(t) &&
64
+ t.length === 4 &&
65
+ t.every(finite) &&
66
+ t[0] >= 0 &&
67
+ t[0] <= 1 &&
68
+ t[2] >= 0 &&
69
+ t[2] <= 1);
70
+
71
+ /** Is `a` an animation the bridge will take as it is — so that a bad one
72
+ * is refused here rather than thrown from inside a frame? */
73
+ function isAnimation(a) {
74
+ if (a == null || typeof a.id !== 'string') return false;
75
+ if (a.property !== 'opacity' && a.property !== 'transform') return false;
76
+ if (!finite(a.duration) || !(a.duration > 0)) return false;
77
+ const values = a.values;
78
+ if (!Array.isArray(values) || values.length < 2) return false;
79
+ const value = a.property === 'opacity' ? finite : isMatrix;
80
+ if (!values.every(value)) return false;
81
+ if (a.keyTimes !== undefined) {
82
+ const t = a.keyTimes;
83
+ if (!Array.isArray(t) || t.length !== values.length || t[0] !== 0) {
84
+ return false;
85
+ }
86
+ for (let i = 0; i < t.length; i++) {
87
+ if (!finite(t[i]) || t[i] > 1 || (i > 0 && t[i] < t[i - 1])) {
88
+ return false;
89
+ }
90
+ }
91
+ }
92
+ if (a.timings !== undefined) {
93
+ const t = a.timings;
94
+ if (!Array.isArray(t) || t.length !== values.length - 1) return false;
95
+ if (!t.every(isCurve)) return false;
96
+ }
97
+ if (a.delay !== undefined && !finite(a.delay)) return false;
98
+ if (
99
+ a.repeat !== undefined &&
100
+ !(a.repeat === Infinity || (finite(a.repeat) && a.repeat > 0))
101
+ ) {
102
+ return false;
103
+ }
104
+ return true;
105
+ }
106
+
107
+ /** Is `sprite` a part as `Node.sprites()` documents one? */
108
+ function isSprite(sprite) {
109
+ if (sprite == null || typeof sprite.key !== 'string') return false;
110
+ if (!isRect(sprite.rect) || typeof sprite.paint !== 'function') return false;
111
+ if (sprite.reach !== undefined && !isRect(sprite.reach)) return false;
112
+ if (sprite.opacity !== undefined && !finite(sprite.opacity)) return false;
113
+ if (sprite.transform !== undefined && !isMatrix(sprite.transform)) {
114
+ return false;
115
+ }
116
+ if (
117
+ sprite.origin !== undefined &&
118
+ !(
119
+ sprite.origin != null &&
120
+ finite(sprite.origin.x) &&
121
+ finite(sprite.origin.y)
122
+ )
123
+ ) {
124
+ return false;
125
+ }
126
+ const animations = sprite.animations;
127
+ if (animations !== undefined) {
128
+ if (!Array.isArray(animations) || !animations.every(isAnimation)) {
129
+ return false;
130
+ }
131
+ }
132
+ return true;
133
+ }
134
+
135
+ const originOf = (sprite) =>
136
+ sprite.origin ?? {
137
+ x: sprite.rect.x + sprite.rect.width / 2,
138
+ y: sprite.rect.y + sprite.rect.height / 2,
139
+ };
140
+
141
+ /** `rect` through `m` about `origin`: the bounds of its four corners. */
142
+ function mapRect(rect, m, origin) {
143
+ let x0 = Infinity;
144
+ let y0 = Infinity;
145
+ let x1 = -Infinity;
146
+ let y1 = -Infinity;
147
+ for (const [px, py] of [
148
+ [rect.x, rect.y],
149
+ [rect.x + rect.width, rect.y],
150
+ [rect.x, rect.y + rect.height],
151
+ [rect.x + rect.width, rect.y + rect.height],
152
+ ]) {
153
+ const dx = px - origin.x;
154
+ const dy = py - origin.y;
155
+ const x = m[0] * dx + m[2] * dy + m[4] + origin.x;
156
+ const y = m[1] * dx + m[3] * dy + m[5] + origin.y;
157
+ if (x < x0) x0 = x;
158
+ if (y < y0) y0 = y;
159
+ if (x > x1) x1 = x;
160
+ if (y > y1) y1 = y;
161
+ }
162
+ return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
163
+ }
164
+
165
+ function unionRect(a, b) {
166
+ if (!a) return b;
167
+ const x = Math.min(a.x, b.x);
168
+ const y = Math.min(a.y, b.y);
169
+ return {
170
+ x,
171
+ y,
172
+ width: Math.max(a.x + a.width, b.x + b.width) - x,
173
+ height: Math.max(a.y + a.height, b.y + b.height) - y,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Everywhere `sprite` can draw while its animations run: its reach through
179
+ * its own transform and every transform it is animated through, out to
180
+ * whole pixels and the antialiasing a raster is padded for. Between two
181
+ * keyframes Core Animation interpolates the matrices by their parts, which
182
+ * stays within a pixel of the two for the densely sampled keyframes a CSS
183
+ * transform arrives as; an element whose keyframes are far apart, or whose
184
+ * curve overshoots, offers a `reach` that covers what is between them.
185
+ */
186
+ function extentOf(sprite) {
187
+ const reach = sprite.reach ?? sprite.rect;
188
+ const origin = originOf(sprite);
189
+ let box = mapRect(reach, sprite.transform ?? IDENTITY, origin);
190
+ for (const a of sprite.animations ?? []) {
191
+ if (a.property !== 'transform') continue;
192
+ for (const m of a.values) box = unionRect(box, mapRect(reach, m, origin));
193
+ }
194
+ const x = Math.floor(box.x) - RASTER_PAD;
195
+ const y = Math.floor(box.y) - RASTER_PAD;
196
+ return {
197
+ x,
198
+ y,
199
+ width: Math.ceil(box.x + box.width) + RASTER_PAD - x,
200
+ height: Math.ceil(box.y + box.height) + RASTER_PAD - y,
201
+ };
202
+ }
203
+
204
+ /** The raster a part's content is painted into: its reach, out to whole
205
+ * pixels and padded for antialiasing, untransformed. */
206
+ function rasterRectOf(sprite) {
207
+ const reach = sprite.reach ?? sprite.rect;
208
+ const x = Math.floor(reach.x) - RASTER_PAD;
209
+ const y = Math.floor(reach.y) - RASTER_PAD;
210
+ return {
211
+ x,
212
+ y,
213
+ width: Math.ceil(reach.x + reach.width) + RASTER_PAD - x,
214
+ height: Math.ceil(reach.y + reach.height) + RASTER_PAD - y,
215
+ };
216
+ }
217
+
218
+ const sameMatrix = (a, b) =>
219
+ a === b || (a != null && b != null && a.every((v, i) => v === b[i]));
220
+
221
+ /**
222
+ * The lifted parts of every element in one window: a layer each, its raster,
223
+ * and what the render server runs on it. `CocoaPromotion` asks, decides and
224
+ * syncs through it inside its own frame.
225
+ */
226
+ export class SpriteLayers {
227
+ constructor(promotion) {
228
+ this.promotion = promotion;
229
+ this.native = promotion.native;
230
+ this.scale = promotion.scale;
231
+ this.app = promotion.app;
232
+ // a matrix and a negative delay are what a CSS animation needs from the
233
+ // bridge, and @windowkit/appkit 0.19 is where both are
234
+ this.supported = Boolean(
235
+ this.native.transformForms?.()?.includes?.('matrix'),
236
+ );
237
+ this.hosts = new Map(); // element -> Map(key -> state)
238
+ this._warned = new WeakSet();
239
+ }
240
+
241
+ _ends() {
242
+ return (this.app._animationEnds ??= new Map());
243
+ }
244
+
245
+ _warn(host, message) {
246
+ if (!DEV || this._warned.has(host)) return;
247
+ this._warned.add(host);
248
+ console.warn(`react-x11: <${host.kind}> ${message}`);
249
+ }
250
+
251
+ /**
252
+ * Every element's answer this frame, before anything is decided: the
253
+ * elements that offer a part, and what they offer. An element that throws
254
+ * offers nothing and draws everything, which is what it would do anywhere
255
+ * else.
256
+ */
257
+ ask(root) {
258
+ const offers = new Map();
259
+ if (!this.supported) return offers;
260
+ for (const host of root._spriteNodes ?? []) {
261
+ if (host.destroyed) continue;
262
+ let list = null;
263
+ try {
264
+ list = host.sprites();
265
+ } catch (err) {
266
+ this._warn(
267
+ host,
268
+ `sprites() threw, and its parts are drawn by it: ${err?.message ?? err}`,
269
+ );
270
+ }
271
+ if (Array.isArray(list) && list.length !== 0) offers.set(host, list);
272
+ }
273
+ return offers;
274
+ }
275
+
276
+ /**
277
+ * Which offered parts go on layers this frame, against the scene as laid
278
+ * out and the layers promotion has decided on: a part is lifted, kept, or
279
+ * given back. Answers the elements whose set of lifted parts changed.
280
+ */
281
+ decide(root, offers) {
282
+ const changed = new Set();
283
+ for (const [host, lifted] of this.hosts) {
284
+ if (offers.has(host)) continue;
285
+ for (const state of lifted.values()) this._drop(state, root);
286
+ this.hosts.delete(host);
287
+ if (!host.destroyed) changed.add(host);
288
+ }
289
+ for (const [host, list] of offers) {
290
+ const was = this.hosts.get(host);
291
+ const now = new Map();
292
+ // where the element stands in the paint order, or null where none of
293
+ // it may go on a layer: hidden, faded, inside a <glarea>
294
+ const place = this.promotion._spritePlace(host);
295
+ for (let index = 0; place && index < list.length; index++) {
296
+ const sprite = list[index];
297
+ if (!isSprite(sprite)) {
298
+ this._warn(
299
+ host,
300
+ `offered a sprite that is not one (${JSON.stringify(sprite?.key)}); see Node.sprites()`,
301
+ );
302
+ continue;
303
+ }
304
+ if (now.has(sprite.key)) continue;
305
+ const raster = rasterRectOf(sprite);
306
+ if (raster.width > MAX_SIDE || raster.height > MAX_SIDE) continue;
307
+ const extent = extentOf(sprite);
308
+ if (!this.promotion._clear(host, extent)) continue;
309
+ let state = was?.get(sprite.key);
310
+ const lifting = !state;
311
+ if (lifting) state = this._lift(host, sprite.key);
312
+ state.sprite = sprite;
313
+ state.order = [...place, AFTER_CHILDREN, index];
314
+ // the bitmap under where it can be repaints without it, from this
315
+ // frame on; where it went since, it is the element's own claims
316
+ // that move the hole
317
+ if (lifting) this.promotion._claim(root, extent);
318
+ state.extent = extent;
319
+ now.set(sprite.key, state);
320
+ }
321
+ if (was) {
322
+ for (const [key, state] of was) {
323
+ if (!now.has(key)) this._drop(state, root);
324
+ }
325
+ }
326
+ if (now.size !== 0) this.hosts.set(host, now);
327
+ else this.hosts.delete(host);
328
+ const before = was?.size ?? 0;
329
+ if (before !== now.size || [...now.keys()].some((k) => !was.has(k))) {
330
+ changed.add(host);
331
+ }
332
+ }
333
+ return changed;
334
+ }
335
+
336
+ /** Every lifted part, with its place in the paint order. */
337
+ entries() {
338
+ const out = [];
339
+ for (const lifted of this.hosts.values()) {
340
+ for (const state of lifted.values()) out.push(state);
341
+ }
342
+ return out;
343
+ }
344
+
345
+ /** Tell each element that changed which of its parts are lifted now —
346
+ * after the decisions, before the frame paints. */
347
+ notify(changed) {
348
+ for (const host of changed) {
349
+ if (host.destroyed) continue;
350
+ const keys = new Set(this.hosts.get(host)?.keys() ?? []);
351
+ try {
352
+ host.spritesLifted(keys);
353
+ } catch (err) {
354
+ this._warn(host, `spritesLifted() threw: ${err?.message ?? err}`);
355
+ }
356
+ }
357
+ }
358
+
359
+ _lift(host, key) {
360
+ const layer = this.native.createLayer();
361
+ this.native.addSublayer(this.promotion.rootVisual.layer, layer);
362
+ return {
363
+ host,
364
+ key,
365
+ layer,
366
+ raster: new RasterState(),
367
+ painted: UNPAINTED,
368
+ rect: null, // the raster's rect, device pixels, window coordinates
369
+ phase: null, // where in a pixel its reach starts
370
+ props: {},
371
+ runs: new Map(), // element's animation id -> { caId, caKey, held }
372
+ // ids the render server is done with, while the element still lists
373
+ // them: an animation is attached once per id, and an end the element
374
+ // has not caught up with must not start it again
375
+ done: new Set(),
376
+ sprite: null,
377
+ extent: null,
378
+ order: null,
379
+ };
380
+ }
381
+
382
+ /** Off its layer: the layer and its raster go, its animations are
383
+ * forgotten, and the bitmap under where it could be repaints with the
384
+ * element drawing it again. */
385
+ _drop(state, root) {
386
+ this.native.removeFromSuperlayer(state.layer);
387
+ state.raster.release(this.native);
388
+ for (const run of state.runs.values()) this._ends().delete(run.caId);
389
+ state.runs.clear();
390
+ if (!state.host.destroyed && state.extent) {
391
+ this.promotion._claim(root, state.extent);
392
+ }
393
+ }
394
+
395
+ /** The layer as the part says, at `z` among the layers above the bitmap. */
396
+ sync(state, z) {
397
+ const sprite = state.sprite;
398
+ const s = this.scale;
399
+ const rect = rasterRectOf(sprite);
400
+ const reach = sprite.reach ?? sprite.rect;
401
+ const phase = `${reach.x - Math.floor(reach.x)},${reach.y - Math.floor(reach.y)}`;
402
+ // painted again for a new version, and for a raster of another size or
403
+ // with the part at another fraction of a pixel; a move by whole pixels
404
+ // is the layer's alone
405
+ if (
406
+ state.painted === UNPAINTED ||
407
+ sprite.version !== state.painted ||
408
+ state.rect?.width !== rect.width ||
409
+ state.rect?.height !== rect.height ||
410
+ state.phase !== phase
411
+ ) {
412
+ this._paint(state, sprite, rect);
413
+ state.phase = phase;
414
+ }
415
+ state.rect = rect;
416
+ const origin = originOf(sprite);
417
+ const m = sprite.transform ?? IDENTITY;
418
+ const next = {
419
+ bounds: [0, 0, rect.width / s, rect.height / s],
420
+ anchorPoint: [
421
+ (origin.x - rect.x) / rect.width,
422
+ (origin.y - rect.y) / rect.height,
423
+ ],
424
+ position: [origin.x / s, origin.y / s],
425
+ zPosition: z,
426
+ opacity: Math.min(1, Math.max(0, sprite.opacity ?? 1)),
427
+ matrix: m,
428
+ };
429
+ const prev = state.props;
430
+ const out = {};
431
+ let any = false;
432
+ for (const key of ['bounds', 'anchorPoint', 'position']) {
433
+ if (!sameMatrix(prev[key], next[key])) {
434
+ out[key] = next[key];
435
+ any = true;
436
+ }
437
+ }
438
+ for (const key of ['zPosition', 'opacity']) {
439
+ if (prev[key] !== next[key]) {
440
+ out[key] = next[key];
441
+ any = true;
442
+ }
443
+ }
444
+ if (!sameMatrix(prev.matrix, m)) {
445
+ out.transform = { matrix: [m[0], m[1], m[2], m[3], m[4] / s, m[5] / s] };
446
+ any = true;
447
+ }
448
+ if (any) this.native.setLayerProps(state.layer, out);
449
+ state.props = next;
450
+ // after the model went out, in the same transaction
451
+ this._syncAnimations(state, sprite);
452
+ }
453
+
454
+ _paint(state, sprite, rect) {
455
+ const ctx = state.raster.ensure(
456
+ this.promotion,
457
+ rect.width,
458
+ rect.height,
459
+ this.scale,
460
+ );
461
+ const root = state.host.root;
462
+ ctx.save();
463
+ try {
464
+ ctx.clearRect(0, 0, rect.width, rect.height);
465
+ ctx.translate(-rect.x, -rect.y);
466
+ // a part paints whole: there is no pass to cull it by
467
+ if (root) root._paintDamage = null;
468
+ sprite.paint(ctx);
469
+ } catch (err) {
470
+ this._warn(
471
+ state.host,
472
+ `a sprite's paint() threw: ${err?.message ?? err}`,
473
+ );
474
+ } finally {
475
+ ctx.restore();
476
+ }
477
+ this.native.surfaceToLayer(state.raster.surface, state.layer);
478
+ state.painted = sprite.version;
479
+ }
480
+
481
+ /** What runs on the layer, by the element's ids: one gone from the part
482
+ * comes off, one new to it goes on, and one already on is left alone —
483
+ * its `delay` is counted from the frame that asks, so it moves every
484
+ * frame and means nothing past the first. */
485
+ _syncAnimations(state, sprite) {
486
+ const wanted = sprite.animations ?? [];
487
+ const listed = (id) => wanted.some((a) => a.id === id);
488
+ for (const [id, run] of state.runs) {
489
+ if (listed(id)) continue;
490
+ this.native.removeAnimation(state.layer, run.caKey);
491
+ this._ends().delete(run.caId);
492
+ state.runs.delete(id);
493
+ }
494
+ for (const id of state.done) if (!listed(id)) state.done.delete(id);
495
+ const s = this.scale;
496
+ for (const a of wanted) {
497
+ if (state.runs.has(a.id) || state.done.has(a.id)) continue;
498
+ const caId = `rxs${++spriteSeq}`;
499
+ const caKey = `sprite:${caId}`;
500
+ const opts = {
501
+ values:
502
+ a.property === 'transform'
503
+ ? a.values.map((m) => ({
504
+ matrix: [m[0], m[1], m[2], m[3], m[4] / s, m[5] / s],
505
+ }))
506
+ : a.values,
507
+ duration: a.duration / 1000,
508
+ id: caId,
509
+ };
510
+ if (a.keyTimes) opts.keyTimes = a.keyTimes;
511
+ if (a.timings) opts.timings = a.timings;
512
+ if (a.repeat !== undefined) opts.repeat = a.repeat;
513
+ if (a.autoreverse) opts.autoreverse = true;
514
+ if (a.hold) opts.hold = true;
515
+ // from the frame that asked: ahead, or already that far in
516
+ if (a.delay) opts.delay = a.delay / 1000;
517
+ try {
518
+ this.native.addAnimation(state.layer, a.property, opts, caKey);
519
+ } catch (err) {
520
+ this._warn(
521
+ state.host,
522
+ `the bridge refused sprite animation ${JSON.stringify(a.id)}: ${err?.message ?? err}`,
523
+ );
524
+ continue;
525
+ }
526
+ state.runs.set(a.id, { caId, caKey, held: Boolean(a.hold) });
527
+ this._ends().set(caId, (ev) => this._ended(state, a.id, caId, ev));
528
+ }
529
+ }
530
+
531
+ /** The bridge's `animation-end` for one of ours. A held one stays on the
532
+ * layer, showing its last value, until the element stops listing it. */
533
+ _ended(state, id, caId, ev) {
534
+ this._ends().delete(caId);
535
+ const run = state.runs.get(id);
536
+ if (run?.caId === caId) {
537
+ state.done.add(id);
538
+ if (!run.held) state.runs.delete(id);
539
+ }
540
+ if (state.host.destroyed) return;
541
+ try {
542
+ state.host.spriteAnimationEnded(state.key, id, ev?.finished !== false);
543
+ } catch (err) {
544
+ this._warn(
545
+ state.host,
546
+ `spriteAnimationEnded() threw: ${err?.message ?? err}`,
547
+ );
548
+ }
549
+ }
550
+
551
+ /** Everything off the root and freed: the window is going. */
552
+ destroy() {
553
+ for (const lifted of this.hosts.values()) {
554
+ for (const state of lifted.values()) {
555
+ this.native.removeFromSuperlayer(state.layer);
556
+ state.raster.release(this.native);
557
+ for (const run of state.runs.values()) this._ends().delete(run.caId);
558
+ }
559
+ }
560
+ this.hosts.clear();
561
+ }
562
+ }
@@ -260,6 +260,10 @@ export class CocoaWindow {
260
260
  this._promotion.noteInvalidate(damage, layoutChanged);
261
261
  this.prepareFrame = (root, layoutRan) =>
262
262
  this._promotion.frame(root, layoutRan);
263
+ // An element's parts on layers of their own (src/cocoa/sprites.js)
264
+ // are asked for in that frame, so an element whose parts changed with
265
+ // nothing else to paint asks for one.
266
+ this.spritesChanged = () => this._reactX11Node?._scheduleFrame();
263
267
  }
264
268
  app._registerWindow(this);
265
269
  }
package/src/node.d.ts CHANGED
@@ -230,6 +230,75 @@ export interface PaintCachePlan {
230
230
  tint?: string;
231
231
  }
232
232
 
233
+ /**
234
+ * CSS's `matrix(a, b, c, d, e, f)`: a point (x, y) goes to
235
+ * (a·x + c·y + e, b·x + d·y + f), with `e` and `f` in device pixels.
236
+ */
237
+ export type SpriteMatrix = [number, number, number, number, number, number];
238
+
239
+ /** A cubic-bezier's control points `[x1, y1, x2, y2]`, `x1` and `x2` in
240
+ * 0..1, or one of Core Animation's curve names. */
241
+ export type SpriteTiming = [number, number, number, number] | string;
242
+
243
+ /**
244
+ * An animation the render server runs on a sprite: keyframes for one
245
+ * property, played as CSS plays an animation's iterations.
246
+ */
247
+ export interface SpriteAnimation {
248
+ /** The element's own name for this run, which `spriteAnimationEnded`
249
+ * reports. An animation is attached once per id: to change one, give it
250
+ * a new id. */
251
+ id: string;
252
+ property: 'opacity' | 'transform';
253
+ /** At least two: opacities, or matrices for a transform — sampled densely
254
+ * where the element interpolates in a way the render server would not. */
255
+ values: number[] | SpriteMatrix[];
256
+ /** One per value, 0 to 1, never decreasing; evenly spaced when left out. */
257
+ keyTimes?: number[];
258
+ /** One curve per segment, so one fewer than `values`; linear when left
259
+ * out. */
260
+ timings?: SpriteTiming[];
261
+ /** Milliseconds, one iteration. */
262
+ duration: number;
263
+ /** Milliseconds from the frame that asks: ahead, or — negative — already
264
+ * that far in. Read when the animation is attached and not after. */
265
+ delay?: number;
266
+ /** Iterations, `Infinity` for a loop; one when left out. */
267
+ repeat?: number;
268
+ /** Each iteration turns round at its end, as `alternate` does. */
269
+ autoreverse?: boolean;
270
+ /** The last value stays on screen after the end. */
271
+ hold?: boolean;
272
+ }
273
+
274
+ /**
275
+ * A part of an element's drawing a presenter may put on a layer of its own
276
+ * — what `sprites()` answers with (docs/extending.md "Parts of your drawing
277
+ * on layers of their own").
278
+ */
279
+ export interface Sprite {
280
+ /** The same across frames for the same part. */
281
+ key: string;
282
+ /** Its box, untransformed: device pixels, window coordinates, like
283
+ * `abs`. */
284
+ rect: Rect;
285
+ /** What it draws, untransformed, when that is more than `rect`. */
286
+ reach?: Rect;
287
+ /** Draws the part where it is in the window, as `paint` would, but at
288
+ * opacity 1 and untransformed. */
289
+ paint(ctx: Context2D): void;
290
+ /** Compared with `===` from frame to frame; a change paints the part
291
+ * again. Left out, it is painted once. */
292
+ version?: unknown;
293
+ /** What the layer shows when no animation runs on it; 1 when left out. */
294
+ opacity?: number;
295
+ /** …and the transform, about `origin`; the identity when left out. */
296
+ transform?: SpriteMatrix;
297
+ /** Device pixels, window coordinates; `rect`'s centre when left out. */
298
+ origin?: { x: number; y: number };
299
+ animations?: SpriteAnimation[];
300
+ }
301
+
233
302
  /** How an axis is bounded when layout asks an element for its size.
234
303
  * `'exactly'` — the style decided this axis; `'at-most'` — that many pixels
235
304
  * are on offer; `'unconstrained'` — nothing bounds it. */
@@ -570,6 +639,28 @@ export declare class Node {
570
639
  * read-only.
571
640
  */
572
641
  paintDamage(): Rect | null;
642
+ /**
643
+ * The parts of this element's drawing a presenter may put on layers of
644
+ * their own, and animate there in the render server — or null, the
645
+ * default. Asked every frame, after layout and before the damage is
646
+ * taken, by the macOS surface presenter's layer promotion and by nothing
647
+ * else: on X11, on Wayland and wherever a frame's scene refuses a part,
648
+ * the element draws everything, as it would without this.
649
+ */
650
+ sprites(): Sprite[] | null;
651
+ /**
652
+ * The keys of this element's sprites that are on layers of their own now.
653
+ * Called in the frame that changes the set, before it paints; `paint`
654
+ * leaves a lifted part out from then on, and draws again one the set no
655
+ * longer holds.
656
+ */
657
+ spritesLifted(keys: ReadonlySet<string>): void;
658
+ /** The render server is done with animation `id` on sprite `key`: it ran
659
+ * out (`finished`), or it was taken off with its layer. */
660
+ spriteAnimationEnded(key: string, id: string, finished: boolean): void;
661
+ /** Ask for the frame in which `sprites()` is asked again, when the parts
662
+ * changed and nothing the element draws did. A no-op where nothing asks. */
663
+ spritesChanged(): void;
573
664
  /**
574
665
  * "The pixels in `rect` moved by (dx, dy); the rest of it is new" — for an
575
666
  * element with a viewport of its own, whose pan is a scroll in every way
package/src/nodes/node.js CHANGED
@@ -45,6 +45,7 @@ import { NodePosition } from './position.js';
45
45
  import { NodeQueries } from './queries.js';
46
46
  import { NodeScrollBlit } from './scrollblit.js';
47
47
  import { NodeSelectable } from './selectable.js';
48
+ import { NodeSprites } from './sprites.js';
48
49
  import { NodeStyling } from './styling.js';
49
50
  import { DEV } from './util.js';
50
51
 
@@ -608,6 +609,7 @@ export class Node {
608
609
  this.root?._animating.delete(this);
609
610
  this.root?._opaqueNodes?.delete(this);
610
611
  this.root?._viewportFixedNodes?.delete(this);
612
+ this.root?._spriteNodes?.delete(this);
611
613
  // a surface that goes away takes its selection with it, and the app-wide
612
614
  // claim on being the one showing one goes with it too
613
615
  this._textSelection?.destroy();
@@ -627,14 +629,18 @@ export class Node {
627
629
  root?._layoutHosts?.add(this);
628
630
  }
629
631
  // an element answering `opaqueRect()` is one the window asks per pass,
630
- // and one answering `viewportFixedRects()` one it asks per scroll blit
632
+ // one answering `viewportFixedRects()` one it asks per scroll blit, and
633
+ // one answering `sprites()` one its presenter asks per frame
631
634
  const opaque = this.opaqueRect !== Node.prototype.opaqueRect;
632
635
  const fixed = this.viewportFixedRects !== Node.prototype.viewportFixedRects;
636
+ const sprites = this.sprites !== Node.prototype.sprites;
633
637
  if (opaque) this.root?._opaqueNodes?.delete(this);
634
638
  if (fixed) this.root?._viewportFixedNodes?.delete(this);
639
+ if (sprites) this.root?._spriteNodes?.delete(this);
635
640
  this.root = root;
636
641
  if (opaque) root?._opaqueNodes?.add(this);
637
642
  if (fixed) root?._viewportFixedNodes?.add(this);
643
+ if (sprites) root?._spriteNodes?.add(this);
638
644
  // styled before it had a window, so this is where a placed node is
639
645
  // first registered (WindowNode._placeNodes)
640
646
  if (this.style && isPlaced(this.style)) root?._placedNodes?.add(this);
@@ -1034,4 +1040,5 @@ installMethods(
1034
1040
  NodePaint,
1035
1041
  NodeBoxPaint,
1036
1042
  NodeSelectable,
1043
+ NodeSprites,
1037
1044
  );
@@ -16,7 +16,7 @@ import {
16
16
  XK_END,
17
17
  XK_SPACE,
18
18
  } from '../keysyms.js';
19
- import { DAMAGE_SLOP, layoutDiff } from './damage.js';
19
+ import { DAMAGE_SLOP, NO_DAMAGE, layoutDiff } from './damage.js';
20
20
  import { describeSize, laidBox, offsetInParent } from './layout.js';
21
21
  import { insetRect, intersectRects, rectsOverlap } from './rects.js';
22
22
  import {
@@ -340,11 +340,23 @@ export const Scrollable = (Base) =>
340
340
  const askedY = this.scrollY;
341
341
  this.scrollY = clampScroll(this.scrollY, this._maxScroll('y'));
342
342
  this.scrollX = clampScroll(this.scrollX, this._maxScroll('x'));
343
- // Of those three moves the clamp's is the one no call claimed when it
344
- // was asked for — the diff below claims it
343
+ // Of those three moves the clamp's is the one nobody asked for, and
344
+ // where no blit is armed the diff below claims it
345
345
  const clamped = this.scrollX !== askedX || this.scrollY !== askedY;
346
346
  this._reportViewport();
347
347
  this._reportScrollTo(from);
348
+ // …and whichever of them moved the offsets claims the viewport the
349
+ // content moved in, once the pass is over. A `scrollIntoView` asks for
350
+ // its pass claiming nothing (issue #813), so this is the only claim
351
+ // its move gets, and the blit it armed reads it as the scroll's own,
352
+ // as it reads the one `scrollTo` makes. Not through the diff, where a
353
+ // claim reads as content laid out anew, which is not a pure scroll
354
+ // and declines that blit. A clamp under an armed blit owes it too:
355
+ // the walk below gives that frame's moves to the blit's ledger, and
356
+ // without a scrollTo nothing else has claimed the viewport.
357
+ if (this.scrollX !== from.x || this.scrollY !== from.y) {
358
+ this.root?._reflowed.add(this);
359
+ }
348
360
  // `scrollX` is how far the content has moved **from its start**, which
349
361
  // is the right-hand edge in RTL — so scrolling shifts the children the
350
362
  // other way. Keeping it a distance rather than a coordinate is what
@@ -588,43 +600,7 @@ export const Scrollable = (Base) =>
588
600
  // A pane no pass has placed arms nothing: its first pass is a layout
589
601
  // change rather than a pure scroll.
590
602
  if (root && this._childOrigin != null) {
591
- // Arming is the one moment the evidence still exists: the viewport
592
- // claim recorded below coalesces earlier claims into itself
593
- // (addDamageRect keeps the list disjoint), after which a change
594
- // inside the viewport is indistinguishable from the scroll's own
595
- // claim — the blind spot the claim-time cancel in
596
- // WindowNode.invalidate cannot cover (react-x11#295). The scroll
597
- // has not claimed yet, so damage already overlapping this viewport
598
- // is foreign by construction: poison the frame instead of arming,
599
- // and the full-viewport repaint below stays in force.
600
- const arming = this._pendingBlitFrom == null;
601
- // The ledger this frame's changes inside the viewport are written
602
- // to (issue #398). Opened with the blit and read by
603
- // _applyScrollBlits, which clears it beside the origin.
604
- if (arming) this._blitLedger = [];
605
- if (arming && Array.isArray(root._damage)) {
606
- const zone = insetRect(this.abs, -(DAMAGE_SLOP * 2 + 1));
607
- for (const rect of root._damage) {
608
- // Already coalesced, so these rects are as coarse as the frame
609
- // has made them — which the ledger reads conservatively: a blob
610
- // that swallowed the viewport says so and poisons, exactly as
611
- // this gate used to for every claim it saw.
612
- if (rectsOverlap(rect, zone) && !this._recordBlitClaim(rect)) {
613
- this._pendingBlitFrom = BLIT_POISONED;
614
- break;
615
- }
616
- }
617
- }
618
- // An element that also shifted its own drawing this frame
619
- // (`scrollContents`, issue #303) is two shifts of the same pixels,
620
- // and a frame can only have one.
621
- if (this._pendingBlitContents) this._pendingBlitFrom = BLIT_POISONED;
622
- // The offsets whose pixels are on screen, captured before the first
623
- // change of the frame: the frame's blit fast path (issue #138) shifts
624
- // from *these* to wherever layout settles, however many scrollTo
625
- // calls land in between.
626
- this._pendingBlitFrom ??= { x: this.scrollX, y: this.scrollY };
627
- (root._pendingScrolls ??= new Set()).add(this);
603
+ this._armScrollBlit(root);
628
604
  // ... and the claim about to be recorded is the scroll itself, not a
629
605
  // reason to un-blit it
630
606
  root._scrollClaim = this;
@@ -644,6 +620,52 @@ export const Scrollable = (Base) =>
644
620
  if (root) root._scrollClaim = null;
645
621
  }
646
622
 
623
+ /**
624
+ * Arm the frame's scroll blit (issue #138) for a move of this pane's
625
+ * offsets: a `scrollTo` about to make one, or a `scrollIntoView` whose
626
+ * pass may (`scrollIntoView`). Before the move, and before the claim
627
+ * that comes with it.
628
+ *
629
+ * Arming is the one moment the evidence still exists: the viewport
630
+ * claim coalesces earlier claims into itself (addDamageRect keeps the
631
+ * list disjoint), after which a change inside the viewport is
632
+ * indistinguishable from the scroll's own claim — the blind spot the
633
+ * claim-time cancel in WindowNode.invalidate cannot cover
634
+ * (react-x11#295). The scroll has not claimed yet, so damage already
635
+ * overlapping this viewport is foreign by construction: poison the
636
+ * frame instead of arming, and the full-viewport repaint stays in force.
637
+ */
638
+ _armScrollBlit(root) {
639
+ const arming = this._pendingBlitFrom == null;
640
+ // The ledger this frame's changes inside the viewport are written
641
+ // to (issue #398). Opened with the blit and read by
642
+ // _applyScrollBlits, which clears it beside the origin.
643
+ if (arming) this._blitLedger = [];
644
+ if (arming && Array.isArray(root._damage)) {
645
+ const zone = insetRect(this.abs, -(DAMAGE_SLOP * 2 + 1));
646
+ for (const rect of root._damage) {
647
+ // Already coalesced, so these rects are as coarse as the frame
648
+ // has made them — which the ledger reads conservatively: a blob
649
+ // that swallowed the viewport says so and poisons, exactly as
650
+ // this gate used to for every claim it saw.
651
+ if (rectsOverlap(rect, zone) && !this._recordBlitClaim(rect)) {
652
+ this._pendingBlitFrom = BLIT_POISONED;
653
+ break;
654
+ }
655
+ }
656
+ }
657
+ // An element that also shifted its own drawing this frame
658
+ // (`scrollContents`, issue #303) is two shifts of the same pixels,
659
+ // and a frame can only have one.
660
+ if (this._pendingBlitContents) this._pendingBlitFrom = BLIT_POISONED;
661
+ // The offsets whose pixels are on screen, captured before the first
662
+ // change of the frame: the frame's blit fast path (issue #138) shifts
663
+ // from *these* to wherever layout settles, however many scrollTo
664
+ // calls land in between.
665
+ this._pendingBlitFrom ??= { x: this.scrollX, y: this.scrollY };
666
+ (root._pendingScrolls ??= new Set()).add(this);
667
+ }
668
+
647
669
  /**
648
670
  * Keep the part of a request the extent in hand cannot answer, for the
649
671
  * pass that will measure one that can. `_scrollToDevice` clamps against
@@ -864,13 +886,29 @@ export const Scrollable = (Base) =>
864
886
  * that has no geometry yet. `absolutize` resolves it against freshly
865
887
  * computed yoga positions, and `onScroll` reports the move once that
866
888
  * pass is over (`_reportScrollTo`).
889
+ *
890
+ * Asking claims nothing (issue #813). Most of the time the node is in
891
+ * view already — every Tab between the fields of a form in a pane — and
892
+ * a claim made now, before anyone knows, repainted the pane's whole
893
+ * viewport for a frame that moved nothing. So the pass is asked for
894
+ * with no damage, and a request that finds its node in view costs what
895
+ * the focus change costs outside a pane. One that does move the pane
896
+ * moves it the way a scroll does: the blit is armed here, as `scrollTo`
897
+ * arms it, and the pass claims the viewport once the offsets have
898
+ * changed (`_absolutizeChildren`), which `_applyScrollBlits` narrows to
899
+ * the strip the move uncovered.
867
900
  */
868
901
  scrollIntoView(node) {
869
902
  if (!node || !this.isScroller()) return;
870
903
  this._scrollIntoViewTarget = node;
871
- // whatever the resolved scroll moves is inside this clipped viewport,
872
- // so the viewport's own before/after rects bound the frame
873
- this._invalidateLayout('scroll');
904
+ const root = this.root;
905
+ if (!root) return;
906
+ // a pane no pass has placed arms nothing, as for `scrollTo`
907
+ if (this._childOrigin != null) this._armScrollBlit(root);
908
+ // `'scroll'`: the walk goes everywhere, which is what reaches this
909
+ // pane however clean the boxes above it are, and the content floors
910
+ // owe nothing
911
+ root.invalidate(true, NO_DAMAGE, 'scroll', this);
874
912
  }
875
913
 
876
914
  _resolveScrollIntoView() {
@@ -0,0 +1,73 @@
1
+ // Sprites (issue #819): the parts of what an element draws that a retained
2
+ // presenter may lift onto layers of their own, and animate there in the
3
+ // render server. The element's half of the seam is here; the presenter's is
4
+ // src/cocoa/sprites.js, under layer promotion.
5
+ //
6
+ // It is the shape of `opaqueRect()`: an accessor core asks every frame, a
7
+ // registry of the nodes that override it (`WindowNode._spriteNodes`, kept by
8
+ // `_setRoot`), and a default that answers nothing, so an element that never
9
+ // heard of sprites pays nothing and one that answers them draws exactly what
10
+ // it draws today wherever nothing asks — on X11, on Wayland, on the layer
11
+ // presenter, and in any frame whose scene refuses a lift.
12
+
13
+ /** Node's half of sprites, installed onto `Node.prototype` by node.js. */
14
+ export class NodeSprites {
15
+ /**
16
+ * The parts of this element's drawing a presenter may put on layers of
17
+ * their own, or null, the default. Each is a plain object:
18
+ *
19
+ * - `key`: a string, the same across frames for the same part.
20
+ * - `rect`: its box, untransformed — device pixels, window coordinates,
21
+ * like `abs`.
22
+ * - `reach`: what it draws, untransformed, when that is more than `rect`.
23
+ * - `paint(ctx)`: draws it where it is in the window, as `paint` would,
24
+ * but at opacity 1 and untransformed: the layer carries both.
25
+ * - `version`: compared with `===` from frame to frame; a change paints
26
+ * the part again. Left out, it is painted once.
27
+ * - `opacity`, and `transform` — CSS's `matrix(a, b, c, d, e, f)` as an
28
+ * array, `e` and `f` in device pixels — about `origin`, a point in
29
+ * device pixels that defaults to `rect`'s centre: the values the layer
30
+ * shows when no animation is running on it.
31
+ * - `animations`: what the render server runs on it, each `{ id,
32
+ * property: 'opacity' | 'transform', values, keyTimes, timings,
33
+ * duration, delay, repeat, autoreverse, hold }`, with times in
34
+ * milliseconds and `delay` counted from the frame that asks — negative
35
+ * for one that started before it. An animation is attached once per
36
+ * `id`: to change one, give it a new id.
37
+ *
38
+ * Asked every frame, after layout and before the damage is taken, on a
39
+ * window whose presenter lifts sprites; nowhere else. An element whose
40
+ * parts changed while nothing was going to paint asks for that frame with
41
+ * `spritesChanged()`.
42
+ */
43
+ sprites() {
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * The keys of this element's sprites that are on layers of their own now,
49
+ * a set the element may keep. Called in the frame that changes it, before
50
+ * that frame paints. From then on `paint` must not draw a lifted part —
51
+ * where it is, the window's bitmap holds a hole the layer shows through —
52
+ * and must draw again one the set no longer holds: a presenter gives a
53
+ * part back whenever the scene stops allowing it, and the bitmap under it
54
+ * is repainted in that same frame.
55
+ */
56
+ spritesLifted(keys) {}
57
+
58
+ /**
59
+ * The render server is done with animation `id` on sprite `key`: it ran
60
+ * out (`finished`), or it was taken off with its layer. Called from the
61
+ * backend's event, between frames.
62
+ */
63
+ spriteAnimationEnded(key, id, finished) {}
64
+
65
+ /**
66
+ * Ask for the frame in which this element's sprites are asked for again —
67
+ * one whose parts changed while nothing it drew had. Does nothing on a
68
+ * window whose presenter lifts none.
69
+ */
70
+ spritesChanged() {
71
+ this.root?.window?.spritesChanged?.(this);
72
+ }
73
+ }
@@ -196,6 +196,9 @@ export class WindowNode extends Scrollable(Node) {
196
196
  // the nodes answering `viewportFixedRects()`, whose rects a scroll pane
197
197
  // they are in repairs when it blits (`_viewportFixedPins`)
198
198
  this._viewportFixedNodes = new Set();
199
+ // the nodes answering `sprites()`, which a presenter that lifts parts of
200
+ // an element onto layers asks every frame (src/cocoa/sprites.js)
201
+ this._spriteNodes = new Set();
199
202
  // Nodes that want the `attention` event (ntk#37) — an
200
203
  // `unstable_onAttention` prop,
201
204
  // an `:attention` block, or both. Built before the EventManager so the