react-x11 2.7.0 → 2.8.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,708 @@
1
+ // Layer promotion — the surface presenter's answer to an animation it cannot
2
+ // otherwise take off the frame clock (issue #483): the few nodes that
3
+ // animate get a CALayer of their own above the window's bitmap, and the
4
+ // rest of the scene stays exactly the frame it was.
5
+ //
6
+ // The mechanism is the one `<glarea>` already uses. The surface presenter's
7
+ // pixels are the window root layer's `contents`, and a layer's contents
8
+ // draw below its sublayers — so a sublayer on the root composites over the
9
+ // bitmap for free, and the paint walk leaves a hole where the node was
10
+ // (`Node._promoted`, the way `GlAreaNode.paint` is empty). The node itself
11
+ // is a property box, its background, border and radius as properties of
12
+ // the layer — exactly the vocabulary the render server animates
13
+ // (`LayerAnimations`) — and its children, if it has any, are one raster
14
+ // sublayer painted by the node's own `_paintChildren` walk. Hit testing
15
+ // never moves: the node tree stays the source of truth for input on every
16
+ // presenter, so promotion changes pixels and nothing else.
17
+ //
18
+ // The policy is not inferred from the scene. A node is promoted because it
19
+ // has a transition or a loop on a property a layer can express, for as
20
+ // long as it has one and a moment after (`IDLE_GRACE_MS`), and returns to
21
+ // the bitmap then; nothing in a style asks for it, and nothing can promote
22
+ // two hundred nodes by mistake. What it is careful about is z-order, which is the hard
23
+ // part: a promoted layer sits above ALL the 2D content, so a node is
24
+ // promoted only when nothing painted after it in the walk reaches into its
25
+ // bounds — no later sibling at any level, no ancestor's border ring or
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
28
+ // always safe: the frame clock runs the animation exactly as it does
29
+ // 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
32
+ // clock. Overlays, toasts, drag ghosts, spinners and floating cards pass by
33
+ // construction; a hover fade on a row in the middle of a list does not,
34
+ // and stays on the clock. docs/macos.md §"Layer promotion" is the account.
35
+ import {
36
+ BoxNode,
37
+ addDamageRect,
38
+ damageToPaint,
39
+ intersectRects,
40
+ } from '../nodes.js';
41
+ import { resolveBorderWidths } from '../styles.js';
42
+ import {
43
+ LayerAnimations,
44
+ RASTER_PAD,
45
+ RasterState,
46
+ Visual,
47
+ propBoxProps,
48
+ stylePaintsPlain,
49
+ } from './presenter.js';
50
+
51
+ const ORIGIN = Object.freeze({ x: 0, y: 0 });
52
+
53
+ // How long a node that has stopped animating keeps its layer. A hover card
54
+ // fades in and, a moment later, out; a palette step ends one transition and
55
+ // starts the next; a toast pulses again. Demoting on the last frame of each
56
+ // would cost a layer and a repaint of the hole per round trip, and both are
57
+ // frames the bitmap pays for — the presenter bench's `anim` made 96 layers
58
+ // for 48 cards. A second is longer than any such gap and shorter than a
59
+ // user's attention span for a layer that is only holding still.
60
+ const IDLE_GRACE_MS = 1000;
61
+
62
+ // Past this many bare-rect claims on one raster the passes cost more than
63
+ // the repaint they save (the layer presenter's rule, per raster).
64
+ const MAX_DIRTY_RECTS = 16;
65
+
66
+ const rectsOverlap = (a, b) =>
67
+ a.x < b.x + b.width &&
68
+ b.x < a.x + a.width &&
69
+ a.y < b.y + b.height &&
70
+ b.y < a.y + a.height;
71
+
72
+ const containsRect = (outer, inner) =>
73
+ inner.x >= outer.x &&
74
+ inner.y >= outer.y &&
75
+ inner.x + inner.width <= outer.x + outer.width &&
76
+ inner.y + inner.height <= outer.y + outer.height;
77
+
78
+ const insetRect = (rect, by) => ({
79
+ x: rect.x + by,
80
+ y: rect.y + by,
81
+ width: rect.width - 2 * by,
82
+ height: rect.height - 2 * by,
83
+ });
84
+
85
+ function unionRect(a, b) {
86
+ if (!a) return b;
87
+ if (!b) return a;
88
+ const x = Math.min(a.x, b.x);
89
+ const y = Math.min(a.y, b.y);
90
+ return {
91
+ x,
92
+ y,
93
+ width: Math.max(a.x + a.width, b.x + b.width) - x,
94
+ height: Math.max(a.y + a.height, b.y + b.height) - y,
95
+ };
96
+ }
97
+
98
+ /** A rect grown outward to whole pixels — a pass clears and clips to its
99
+ * edges, and a fractional edge would antialias the clip into a seam. */
100
+ function wholePixels(rect) {
101
+ const x = Math.floor(rect.x);
102
+ const y = Math.floor(rect.y);
103
+ return {
104
+ x,
105
+ y,
106
+ width: Math.ceil(rect.x + rect.width) - x,
107
+ height: Math.ceil(rect.y + rect.height) - y,
108
+ };
109
+ }
110
+
111
+ /** The widest of a node's four borders, or 0. */
112
+ function borderReach(node) {
113
+ const style = node.style ?? {};
114
+ const w = resolveBorderWidths(style, node.direction);
115
+ return Math.max(0, w.top, w.right, w.bottom, w.left);
116
+ }
117
+
118
+ /** Would `_paintOutline` draw a ring on this node right now? Counted as
119
+ * ink whatever its colour: a wrong "no" here is a ring drawn under a layer. */
120
+ function paintsOutline(node) {
121
+ const style = node.style ?? {};
122
+ if (style.outlineWidth === undefined && !node.states?.[':focus-visible']) {
123
+ return null;
124
+ }
125
+ return node._outline?.() ?? null;
126
+ }
127
+
128
+ /** A `<box>` painting as core paints one — not an element with a paint of
129
+ * its own, whose content exists nowhere but in that override. */
130
+ const plainBox = (node) =>
131
+ node.kind === 'box' && node.paint === BoxNode.prototype.paint;
132
+
133
+ /**
134
+ * Does this node put any ink of its own on the bitmap? A layout-only box —
135
+ * no background, no border, no shadow, no ring — overlaps nothing, however
136
+ * big its rect; everything else is taken to paint its whole box.
137
+ */
138
+ function paintsSomething(node) {
139
+ if (!plainBox(node)) return true;
140
+ const style = node.style ?? {};
141
+ if (style.backgroundColor || style.backgroundImage || style.boxShadow) {
142
+ return true;
143
+ }
144
+ if (borderReach(node) > 0 || paintsOutline(node)) return true;
145
+ return Boolean(node.isScroller?.());
146
+ }
147
+
148
+ /**
149
+ * Can this node be a property box on a layer at all — the static half of
150
+ * the answer, the same whatever the scene around it does: a plain box by
151
+ * its target style, not a scroller (its bars and clip host are the layer
152
+ * presenter's business), no ring lit on it, no paint of its own.
153
+ */
154
+ function promotableNode(node) {
155
+ if (node.destroyed || !plainBox(node)) return false;
156
+ if (!stylePaintsPlain(node, node._targetStyle ?? node.style)) return false;
157
+ if (node.isScroller?.()) return false;
158
+ return !paintsOutline(node);
159
+ }
160
+
161
+ /** The strip a scrollbar's track occupies, with the pad the thumb's
162
+ * antialiasing needs. Mirrors the layer presenter's bar strip. */
163
+ function scrollbarStrip(bar, scale) {
164
+ const pad = Math.ceil(2 * scale);
165
+ return bar.axis === 'x'
166
+ ? {
167
+ x: bar.trackStart - pad,
168
+ y: bar.crossStart - pad,
169
+ width: bar.trackLength + 2 * pad,
170
+ height: bar.height + 2 * pad,
171
+ }
172
+ : {
173
+ x: bar.crossStart - pad,
174
+ y: bar.trackStart - pad,
175
+ width: bar.width + 2 * pad,
176
+ height: bar.trackLength + 2 * pad,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * The surface window's promoted nodes: which ones have a layer, what each
182
+ * layer shows, and the animations the render server runs on them.
183
+ * `frame()` is the whole of the per-frame work, called by the window from
184
+ * nodes.js's `prepareFrame` seam — after layout, before the damage is taken.
185
+ */
186
+ export class CocoaPromotion {
187
+ constructor(window) {
188
+ this.window = window;
189
+ this.native = window._native;
190
+ this.scale = window.scale;
191
+ this.app = window.app;
192
+ this.fonts = window.app.fonts;
193
+ this.rootVisual = { layer: window._layer };
194
+ this.promoted = new Map(); // node -> { visual, content, dirty }
195
+ // nodes whose animation was taken and have no layer yet: the frame
196
+ // decides, against the scene as laid out
197
+ this.candidates = new Set();
198
+ // a refusal, by the layout it was decided in: the same scene answers
199
+ // the same way, and every refusal a frame makes restarts the entry
200
+ this.denied = new WeakMap(); // node -> layout generation
201
+ this.layoutGen = 0;
202
+ // nodes whose last animation ended, waiting out the grace before the
203
+ // frame that takes them back into the bitmap; and the ones that frame
204
+ // is owed for
205
+ this.idle = new Map(); // node -> timer
206
+ this.releasing = new Set();
207
+ this.animations = new LayerAnimations({
208
+ native: this.native,
209
+ app: this.app,
210
+ scale: this.scale,
211
+ layerOf: (node) => this.promoted.get(node)?.visual.layer ?? null,
212
+ onIdle: (node) => this._idle(node),
213
+ });
214
+ this._claiming = false;
215
+ }
216
+
217
+ // --- the window's animation seam -------------------------------------------
218
+
219
+ /**
220
+ * Take `prop`'s animation for `node` off the frame clock, or decline.
221
+ * Taken here means only that the node is a candidate: whether it gets a
222
+ * layer is decided by the next frame, with the scene laid out, and a
223
+ * frame that decides against it hands the entry back to the clock from
224
+ * the top — before anything was painted at the target, so nothing shows.
225
+ */
226
+ animate(node, prop, entry) {
227
+ if (this.window.destroyed) return false;
228
+ if (!this.promoted.has(node)) {
229
+ if (this.denied.get(node) === this.layoutGen) return false;
230
+ if (!promotableNode(node)) return false;
231
+ }
232
+ if (!this.animations.take(node, prop, entry)) return false;
233
+ if (this.promoted.has(node)) this._keep(node);
234
+ else this.candidates.add(node);
235
+ return true;
236
+ }
237
+
238
+ /** Stop what runs for `prop` on `node`. The layer stays: a node with
239
+ * nothing left running on it waits out the grace like one whose
240
+ * animation ended, and the frame after that takes it back. */
241
+ cancel(node, prop) {
242
+ this.animations.cancel(node, prop);
243
+ if (this.promoted.has(node) && !this.animations.has(node)) {
244
+ this._idle(node);
245
+ }
246
+ }
247
+
248
+ /** Nothing runs on `node`'s layer any more: keep it for the grace, then
249
+ * ask for the frame that takes the node back into the bitmap. */
250
+ _idle(node) {
251
+ if (!this.promoted.has(node) || this.idle.has(node)) return;
252
+ const timer = setTimeout(() => {
253
+ this.idle.delete(node);
254
+ this._release(node);
255
+ }, IDLE_GRACE_MS);
256
+ timer.unref?.();
257
+ this.idle.set(node, timer);
258
+ }
259
+
260
+ /** Something runs on `node`'s layer again: it stays. */
261
+ _keep(node) {
262
+ const timer = this.idle.get(node);
263
+ if (timer) {
264
+ clearTimeout(timer);
265
+ this.idle.delete(node);
266
+ }
267
+ this.releasing.delete(node);
268
+ }
269
+
270
+ _release(node) {
271
+ if (!this.promoted.has(node) || node.destroyed) return;
272
+ if (this.animations.has(node)) return; // taken again meanwhile
273
+ this.releasing.add(node);
274
+ const root = this.window._reactX11Node;
275
+ if (root && !root.destroyed) root.invalidate(false, node, 'animation');
276
+ }
277
+
278
+ /** Every idle node back into the bitmap on the next frame, grace or no
279
+ * grace — for the tests, which do not wait a second. */
280
+ releaseIdle() {
281
+ for (const node of [...this.idle.keys()]) {
282
+ clearTimeout(this.idle.get(node));
283
+ this.idle.delete(node);
284
+ this._release(node);
285
+ }
286
+ }
287
+
288
+ // --- the invalidate channel ----------------------------------------------
289
+
290
+ /**
291
+ * A claim against the bitmap, seen on its way there: what it says about
292
+ * a promoted node's own layer needs no note — the model is re-diffed
293
+ * every frame — but its children's raster repaints only what is claimed
294
+ * inside it. A node claim inside a promoted subtree is a pass over that
295
+ * node's reach; a layout change anywhere in the subtree, or a claim with
296
+ * no bound, repaints the raster in full; a bare rect repaints that part
297
+ * of every raster it reaches into (the layer presenter's rule).
298
+ */
299
+ noteInvalidate(damage, layoutChanged) {
300
+ if (this._claiming || this.promoted.size === 0) return false;
301
+ if (damage == null) {
302
+ for (const p of this.promoted.values()) p.dirty.all = true;
303
+ return false;
304
+ }
305
+ if (damage.kind) {
306
+ let owner = null;
307
+ for (let n = damage; n && !n.isWindow; n = n.parent) {
308
+ if (this.promoted.has(n)) {
309
+ owner = n;
310
+ break;
311
+ }
312
+ }
313
+ if (!owner) return false;
314
+ const p = this.promoted.get(owner);
315
+ if (layoutChanged) {
316
+ p.dirty.all = true;
317
+ return false;
318
+ }
319
+ if (owner !== damage) this._dirtyRect(p, damage.paintBounds());
320
+ // answered here — the model re-diffed, the raster repainted — and the
321
+ // bitmap owes nothing: it holds a hole where this node is
322
+ return true;
323
+ }
324
+ if (!(damage.width > 0 && damage.height > 0)) return false;
325
+ for (const p of this.promoted.values()) {
326
+ if (p.content && rectsOverlap(p.content.rect, damage)) {
327
+ this._dirtyRect(p, damage);
328
+ }
329
+ }
330
+ return false;
331
+ }
332
+
333
+ _dirtyRect(p, rect) {
334
+ if (p.dirty.all) return;
335
+ if (p.dirty.rects.length >= MAX_DIRTY_RECTS) {
336
+ p.dirty.all = true;
337
+ return;
338
+ }
339
+ // copied: a caller's rect is very often a live `abs` about to move
340
+ p.dirty.rects.push({
341
+ x: rect.x,
342
+ y: rect.y,
343
+ width: rect.width,
344
+ height: rect.height,
345
+ });
346
+ }
347
+
348
+ // --- the frame -------------------------------------------------------------
349
+
350
+ /**
351
+ * The presenter's half of a frame. Three passes, all inside one
352
+ * disabled-actions transaction: every promoted node is asked again
353
+ * whether it may stay, the candidates are decided, and what remains is
354
+ * synced — frame, properties, the animations waiting to attach, the
355
+ * children's raster. A node taken off a layer here is painted back into
356
+ * the bitmap by this same frame, which is why this runs before the
357
+ * damage is taken (`_claim`).
358
+ */
359
+ frame(root, layoutRan) {
360
+ if (layoutRan) this.layoutGen++;
361
+ if (this.promoted.size === 0 && this.candidates.size === 0) return;
362
+ const native = this.native;
363
+ native.txBegin({ disableActions: true });
364
+ try {
365
+ // Later-painted first: a node painted after this one that is coming
366
+ // off its layer is what this one would be overlapped by, and the
367
+ // answer has to be known in the same frame, not the next.
368
+ const keyed = this._inPaintOrder([...this.promoted.keys()]);
369
+ for (let i = keyed.length - 1; i >= 0; i--) {
370
+ const { node, key } = keyed[i];
371
+ if (!this._mayStay(node, key)) this._demote(node, root, true);
372
+ }
373
+ if (this.candidates.size) {
374
+ const candidates = this._inPaintOrder([...this.candidates]);
375
+ this.candidates.clear();
376
+ for (let i = candidates.length - 1; i >= 0; i--) {
377
+ const { node, key } = candidates[i];
378
+ if (this.promoted.has(node) || !this.animations.has(node)) continue;
379
+ if (key && promotableNode(node) && this._clear(node)) {
380
+ this._promote(node, root);
381
+ } else {
382
+ this.denied.set(node, this.layoutGen);
383
+ this.animations.drop(node, true);
384
+ }
385
+ }
386
+ }
387
+ const order = this._inPaintOrder([...this.promoted.keys()]);
388
+ for (let i = 0; i < order.length; i++) {
389
+ this._sync(order[i].node, i, root, layoutRan);
390
+ }
391
+ } finally {
392
+ native.txCommit();
393
+ }
394
+ }
395
+
396
+ _mayStay(node, key) {
397
+ if (!key || !promotableNode(node)) return false;
398
+ if (this.releasing.has(node)) return false; // its grace ran out
399
+ return this._clear(node);
400
+ }
401
+
402
+ _promote(node, root) {
403
+ const visual = new Visual(this, node);
404
+ visual.attach(this.rootVisual);
405
+ this.promoted.set(node, {
406
+ visual,
407
+ content: null,
408
+ dirty: { all: true, rects: [] },
409
+ });
410
+ node._promoted = true;
411
+ // the bitmap under it repaints without it, from this frame on
412
+ this._claim(root, node.paintBounds());
413
+ }
414
+
415
+ /**
416
+ * Off its layer and back into the bitmap, in this frame. `reclaim` hands
417
+ * what was waiting for a frame back to the clock; what was running is
418
+ * over either way, and the model shows — a loop comes back to the clock
419
+ * through `_offloadEnded`'s own rule.
420
+ */
421
+ _demote(node, root, reclaim) {
422
+ const p = this.promoted.get(node);
423
+ if (!p) return;
424
+ this.promoted.delete(node);
425
+ this._keep(node);
426
+ node._promoted = false;
427
+ this.animations.drop(node, reclaim && !node.destroyed);
428
+ this._dropContent(p);
429
+ p.visual.destroy();
430
+ if (!node.destroyed && node.abs) this._claim(root, node.paintBounds());
431
+ }
432
+
433
+ _dropContent(p) {
434
+ if (!p.content) return;
435
+ this.native.removeFromSuperlayer(p.content.layer);
436
+ p.content.raster.release(this.native);
437
+ p.content = null;
438
+ }
439
+
440
+ /** A claim of our own against the bitmap, kept off our own books. */
441
+ _claim(root, rect) {
442
+ if (!root || root.destroyed) return;
443
+ this._claiming = true;
444
+ try {
445
+ root.invalidate(false, rect, 'animation');
446
+ } finally {
447
+ this._claiming = false;
448
+ }
449
+ }
450
+
451
+ // --- where a node stands in the walk ----------------------------------------
452
+
453
+ /**
454
+ * The node's place in the window's paint order — its index in each
455
+ * ancestor's `paintOrder()`, top down — or null when the walk would not
456
+ * reach it: hidden, `display: none`, or not attached to a window.
457
+ */
458
+ _paintKey(node) {
459
+ const key = [];
460
+ for (let n = node; !n.isWindow; n = n.parent) {
461
+ const parent = n.parent;
462
+ if (!parent) return null;
463
+ const i = parent.paintOrder().indexOf(n);
464
+ if (i < 0) return null;
465
+ key.push(i);
466
+ }
467
+ return key.reverse();
468
+ }
469
+
470
+ _inPaintOrder(nodes) {
471
+ const keyed = nodes.map((node) => ({ node, key: this._paintKey(node) }));
472
+ keyed.sort((a, b) => {
473
+ if (!a.key || !b.key) return (a.key ? 1 : 0) - (b.key ? 1 : 0);
474
+ const n = Math.min(a.key.length, b.key.length);
475
+ for (let i = 0; i < n; i++) {
476
+ if (a.key[i] !== b.key[i]) return a.key[i] - b.key[i];
477
+ }
478
+ return a.key.length - b.key.length;
479
+ });
480
+ return keyed;
481
+ }
482
+
483
+ /**
484
+ * Is nothing painted over this node, and is all of it visible? Walked up
485
+ * the chain: at every level, what the parent paints after the child on
486
+ * the way to this node — later siblings, then the parent's own border and
487
+ * ring, then its bars — must keep out of the node's reach, and the
488
+ * parent's clip, if it has one, must hold the whole of it.
489
+ */
490
+ _clear(node) {
491
+ // the exact reach, not `paintBounds()`: that one carries the damage
492
+ // model's pixel of slop, and a section laid out flush under a card
493
+ // would read as reaching into it
494
+ const bounds = node._subtreeBounds();
495
+ for (let n = node; !n.isWindow; n = n.parent) {
496
+ const parent = n.parent;
497
+ if (!parent) return false;
498
+ const order = parent.paintOrder();
499
+ for (let j = order.indexOf(n) + 1; j < order.length; j++) {
500
+ if (this._reaches(order[j], bounds)) return false;
501
+ }
502
+ if (!parent.isWindow) {
503
+ const border = borderReach(parent);
504
+ if (
505
+ border > 0 &&
506
+ !containsRect(insetRect(parent.abs, border), bounds)
507
+ ) {
508
+ return false;
509
+ }
510
+ const ring = paintsOutline(parent);
511
+ if (ring) {
512
+ const inside = Math.max(0, ring.width / 2 - ring.offset) + 1;
513
+ if (!containsRect(insetRect(parent.abs, inside), bounds))
514
+ return false;
515
+ }
516
+ if (parent.clipsChildren?.()) {
517
+ const radius = parent.style?.borderRadius;
518
+ const clip =
519
+ typeof radius === 'number' && radius > 0
520
+ ? insetRect(parent.abs, radius)
521
+ : parent.abs;
522
+ if (!containsRect(clip, bounds)) return false;
523
+ }
524
+ }
525
+ if (typeof parent._scrollbars === 'function') {
526
+ for (const bar of parent._scrollbars()) {
527
+ if (rectsOverlap(scrollbarStrip(bar, this.scale), bounds)) {
528
+ return false;
529
+ }
530
+ }
531
+ }
532
+ }
533
+ return true;
534
+ }
535
+
536
+ /**
537
+ * Does anything in `n`'s subtree put ink inside `rect`? A promoted
538
+ * subtree does not: it is on a layer of its own, above this one since it
539
+ * is painted later. A subtree whose reach misses the rect is answered
540
+ * from the cached reach without a walk.
541
+ */
542
+ _reaches(n, rect) {
543
+ if (!rectsOverlap(n._subtreeBounds(), rect)) return false;
544
+ if (this.promoted.has(n)) return false;
545
+ if (paintsSomething(n) && rectsOverlap(n._ownPaintBounds(), rect)) {
546
+ return true;
547
+ }
548
+ if (n.kind === 'text') return true; // its spans are its own ink
549
+ for (const child of n.paintOrder()) {
550
+ if (this._reaches(child, rect)) return true;
551
+ }
552
+ return false;
553
+ }
554
+
555
+ // --- what the layer shows ----------------------------------------------------
556
+
557
+ _sync(node, order, root, layoutRan) {
558
+ const p = this.promoted.get(node);
559
+ p.visual.set(propBoxProps(node, this.app, this.scale, ORIGIN, order));
560
+ // after the model value went out, inside the same transaction
561
+ this.animations.apply(node, p.visual.layer);
562
+ this._syncContent(node, p, root, layoutRan);
563
+ }
564
+
565
+ /**
566
+ * The children, rastered into one sublayer at their reach: the node's own
567
+ * `_paintChildren` walk, translated so the reach lands at the bitmap's
568
+ * origin, with `paintDamage()` naming each pass the way the window's
569
+ * paint does. Repainted for the claims that reached into it since the
570
+ * last frame, for a change of size, and for any change in where the
571
+ * children sit inside the node — a layout pass can move them without a
572
+ * claim naming any of them, so a layout frame compares their rects.
573
+ */
574
+ _syncContent(node, p, root, layoutRan) {
575
+ const abs = node.abs;
576
+ let reach = null;
577
+ for (const child of node.paintOrder()) {
578
+ if (child._promoted) continue; // on a layer of its own
579
+ reach = unionRect(reach, child._subtreeBounds());
580
+ }
581
+ if (reach && node.clipsChildren?.()) reach = intersectRects(reach, abs);
582
+ if (!reach || !(reach.width > 0 && reach.height > 0)) {
583
+ this._dropContent(p);
584
+ p.dirty = { all: true, rects: [] };
585
+ return;
586
+ }
587
+ const rect = {
588
+ x: Math.floor(reach.x) - RASTER_PAD,
589
+ y: Math.floor(reach.y) - RASTER_PAD,
590
+ width: Math.ceil(reach.width) + RASTER_PAD * 2,
591
+ height: Math.ceil(reach.height) + RASTER_PAD * 2,
592
+ };
593
+ let content = p.content;
594
+ if (!content) {
595
+ const layer = this.native.createLayer();
596
+ this.native.addSublayer(p.visual.layer, layer);
597
+ content = p.content = {
598
+ layer,
599
+ raster: new RasterState(),
600
+ rect: null,
601
+ layoutKey: null,
602
+ props: {},
603
+ };
604
+ }
605
+ const s = this.scale;
606
+ const frame = [
607
+ (rect.x - abs.x) / s,
608
+ (rect.y - abs.y) / s,
609
+ rect.width / s,
610
+ rect.height / s,
611
+ ];
612
+ if (
613
+ !content.props.frame ||
614
+ frame.some((v, i) => v !== content.props.frame[i])
615
+ ) {
616
+ content.props.frame = frame;
617
+ this.native.setLayerProps(content.layer, { frame, zPosition: 0 });
618
+ }
619
+ const raster = content.raster;
620
+ const sizeChanged =
621
+ raster.width !== rect.width || raster.height !== rect.height;
622
+ const layoutKey =
623
+ layoutRan || !content.layoutKey ? layoutKeyOf(node) : null;
624
+ const moved =
625
+ content.rect && (content.rect.x !== rect.x || content.rect.y !== rect.y);
626
+ const full =
627
+ p.dirty.all ||
628
+ sizeChanged ||
629
+ (layoutKey !== null && layoutKey !== content.layoutKey) ||
630
+ (moved && p.dirty.rects.length > 0);
631
+ let passes = null;
632
+ if (full) {
633
+ passes = [null];
634
+ } else if (p.dirty.rects.length) {
635
+ for (const claimed of p.dirty.rects) {
636
+ const hit = intersectRects(wholePixels(claimed), rect);
637
+ if (hit) passes = addDamageRect(passes, hit);
638
+ }
639
+ if (passes) passes = damageToPaint(passes);
640
+ }
641
+ p.dirty = { all: false, rects: [] };
642
+ content.rect = rect;
643
+ if (layoutKey !== null) content.layoutKey = layoutKey;
644
+ if (!passes) return;
645
+ const ctx = raster.ensure(this, rect.width, rect.height, s);
646
+ ctx.save();
647
+ try {
648
+ ctx.translate(-rect.x, -rect.y);
649
+ for (const pass of passes) {
650
+ const area = pass ?? rect;
651
+ ctx.save();
652
+ try {
653
+ if (pass) {
654
+ ctx.beginPath();
655
+ ctx.rect(area.x, area.y, area.width, area.height);
656
+ ctx.clip();
657
+ }
658
+ ctx.clearRect(area.x, area.y, area.width, area.height);
659
+ root._paintDamage = pass;
660
+ node._paintChildren(ctx);
661
+ } finally {
662
+ root._paintDamage = null;
663
+ ctx.restore();
664
+ }
665
+ }
666
+ } finally {
667
+ ctx.restore();
668
+ }
669
+ this.native.surfaceToLayer(raster.surface, content.layer);
670
+ }
671
+
672
+ /** Everything off the root layer and freed: the window is going. */
673
+ destroy() {
674
+ for (const timer of this.idle.values()) clearTimeout(timer);
675
+ this.idle.clear();
676
+ this.releasing.clear();
677
+ for (const [node, p] of this.promoted) {
678
+ node._promoted = false;
679
+ this.animations.drop(node, false);
680
+ this._dropContent(p);
681
+ p.visual.destroy();
682
+ }
683
+ this.promoted.clear();
684
+ this.candidates.clear();
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Where a node's drawn descendants sit inside it, as one string: the same
690
+ * arrangement gives the same key, and a raster painted for one is good for
691
+ * the other. Promoted subtrees are left out — they are on layers of their
692
+ * own — and so is anything that is not layout, which claims for itself.
693
+ */
694
+ function layoutKeyOf(node) {
695
+ const ox = node.abs.x;
696
+ const oy = node.abs.y;
697
+ let key = '';
698
+ const walk = (n) => {
699
+ for (const child of n.paintOrder()) {
700
+ if (child._promoted) continue;
701
+ const a = child.abs;
702
+ key += `${a.x - ox},${a.y - oy},${a.width},${a.height};`;
703
+ if (child.kind !== 'text') walk(child);
704
+ }
705
+ };
706
+ walk(node);
707
+ return key;
708
+ }