react-x11 2.28.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.28.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",
@@ -45,6 +45,7 @@ import {
45
45
  propBoxProps,
46
46
  stylePaintsPlain,
47
47
  } from './presenter.js';
48
+ import { SpriteLayers } from './sprites.js';
48
49
 
49
50
  const ORIGIN = Object.freeze({ x: 0, y: 0 });
50
51
 
@@ -61,6 +62,17 @@ const IDLE_GRACE_MS = 1000;
61
62
  // the repaint they save (the layer presenter's rule, per raster).
62
63
  const MAX_DIRTY_RECTS = 16;
63
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
+
64
76
  const rectsOverlap = (a, b) =>
65
77
  a.x < b.x + b.width &&
66
78
  b.x < a.x + a.width &&
@@ -243,6 +255,9 @@ export class CocoaPromotion {
243
255
  layerOf: (node) => this.promoted.get(node)?.visual.layer ?? null,
244
256
  onIdle: (node) => this._idle(node),
245
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);
246
261
  this._claiming = false;
247
262
  }
248
263
 
@@ -390,8 +405,19 @@ export class CocoaPromotion {
390
405
  */
391
406
  frame(root, layoutRan) {
392
407
  if (layoutRan) this.layoutGen++;
393
- 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
+ }
394
419
  const native = this.native;
420
+ let lifted = null;
395
421
  native.txBegin({ disableActions: true });
396
422
  try {
397
423
  // Later-painted first: a node painted after this one that is coming
@@ -416,13 +442,26 @@ export class CocoaPromotion {
416
442
  }
417
443
  }
418
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.
419
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);
420
454
  for (let i = 0; i < order.length; i++) {
421
- 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);
422
458
  }
423
459
  } finally {
424
460
  native.txCommit();
425
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);
426
465
  }
427
466
 
428
467
  _mayStay(node, key) {
@@ -528,17 +567,22 @@ export class CocoaPromotion {
528
567
 
529
568
  _inPaintOrder(nodes) {
530
569
  const keyed = nodes.map((node) => ({ node, key: this._paintKey(node) }));
531
- keyed.sort((a, b) => {
532
- if (!a.key || !b.key) return (a.key ? 1 : 0) - (b.key ? 1 : 0);
533
- const n = Math.min(a.key.length, b.key.length);
534
- for (let i = 0; i < n; i++) {
535
- if (a.key[i] !== b.key[i]) return a.key[i] - b.key[i];
536
- }
537
- return a.key.length - b.key.length;
538
- });
570
+ keyed.sort(byPaintKey);
539
571
  return keyed;
540
572
  }
541
573
 
574
+ /**
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
+
542
586
  /**
543
587
  * Is nothing painted over this node, is all of it visible, and is it in
544
588
  * no faded group? Walked up the chain: at every level, what the parent
@@ -548,11 +592,11 @@ export class CocoaPromotion {
548
592
  * it, and the parent must not fade: its group is drawn on the bitmap or on
549
593
  * its own layer, and either way a layer of the node's would be outside it.
550
594
  */
551
- _clear(node) {
595
+ _clear(node, bounds = node._subtreeBounds()) {
552
596
  // the exact reach, not `paintBounds()`: that one carries the damage
553
597
  // model's pixel of slop, and a section laid out flush under a card
554
- // would read as reaching into it
555
- 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).
556
600
  for (let n = node; !n.isWindow; n = n.parent) {
557
601
  const parent = n.parent;
558
602
  if (!parent) return false;
@@ -733,6 +777,7 @@ export class CocoaPromotion {
733
777
 
734
778
  /** Everything off the root layer and freed: the window is going. */
735
779
  destroy() {
780
+ this.sprites.destroy();
736
781
  for (const timer of this.idle.values()) clearTimeout(timer);
737
782
  this.idle.clear();
738
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
  );
@@ -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