react-x11 2.28.0 → 2.30.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.
@@ -0,0 +1,605 @@
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 { intersectRects } from '../nodes/rects.js';
29
+ import { DEV } from '../nodes/util.js';
30
+
31
+ // Past this many device pixels on a side a part is drawn by its element: a
32
+ // layer's contents are a bitmap, and a page-sized one is a page-sized
33
+ // upload for every new version.
34
+ const MAX_SIDE = 8192;
35
+
36
+ // Sorted after the element's children, which its own drawing comes after
37
+ // (`Node.paint` paints the children first), and before its next sibling.
38
+ const AFTER_CHILDREN = Number.MAX_SAFE_INTEGER;
39
+
40
+ const IDENTITY = Object.freeze([1, 0, 0, 1, 0, 0]);
41
+
42
+ // what a part's raster holds before its first paint
43
+ const UNPAINTED = Symbol('unpainted');
44
+
45
+ // the ids the bridge reports an animation's end under: unique per process,
46
+ // looked up per app (`_animationEnds`), like LayerAnimations'
47
+ let spriteSeq = 0;
48
+
49
+ const finite = (v) => typeof v === 'number' && Number.isFinite(v);
50
+
51
+ const isRect = (r) =>
52
+ r != null &&
53
+ finite(r.x) &&
54
+ finite(r.y) &&
55
+ finite(r.width) &&
56
+ finite(r.height) &&
57
+ r.width > 0 &&
58
+ r.height > 0;
59
+
60
+ const isMatrix = (m) => Array.isArray(m) && m.length === 6 && m.every(finite);
61
+
62
+ const isCurve = (t) =>
63
+ typeof t === 'string' ||
64
+ (Array.isArray(t) &&
65
+ t.length === 4 &&
66
+ t.every(finite) &&
67
+ t[0] >= 0 &&
68
+ t[0] <= 1 &&
69
+ t[2] >= 0 &&
70
+ t[2] <= 1);
71
+
72
+ /** Is `a` an animation the bridge will take as it is — so that a bad one
73
+ * is refused here rather than thrown from inside a frame? */
74
+ function isAnimation(a) {
75
+ if (a == null || typeof a.id !== 'string') return false;
76
+ if (a.property !== 'opacity' && a.property !== 'transform') return false;
77
+ if (!finite(a.duration) || !(a.duration > 0)) return false;
78
+ const values = a.values;
79
+ if (!Array.isArray(values) || values.length < 2) return false;
80
+ const value = a.property === 'opacity' ? finite : isMatrix;
81
+ if (!values.every(value)) return false;
82
+ if (a.keyTimes !== undefined) {
83
+ const t = a.keyTimes;
84
+ if (!Array.isArray(t) || t.length !== values.length || t[0] !== 0) {
85
+ return false;
86
+ }
87
+ for (let i = 0; i < t.length; i++) {
88
+ if (!finite(t[i]) || t[i] > 1 || (i > 0 && t[i] < t[i - 1])) {
89
+ return false;
90
+ }
91
+ }
92
+ }
93
+ if (a.timings !== undefined) {
94
+ const t = a.timings;
95
+ if (!Array.isArray(t) || t.length !== values.length - 1) return false;
96
+ if (!t.every(isCurve)) return false;
97
+ }
98
+ if (a.delay !== undefined && !finite(a.delay)) return false;
99
+ if (
100
+ a.repeat !== undefined &&
101
+ !(a.repeat === Infinity || (finite(a.repeat) && a.repeat > 0))
102
+ ) {
103
+ return false;
104
+ }
105
+ return true;
106
+ }
107
+
108
+ /** Is `sprite` a part as `Node.sprites()` documents one? */
109
+ function isSprite(sprite) {
110
+ if (sprite == null || typeof sprite.key !== 'string') return false;
111
+ if (!isRect(sprite.rect) || typeof sprite.paint !== 'function') return false;
112
+ if (sprite.reach !== undefined && !isRect(sprite.reach)) return false;
113
+ if (sprite.clip !== undefined && !isRect(sprite.clip)) return false;
114
+ if (sprite.opacity !== undefined && !finite(sprite.opacity)) return false;
115
+ if (sprite.transform !== undefined && !isMatrix(sprite.transform)) {
116
+ return false;
117
+ }
118
+ if (
119
+ sprite.origin !== undefined &&
120
+ !(
121
+ sprite.origin != null &&
122
+ finite(sprite.origin.x) &&
123
+ finite(sprite.origin.y)
124
+ )
125
+ ) {
126
+ return false;
127
+ }
128
+ const animations = sprite.animations;
129
+ if (animations !== undefined) {
130
+ if (!Array.isArray(animations) || !animations.every(isAnimation)) {
131
+ return false;
132
+ }
133
+ }
134
+ return true;
135
+ }
136
+
137
+ const originOf = (sprite) =>
138
+ sprite.origin ?? {
139
+ x: sprite.rect.x + sprite.rect.width / 2,
140
+ y: sprite.rect.y + sprite.rect.height / 2,
141
+ };
142
+
143
+ /** `rect` through `m` about `origin`: the bounds of its four corners. */
144
+ function mapRect(rect, m, origin) {
145
+ let x0 = Infinity;
146
+ let y0 = Infinity;
147
+ let x1 = -Infinity;
148
+ let y1 = -Infinity;
149
+ for (const [px, py] of [
150
+ [rect.x, rect.y],
151
+ [rect.x + rect.width, rect.y],
152
+ [rect.x, rect.y + rect.height],
153
+ [rect.x + rect.width, rect.y + rect.height],
154
+ ]) {
155
+ const dx = px - origin.x;
156
+ const dy = py - origin.y;
157
+ const x = m[0] * dx + m[2] * dy + m[4] + origin.x;
158
+ const y = m[1] * dx + m[3] * dy + m[5] + origin.y;
159
+ if (x < x0) x0 = x;
160
+ if (y < y0) y0 = y;
161
+ if (x > x1) x1 = x;
162
+ if (y > y1) y1 = y;
163
+ }
164
+ return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
165
+ }
166
+
167
+ function unionRect(a, b) {
168
+ if (!a) return b;
169
+ const x = Math.min(a.x, b.x);
170
+ const y = Math.min(a.y, b.y);
171
+ return {
172
+ x,
173
+ y,
174
+ width: Math.max(a.x + a.width, b.x + b.width) - x,
175
+ height: Math.max(a.y + a.height, b.y + b.height) - y,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Everywhere `sprite` can draw while its animations run: its reach through
181
+ * its own transform and every transform it is animated through, out to
182
+ * whole pixels and the antialiasing a raster is padded for. Between two
183
+ * keyframes Core Animation interpolates the matrices by their parts, which
184
+ * stays within a pixel of the two for the densely sampled keyframes a CSS
185
+ * transform arrives as; an element whose keyframes are far apart, or whose
186
+ * curve overshoots, offers a `reach` that covers what is between them.
187
+ */
188
+ function extentOf(sprite) {
189
+ const reach = sprite.reach ?? sprite.rect;
190
+ const origin = originOf(sprite);
191
+ let box = mapRect(reach, sprite.transform ?? IDENTITY, origin);
192
+ for (const a of sprite.animations ?? []) {
193
+ if (a.property !== 'transform') continue;
194
+ for (const m of a.values) box = unionRect(box, mapRect(reach, m, origin));
195
+ }
196
+ const x = Math.floor(box.x) - RASTER_PAD;
197
+ const y = Math.floor(box.y) - RASTER_PAD;
198
+ return {
199
+ x,
200
+ y,
201
+ width: Math.ceil(box.x + box.width) + RASTER_PAD - x,
202
+ height: Math.ceil(box.y + box.height) + RASTER_PAD - y,
203
+ };
204
+ }
205
+
206
+ /** The raster a part's content is painted into: its reach, out to whole
207
+ * pixels and padded for antialiasing, untransformed. */
208
+ function rasterRectOf(sprite) {
209
+ const reach = sprite.reach ?? sprite.rect;
210
+ const x = Math.floor(reach.x) - RASTER_PAD;
211
+ const y = Math.floor(reach.y) - RASTER_PAD;
212
+ return {
213
+ x,
214
+ y,
215
+ width: Math.ceil(reach.x + reach.width) + RASTER_PAD - x,
216
+ height: Math.ceil(reach.y + reach.height) + RASTER_PAD - y,
217
+ };
218
+ }
219
+
220
+ const sameMatrix = (a, b) =>
221
+ a === b || (a != null && b != null && a.every((v, i) => v === b[i]));
222
+
223
+ /**
224
+ * The lifted parts of every element in one window: a layer each, its raster,
225
+ * and what the render server runs on it. `CocoaPromotion` asks, decides and
226
+ * syncs through it inside its own frame.
227
+ */
228
+ export class SpriteLayers {
229
+ constructor(promotion) {
230
+ this.promotion = promotion;
231
+ this.native = promotion.native;
232
+ this.scale = promotion.scale;
233
+ this.app = promotion.app;
234
+ // a matrix and a negative delay are what a CSS animation needs from the
235
+ // bridge, and @windowkit/appkit 0.19 is where both are
236
+ this.supported = Boolean(
237
+ this.native.transformForms?.()?.includes?.('matrix'),
238
+ );
239
+ this.hosts = new Map(); // element -> Map(key -> state)
240
+ this._warned = new WeakSet();
241
+ }
242
+
243
+ _ends() {
244
+ return (this.app._animationEnds ??= new Map());
245
+ }
246
+
247
+ _warn(host, message) {
248
+ if (!DEV || this._warned.has(host)) return;
249
+ this._warned.add(host);
250
+ console.warn(`react-x11: <${host.kind}> ${message}`);
251
+ }
252
+
253
+ /**
254
+ * Every element's answer this frame, before anything is decided: the
255
+ * elements that offer a part, and what they offer. An element that throws
256
+ * offers nothing and draws everything, which is what it would do anywhere
257
+ * else.
258
+ */
259
+ ask(root) {
260
+ const offers = new Map();
261
+ if (!this.supported) return offers;
262
+ for (const host of root._spriteNodes ?? []) {
263
+ if (host.destroyed) continue;
264
+ let list = null;
265
+ try {
266
+ list = host.sprites();
267
+ } catch (err) {
268
+ this._warn(
269
+ host,
270
+ `sprites() threw, and its parts are drawn by it: ${err?.message ?? err}`,
271
+ );
272
+ }
273
+ if (Array.isArray(list) && list.length !== 0) offers.set(host, list);
274
+ }
275
+ return offers;
276
+ }
277
+
278
+ /**
279
+ * Which offered parts go on layers this frame, against the scene as laid
280
+ * out and the layers promotion has decided on: a part is lifted, kept, or
281
+ * given back. Answers the elements whose set of lifted parts changed.
282
+ */
283
+ decide(root, offers) {
284
+ const changed = new Set();
285
+ for (const [host, lifted] of this.hosts) {
286
+ if (offers.has(host)) continue;
287
+ for (const state of lifted.values()) this._drop(state, root);
288
+ this.hosts.delete(host);
289
+ if (!host.destroyed) changed.add(host);
290
+ }
291
+ for (const [host, list] of offers) {
292
+ const was = this.hosts.get(host);
293
+ const now = new Map();
294
+ // where the element stands in the paint order, or null where none of
295
+ // it may go on a layer: hidden, faded, inside a <glarea>
296
+ const place = this.promotion._spritePlace(host);
297
+ for (let index = 0; place && index < list.length; index++) {
298
+ const sprite = list[index];
299
+ if (!isSprite(sprite)) {
300
+ this._warn(
301
+ host,
302
+ `offered a sprite that is not one (${JSON.stringify(sprite?.key)}); see Node.sprites()`,
303
+ );
304
+ continue;
305
+ }
306
+ if (now.has(sprite.key)) continue;
307
+ const raster = rasterRectOf(sprite);
308
+ if (raster.width > MAX_SIDE || raster.height > MAX_SIDE) continue;
309
+ // what shows of everywhere it can be: inside its own clip, and the
310
+ // clips of its element's ancestors, which cut its layer (`_clear`)
311
+ const cut = { clip: sprite.clip ?? null, bounds: null };
312
+ const extent = extentOf(sprite);
313
+ const bounds = cut.clip ? intersectRects(extent, cut.clip) : extent;
314
+ if (!bounds || !this.promotion._clear(host, bounds, cut)) continue;
315
+ const shown = cut.bounds;
316
+ let state = was?.get(sprite.key);
317
+ // a clip that comes or goes is a layer in a box or out of one: lifted
318
+ // again, rather than a running layer moved between parents
319
+ if (state && Boolean(state.clip) !== Boolean(cut.clip)) {
320
+ this._drop(state, root);
321
+ state = null;
322
+ }
323
+ const lifting = !state;
324
+ if (lifting) state = this._lift(host, sprite.key, cut.clip !== null);
325
+ state.sprite = sprite;
326
+ state.clip = cut.clip;
327
+ state.order = [...place, AFTER_CHILDREN, index];
328
+ // the bitmap under where it shows repaints without it, from this
329
+ // frame on; where it went since, it is the element's own claims
330
+ // that move the hole
331
+ if (lifting) this.promotion._claim(root, shown);
332
+ state.extent = shown;
333
+ now.set(sprite.key, state);
334
+ }
335
+ if (was) {
336
+ for (const [key, state] of was) {
337
+ if (!now.has(key)) this._drop(state, root);
338
+ }
339
+ }
340
+ if (now.size !== 0) this.hosts.set(host, now);
341
+ else this.hosts.delete(host);
342
+ const before = was?.size ?? 0;
343
+ if (before !== now.size || [...now.keys()].some((k) => !was.has(k))) {
344
+ changed.add(host);
345
+ }
346
+ }
347
+ return changed;
348
+ }
349
+
350
+ /** Every lifted part, with its place in the paint order. */
351
+ entries() {
352
+ const out = [];
353
+ for (const lifted of this.hosts.values()) {
354
+ for (const state of lifted.values()) out.push(state);
355
+ }
356
+ return out;
357
+ }
358
+
359
+ /** Tell each element that changed which of its parts are lifted now —
360
+ * after the decisions, before the frame paints. */
361
+ notify(changed) {
362
+ for (const host of changed) {
363
+ if (host.destroyed) continue;
364
+ const keys = new Set(this.hosts.get(host)?.keys() ?? []);
365
+ try {
366
+ host.spritesLifted(keys);
367
+ } catch (err) {
368
+ this._warn(host, `spritesLifted() threw: ${err?.message ?? err}`);
369
+ }
370
+ }
371
+ }
372
+
373
+ _lift(host, key, clipped) {
374
+ const layer = this.native.createLayer();
375
+ // a part cut to a clip is in a box of the clip's size that masks to it,
376
+ // and the box is what stands among the layers above the bitmap
377
+ let box = null;
378
+ if (clipped) {
379
+ box = this.native.createLayer();
380
+ this.native.addSublayer(this.promotion.rootVisual.layer, box);
381
+ this.native.addSublayer(box, layer);
382
+ } else {
383
+ this.native.addSublayer(this.promotion.rootVisual.layer, layer);
384
+ }
385
+ return {
386
+ host,
387
+ key,
388
+ layer,
389
+ box,
390
+ boxProps: {},
391
+ clip: null, // device pixels, window coordinates
392
+ raster: new RasterState(),
393
+ painted: UNPAINTED,
394
+ rect: null, // the raster's rect, device pixels, window coordinates
395
+ phase: null, // where in a pixel its reach starts
396
+ props: {},
397
+ runs: new Map(), // element's animation id -> { caId, caKey, held }
398
+ // ids the render server is done with, while the element still lists
399
+ // them: an animation is attached once per id, and an end the element
400
+ // has not caught up with must not start it again
401
+ done: new Set(),
402
+ sprite: null,
403
+ extent: null,
404
+ order: null,
405
+ };
406
+ }
407
+
408
+ /** Off its layer: the layer and its raster go, its animations are
409
+ * forgotten, and the bitmap under where it could be repaints with the
410
+ * element drawing it again. */
411
+ _drop(state, root) {
412
+ this.native.removeFromSuperlayer(state.box ?? state.layer);
413
+ state.raster.release(this.native);
414
+ for (const run of state.runs.values()) this._ends().delete(run.caId);
415
+ state.runs.clear();
416
+ if (!state.host.destroyed && state.extent) {
417
+ this.promotion._claim(root, state.extent);
418
+ }
419
+ }
420
+
421
+ /** The layer as the part says, at `z` among the layers above the bitmap. */
422
+ sync(state, z) {
423
+ const sprite = state.sprite;
424
+ const s = this.scale;
425
+ const rect = rasterRectOf(sprite);
426
+ const reach = sprite.reach ?? sprite.rect;
427
+ const phase = `${reach.x - Math.floor(reach.x)},${reach.y - Math.floor(reach.y)}`;
428
+ // painted again for a new version, and for a raster of another size or
429
+ // with the part at another fraction of a pixel; a move by whole pixels
430
+ // is the layer's alone
431
+ if (
432
+ state.painted === UNPAINTED ||
433
+ sprite.version !== state.painted ||
434
+ state.rect?.width !== rect.width ||
435
+ state.rect?.height !== rect.height ||
436
+ state.phase !== phase
437
+ ) {
438
+ this._paint(state, sprite, rect);
439
+ state.phase = phase;
440
+ }
441
+ state.rect = rect;
442
+ const origin = originOf(sprite);
443
+ const m = sprite.transform ?? IDENTITY;
444
+ // in its box, where it is cut to a clip: placed from the clip's corner,
445
+ // and the box at the part's place among the layers
446
+ const clip = state.clip;
447
+ if (state.box) {
448
+ const frame = [clip.x / s, clip.y / s, clip.width / s, clip.height / s];
449
+ const was = state.boxProps;
450
+ if (!sameMatrix(was.frame, frame) || was.zPosition !== z) {
451
+ this.native.setLayerProps(state.box, {
452
+ frame,
453
+ masksToBounds: true,
454
+ zPosition: z,
455
+ });
456
+ state.boxProps = { frame, zPosition: z };
457
+ }
458
+ }
459
+ const ox = state.box ? clip.x : 0;
460
+ const oy = state.box ? clip.y : 0;
461
+ const next = {
462
+ bounds: [0, 0, rect.width / s, rect.height / s],
463
+ anchorPoint: [
464
+ (origin.x - rect.x) / rect.width,
465
+ (origin.y - rect.y) / rect.height,
466
+ ],
467
+ position: [(origin.x - ox) / s, (origin.y - oy) / s],
468
+ zPosition: state.box ? 0 : z,
469
+ opacity: Math.min(1, Math.max(0, sprite.opacity ?? 1)),
470
+ matrix: m,
471
+ };
472
+ const prev = state.props;
473
+ const out = {};
474
+ let any = false;
475
+ for (const key of ['bounds', 'anchorPoint', 'position']) {
476
+ if (!sameMatrix(prev[key], next[key])) {
477
+ out[key] = next[key];
478
+ any = true;
479
+ }
480
+ }
481
+ for (const key of ['zPosition', 'opacity']) {
482
+ if (prev[key] !== next[key]) {
483
+ out[key] = next[key];
484
+ any = true;
485
+ }
486
+ }
487
+ if (!sameMatrix(prev.matrix, m)) {
488
+ out.transform = { matrix: [m[0], m[1], m[2], m[3], m[4] / s, m[5] / s] };
489
+ any = true;
490
+ }
491
+ if (any) this.native.setLayerProps(state.layer, out);
492
+ state.props = next;
493
+ // after the model went out, in the same transaction
494
+ this._syncAnimations(state, sprite);
495
+ }
496
+
497
+ _paint(state, sprite, rect) {
498
+ const ctx = state.raster.ensure(
499
+ this.promotion,
500
+ rect.width,
501
+ rect.height,
502
+ this.scale,
503
+ );
504
+ const root = state.host.root;
505
+ ctx.save();
506
+ try {
507
+ ctx.clearRect(0, 0, rect.width, rect.height);
508
+ ctx.translate(-rect.x, -rect.y);
509
+ // a part paints whole: there is no pass to cull it by
510
+ if (root) root._paintDamage = null;
511
+ sprite.paint(ctx);
512
+ } catch (err) {
513
+ this._warn(
514
+ state.host,
515
+ `a sprite's paint() threw: ${err?.message ?? err}`,
516
+ );
517
+ } finally {
518
+ ctx.restore();
519
+ }
520
+ this.native.surfaceToLayer(state.raster.surface, state.layer);
521
+ state.painted = sprite.version;
522
+ }
523
+
524
+ /** What runs on the layer, by the element's ids: one gone from the part
525
+ * comes off, one new to it goes on, and one already on is left alone —
526
+ * its `delay` is counted from the frame that asks, so it moves every
527
+ * frame and means nothing past the first. */
528
+ _syncAnimations(state, sprite) {
529
+ const wanted = sprite.animations ?? [];
530
+ const listed = (id) => wanted.some((a) => a.id === id);
531
+ for (const [id, run] of state.runs) {
532
+ if (listed(id)) continue;
533
+ this.native.removeAnimation(state.layer, run.caKey);
534
+ this._ends().delete(run.caId);
535
+ state.runs.delete(id);
536
+ }
537
+ for (const id of state.done) if (!listed(id)) state.done.delete(id);
538
+ const s = this.scale;
539
+ for (const a of wanted) {
540
+ if (state.runs.has(a.id) || state.done.has(a.id)) continue;
541
+ const caId = `rxs${++spriteSeq}`;
542
+ const caKey = `sprite:${caId}`;
543
+ const opts = {
544
+ values:
545
+ a.property === 'transform'
546
+ ? a.values.map((m) => ({
547
+ matrix: [m[0], m[1], m[2], m[3], m[4] / s, m[5] / s],
548
+ }))
549
+ : a.values,
550
+ duration: a.duration / 1000,
551
+ id: caId,
552
+ };
553
+ if (a.keyTimes) opts.keyTimes = a.keyTimes;
554
+ if (a.timings) opts.timings = a.timings;
555
+ if (a.repeat !== undefined) opts.repeat = a.repeat;
556
+ if (a.autoreverse) opts.autoreverse = true;
557
+ if (a.hold) opts.hold = true;
558
+ // from the frame that asked: ahead, or already that far in
559
+ if (a.delay) opts.delay = a.delay / 1000;
560
+ try {
561
+ this.native.addAnimation(state.layer, a.property, opts, caKey);
562
+ } catch (err) {
563
+ this._warn(
564
+ state.host,
565
+ `the bridge refused sprite animation ${JSON.stringify(a.id)}: ${err?.message ?? err}`,
566
+ );
567
+ continue;
568
+ }
569
+ state.runs.set(a.id, { caId, caKey, held: Boolean(a.hold) });
570
+ this._ends().set(caId, (ev) => this._ended(state, a.id, caId, ev));
571
+ }
572
+ }
573
+
574
+ /** The bridge's `animation-end` for one of ours. A held one stays on the
575
+ * layer, showing its last value, until the element stops listing it. */
576
+ _ended(state, id, caId, ev) {
577
+ this._ends().delete(caId);
578
+ const run = state.runs.get(id);
579
+ if (run?.caId === caId) {
580
+ state.done.add(id);
581
+ if (!run.held) state.runs.delete(id);
582
+ }
583
+ if (state.host.destroyed) return;
584
+ try {
585
+ state.host.spriteAnimationEnded(state.key, id, ev?.finished !== false);
586
+ } catch (err) {
587
+ this._warn(
588
+ state.host,
589
+ `spriteAnimationEnded() threw: ${err?.message ?? err}`,
590
+ );
591
+ }
592
+ }
593
+
594
+ /** Everything off the root and freed: the window is going. */
595
+ destroy() {
596
+ for (const lifted of this.hosts.values()) {
597
+ for (const state of lifted.values()) {
598
+ this.native.removeFromSuperlayer(state.box ?? state.layer);
599
+ state.raster.release(this.native);
600
+ for (const run of state.runs.values()) this._ends().delete(run.caId);
601
+ }
602
+ }
603
+ this.hosts.clear();
604
+ }
605
+ }
@@ -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
  }