react-x11 2.34.0 → 2.36.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.
@@ -23,16 +23,19 @@
23
23
  // part: a promoted layer sits above ALL the 2D content, so a node is
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
- // focus ring, no scrollbar — and only when every clipping ancestor holds
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
29
- // always safe: the frame clock runs the animation exactly as it does
30
- // without this file. And the same test runs again every frame, so a node
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
33
- // clock. Overlays, toasts, drag ghosts, spinners and floating cards pass by
34
- // construction; a hover fade on a row in the middle of a list does not,
35
- // and stays on the clock. docs/macos.md §"Layer promotion" is the account.
26
+ // focus ring — and only when every clipping ancestor with round corners
27
+ // holds the whole of it, because the layer is not cut to a curve; one with
28
+ // square corners cuts the layer to its box, a row half out of its list as
29
+ // much as a spinner inside a card. A scrollbar's thumb drawn over it goes
30
+ // on a layer of its own above it instead (`_syncThumbs`). And no ancestor
31
+ // may fade, because the layer would not be faded with it. Declining is always
32
+ // safe: the frame clock runs the animation exactly as it does without this
33
+ // file. The same test runs again every frame, so a node that becomes
34
+ // overlapped, hidden, clipped, faded or non-plain returns to the bitmap in
35
+ // the frame that finds it, the animation handed back to the clock — and a
36
+ // loop the clock runs is offered again once the scene would take it
37
+ // (`wouldTake`), where it is in its cycle. docs/macos.md §"Layer
38
+ // promotion" is the account.
36
39
  import { BoxNode } from '../nodes/box.js';
37
40
  import { addDamageRect, damageToPaint } from '../nodes/damage.js';
38
41
  import { intersectRects } from '../nodes/rects.js';
@@ -42,6 +45,7 @@ import {
42
45
  RASTER_PAD,
43
46
  RasterState,
44
47
  Visual,
48
+ movesNode,
45
49
  propBoxProps,
46
50
  stylePaintsPlain,
47
51
  } from './presenter.js';
@@ -49,6 +53,15 @@ import { SpriteLayers } from './sprites.js';
49
53
 
50
54
  const ORIGIN = Object.freeze({ x: 0, y: 0 });
51
55
 
56
+ // A pane's thumbs come after everything inside it in the paint order — the
57
+ // bars are painted over the pane's children — and before whatever is
58
+ // painted after the pane: its key with this past any child's index.
59
+ const AFTER_CHILDREN = 1e9;
60
+
61
+ // What core paints a thumb in when the pane names no colour
62
+ // (`paintScrollbarThumb`).
63
+ const THUMB_COLOUR = 'rgba(0, 0, 0, 0.25)';
64
+
52
65
  // How long a node that has stopped animating keeps its layer. A hover card
53
66
  // fades in and, a moment later, out; a palette step ends one transition and
54
67
  // starts the next; a toast pulses again. Demoting on the last frame of each
@@ -79,6 +92,11 @@ const rectsOverlap = (a, b) =>
79
92
  a.y < b.y + b.height &&
80
93
  b.y < a.y + a.height;
81
94
 
95
+ // `a` cut to `b`: their overlap, or a clip of no size where they have none,
96
+ // which hides what it holds rather than holding it whole
97
+ const cutTo = (a, b) =>
98
+ intersectRects(a, b) ?? { x: b.x, y: b.y, width: 0, height: 0 };
99
+
82
100
  const containsRect = (outer, inner) =>
83
101
  inner.x >= outer.x &&
84
102
  inner.y >= outer.y &&
@@ -202,9 +220,23 @@ function promotableNode(node) {
202
220
  return !paintsOutline(node);
203
221
  }
204
222
 
205
- /** The strip a scrollbar's track occupies, with the pad the thumb's
206
- * antialiasing needs. Mirrors the layer presenter's bar strip. */
207
- function scrollbarStrip(bar, scale) {
223
+ /** Where a scrollbar puts ink: its thumb, with the pad the thumb's
224
+ * antialiasing needs. The track is not drawn, so a layer beside the thumb
225
+ * covers nothing of the bar's; one the thumb is drawn over has the thumb
226
+ * lifted above it (`_thumbsLiftable`). */
227
+ function scrollbarInk(bar, scale) {
228
+ const pad = Math.ceil(2 * scale);
229
+ return {
230
+ x: bar.x - pad,
231
+ y: bar.y - pad,
232
+ width: bar.width + 2 * pad,
233
+ height: bar.height + 2 * pad,
234
+ };
235
+ }
236
+
237
+ /** …and everywhere the thumb can go, which a lifted thumb's layer may come
238
+ * to stand over as the pane scrolls. */
239
+ function scrollbarTrack(bar, scale) {
208
240
  const pad = Math.ceil(2 * scale);
209
241
  return bar.axis === 'x'
210
242
  ? {
@@ -221,6 +253,38 @@ function scrollbarStrip(bar, scale) {
221
253
  };
222
254
  }
223
255
 
256
+ /**
257
+ * The clip a node's layer is cut to: the one `_clear` narrowed its reach to
258
+ * through the clipping ancestors around it, where that leaves any of the
259
+ * node out — and null where every one of them holds it whole, which keeps a
260
+ * node clear of its pane's edges on the window root as before. A rounded
261
+ * clip comes with its radius, and with the square clips that cut it again
262
+ * (`outer`), as an element's part's does.
263
+ */
264
+ function clipOf(cut, reach) {
265
+ if (!cut.clip) return null;
266
+ const radius = cut.radius > 0 ? cut.radius : 0;
267
+ const held = radius > 0 ? insetRect(cut.clip, radius) : cut.clip;
268
+ if (!cut.outer && containsRect(held, reach)) return null;
269
+ return { rect: cut.clip, radius, outer: cut.outer ?? null };
270
+ }
271
+
272
+ /**
273
+ * Where a node's layer can be. Where the layout put it and what it holds
274
+ * reach — or, for a node a loop moves (an inset on a node out of the flow,
275
+ * which the render server runs as the layer's position, presenter.js
276
+ * `INSET_KEY_PATHS`), anywhere in the nearest box that clips it, the reach
277
+ * every frame of the loop stays inside (nodes/animation.js `_loopReach`).
278
+ * Null where nothing short of the window holds it, which no layer is
279
+ * cleared for.
280
+ */
281
+ function reachOf(node) {
282
+ if (!node._loops?.some((loop) => movesNode(loop.prop))) {
283
+ return node._subtreeBounds();
284
+ }
285
+ return node._loopReach?.()?.rect ?? null;
286
+ }
287
+
224
288
  /**
225
289
  * The surface window's promoted nodes: which ones have a layer, what each
226
290
  * layer shows, and the animations the render server runs on them.
@@ -258,6 +322,11 @@ export class CocoaPromotion {
258
322
  // the parts of elements' drawing they offer (`Node.sprites()`), each on
259
323
  // a layer of its own under the same rules (src/cocoa/sprites.js)
260
324
  this.sprites = new SpriteLayers(this);
325
+ // the scroll panes whose thumbs are on layers of their own, above the
326
+ // layers they are drawn over (`_syncThumbs`), and the ones this frame's
327
+ // layers are under
328
+ this.thumbs = new Map(); // pane -> Map(axis -> { layer, props })
329
+ this._thumbsWanted = null;
261
330
  this._claiming = false;
262
331
  }
263
332
 
@@ -412,12 +481,14 @@ export class CocoaPromotion {
412
481
  this.promoted.size === 0 &&
413
482
  this.candidates.size === 0 &&
414
483
  offers.size === 0 &&
415
- this.sprites.hosts.size === 0
484
+ this.sprites.hosts.size === 0 &&
485
+ this.thumbs.size === 0
416
486
  ) {
417
487
  return;
418
488
  }
419
489
  const native = this.native;
420
490
  let lifted = null;
491
+ const wanted = (this._thumbsWanted = new Set());
421
492
  native.txBegin({ disableActions: true });
422
493
  try {
423
494
  // Later-painted first: a node painted after this one that is coming
@@ -434,8 +505,11 @@ export class CocoaPromotion {
434
505
  for (let i = candidates.length - 1; i >= 0; i--) {
435
506
  const { node, key } = candidates[i];
436
507
  if (this.promoted.has(node) || !this.animations.has(node)) continue;
437
- if (key && promotableNode(node) && this._clear(node)) {
438
- this._promote(node, root);
508
+ const cut = { thumbs: new Set(), round: true };
509
+ const reach = key && promotableNode(node) ? reachOf(node) : null;
510
+ if (reach && this._clear(node, reach, cut)) {
511
+ for (const pane of cut.thumbs) wanted.add(pane);
512
+ this._promote(node, root, clipOf(cut, reach));
439
513
  } else {
440
514
  this.denied.set(node, this.layoutGen);
441
515
  this.animations.drop(node, true);
@@ -445,18 +519,29 @@ export class CocoaPromotion {
445
519
  // Then the parts, against the layers this frame keeps: a node
446
520
  // promoted over a part is a layer above it, not ink on it.
447
521
  lifted = this.sprites.decide(root, offers);
448
- // One order for every layer above the bitmap, nodes and parts alike.
522
+ // A thumb no layer is under any more goes back into the bitmap.
523
+ for (const pane of [...this.thumbs.keys()]) {
524
+ if (!wanted.has(pane)) this._dropThumbs(pane, root);
525
+ }
526
+ // One order for every layer above the bitmap, nodes, parts and
527
+ // thumbs alike.
449
528
  const order = this._inPaintOrder([...this.promoted.keys()]);
450
529
  for (const state of this.sprites.entries()) {
451
530
  order.push({ state, key: state.order });
452
531
  }
532
+ for (const pane of wanted) {
533
+ const key = this._paintKey(pane);
534
+ if (key) order.push({ pane, key: [...key, AFTER_CHILDREN] });
535
+ }
453
536
  order.sort(byPaintKey);
454
537
  for (let i = 0; i < order.length; i++) {
455
- const { node, state } = order[i];
538
+ const { node, state, pane } = order[i];
456
539
  if (node) this._sync(node, i, root, layoutRan);
540
+ else if (pane) this._syncThumbs(pane, i, root);
457
541
  else this.sprites.sync(state, i);
458
542
  }
459
543
  } finally {
544
+ this._thumbsWanted = null;
460
545
  native.txCommit();
461
546
  }
462
547
  // An element told which of its parts are on layers now may claim,
@@ -467,17 +552,55 @@ export class CocoaPromotion {
467
552
  _mayStay(node, key) {
468
553
  if (!key || !promotableNode(node)) return false;
469
554
  if (this.releasing.has(node)) return false; // its grace ran out
470
- return this._clear(node);
555
+ const cut = { thumbs: new Set(), round: true };
556
+ const reach = reachOf(node);
557
+ if (!reach || !this._clear(node, reach, cut)) return false;
558
+ for (const pane of cut.thumbs) this._thumbsWanted?.add(pane);
559
+ this.promoted.get(node).clip = clipOf(cut, reach);
560
+ return true;
471
561
  }
472
562
 
473
- _promote(node, root) {
474
- const visual = new Visual(this, node);
475
- visual.attach(this.rootVisual);
476
- this.promoted.set(node, {
477
- visual,
563
+ /**
564
+ * Would a frame put `node` on a layer now? Asked of a loop the clock runs
565
+ * because a frame turned it down or gave it back (nodes/animation.js
566
+ * `_offerLoopsAgain`): the scene that refused it may have moved on — a
567
+ * fading ancestor arrived, a sibling went, a scroll took the thumb away —
568
+ * and nothing about the loop itself would ever ask again. The frame
569
+ * decides as it always does; this is what keeps a loop that would only
570
+ * be turned down again from being offered every frame.
571
+ */
572
+ wouldTake(node) {
573
+ if (this.window.destroyed || node.destroyed) return false;
574
+ if (this.promoted.has(node)) return true;
575
+ if (!promotableNode(node) || !this._paintKey(node)) return false;
576
+ const reach = reachOf(node);
577
+ if (!reach) return false;
578
+ if (!this._clear(node, reach, { thumbs: new Set(), round: true })) {
579
+ return false;
580
+ }
581
+ // asked of the scene as it is now, so a refusal from an earlier one —
582
+ // kept for the layout it was made in, and a fade that arrives lays
583
+ // nothing out — no longer stands
584
+ this.denied.delete(node);
585
+ return true;
586
+ }
587
+
588
+ _promote(node, root, clip = null) {
589
+ const p = {
590
+ visual: new Visual(this, node),
478
591
  content: null,
479
592
  dirty: { all: true, rects: [] },
480
- });
593
+ // the clip a clipping ancestor cuts it to, or null (`clipOf`), the box
594
+ // that masks to it, and the box of the square clips around a rounded
595
+ // one
596
+ clip,
597
+ box: null,
598
+ boxProps: {},
599
+ outerBox: null,
600
+ outerProps: {},
601
+ };
602
+ this.promoted.set(node, p);
603
+ this._place(p);
481
604
  node._promoted = true;
482
605
  // the bitmap under it repaints without it, from this frame on — and so
483
606
  // does the raster of a promoted node it is in, which the claim that
@@ -500,12 +623,64 @@ export class CocoaPromotion {
500
623
  this.animations.drop(node, reclaim && !node.destroyed);
501
624
  this._dropContent(p);
502
625
  p.visual.destroy();
626
+ if (p.box) this.native.removeFromSuperlayer(p.outerBox ?? p.box);
503
627
  if (!node.destroyed && node.abs) {
504
628
  this._repaintHolder(node);
505
629
  this._claim(root, node.paintBounds());
506
630
  }
507
631
  }
508
632
 
633
+ /**
634
+ * The node's layer on the window root, or in a box of its clip's size
635
+ * that masks to it, where a clipping ancestor with square corners cuts it
636
+ * (`clipOf`) — a row half scrolled out of its list, as an element's part
637
+ * is cut (src/cocoa/sprites.js). A layer moved into or out of the box
638
+ * sends everything again (`Visual.attach`).
639
+ */
640
+ _place(p) {
641
+ const want = p.clip ? (p.clip.outer ? 2 : 1) : 0;
642
+ const have = p.outerBox ? 2 : p.box ? 1 : 0;
643
+ if (want === have) {
644
+ if (!want) p.visual.attach(this.rootVisual);
645
+ return;
646
+ }
647
+ if (p.box) {
648
+ p.visual.attach(this.rootVisual);
649
+ this.native.removeFromSuperlayer(p.outerBox ?? p.box);
650
+ p.box = null;
651
+ p.outerBox = null;
652
+ }
653
+ if (!want) return;
654
+ p.box = this.native.createLayer();
655
+ p.boxProps = {};
656
+ if (want === 2) {
657
+ p.outerBox = this.native.createLayer();
658
+ p.outerProps = {};
659
+ this.native.addSublayer(this.rootVisual.layer, p.outerBox);
660
+ this.native.addSublayer(p.outerBox, p.box);
661
+ } else {
662
+ this.native.addSublayer(this.rootVisual.layer, p.box);
663
+ }
664
+ p.visual.attach({ layer: p.box });
665
+ }
666
+
667
+ /** A box's properties, sent where they changed. */
668
+ _setBox(layer, p, field, next) {
669
+ const was = p[field];
670
+ for (const key of Object.keys(next)) {
671
+ const a = was[key];
672
+ const b = next[key];
673
+ const same = Array.isArray(b)
674
+ ? Array.isArray(a) && b.every((v, i) => v === a[i])
675
+ : a === b;
676
+ if (!same) {
677
+ this.native.setLayerProps(layer, next);
678
+ p[field] = next;
679
+ return;
680
+ }
681
+ }
682
+ }
683
+
509
684
  /**
510
685
  * A node coming off its layer inside a promoted node: the bitmap holds a
511
686
  * hole there, and the node's pixels belong in that node's raster — so the
@@ -592,8 +767,9 @@ export class CocoaPromotion {
592
767
  * it, and the parent must not fade: its group is drawn on the bitmap or on
593
768
  * its own layer, and either way a layer of the node's would be outside it.
594
769
  *
595
- * With `cut`, the reach is an element's part (src/cocoa/sprites.js),
596
- * whose layer can be cut to a rectangle: a clipping ancestor with square
770
+ * With `cut`, the layer can be cut to a rectangle — a node's
771
+ * (`clipOf`), or an element's part's (src/cocoa/sprites.js): a clipping
772
+ * ancestor with square
597
773
  * corners narrows `cut.clip` to its padding box — its border is drawn
598
774
  * over what it holds anyway — instead of having to hold the part whole,
599
775
  * and every test from there up is asked of what shows through the clip.
@@ -615,13 +791,47 @@ export class CocoaPromotion {
615
791
  !(parent.style?.borderRadius > 0)
616
792
  ) {
617
793
  const inner = insetRect(parent.abs, borderReach(parent));
618
- // a clip with round corners cut again is no box's shape
619
- if (cut.radius > 0 && cut.clip && !containsRect(inner, cut.clip)) {
620
- return false;
794
+ // a clip with round corners cut again is no one box's shape: the
795
+ // square ones go in a box of their own around it (`outer`)
796
+ if (cut.radius > 0 && cut.clip) {
797
+ if (cut.outer || !containsRect(inner, cut.clip)) {
798
+ cut.outer = cut.outer ? cutTo(cut.outer, inner) : inner;
799
+ }
800
+ } else {
801
+ cut.clip = cut.clip ? cutTo(cut.clip, inner) : inner;
802
+ }
803
+ bounds = intersectRects(bounds, cut.clip);
804
+ if (bounds && cut.outer) bounds = intersectRects(bounds, cut.outer);
805
+ // nothing of the part shows: its layer is kept, cut to show nothing,
806
+ // and there is nothing it could be under or over until it does
807
+ if (!bounds) {
808
+ cut.bounds = null;
809
+ return true;
810
+ }
811
+ }
812
+ // A node's layer may be cut to one rounded clip, the innermost that
813
+ // does not hold it — a block sliding along a bar's rounded track. Its
814
+ // padding box, with the corners its border leaves, and every square
815
+ // clip above it cuts it again as above (`outer`).
816
+ let roundCut = false;
817
+ if (
818
+ cut?.round &&
819
+ !cut.clip &&
820
+ !parent.isWindow &&
821
+ parent.clipsChildren?.() &&
822
+ typeof parent.style?.borderRadius === 'number' &&
823
+ parent.style.borderRadius > 0 &&
824
+ !containsRect(insetRect(parent.abs, parent.style.borderRadius), bounds)
825
+ ) {
826
+ const border = borderReach(parent);
827
+ cut.clip = insetRect(parent.abs, border);
828
+ cut.radius = Math.max(0, parent.style.borderRadius - border);
829
+ roundCut = true;
830
+ bounds = intersectRects(bounds, cut.clip);
831
+ if (!bounds) {
832
+ cut.bounds = null;
833
+ return true;
621
834
  }
622
- cut.clip = cut.clip ? intersectRects(cut.clip, inner) : inner;
623
- bounds = cut.clip && intersectRects(bounds, cut.clip);
624
- if (!bounds) return false;
625
835
  }
626
836
  const order = parent.paintOrder();
627
837
  for (let j = order.indexOf(n) + 1; j < order.length; j++) {
@@ -642,7 +852,7 @@ export class CocoaPromotion {
642
852
  if (!containsRect(insetRect(parent.abs, inside), bounds))
643
853
  return false;
644
854
  }
645
- if (parent.clipsChildren?.()) {
855
+ if (parent.clipsChildren?.() && !roundCut) {
646
856
  const radius = parent.style?.borderRadius;
647
857
  const clip =
648
858
  typeof radius === 'number' && radius > 0
@@ -653,9 +863,12 @@ export class CocoaPromotion {
653
863
  }
654
864
  if (typeof parent._scrollbars === 'function') {
655
865
  for (const bar of parent._scrollbars()) {
656
- if (rectsOverlap(scrollbarStrip(bar, this.scale), bounds)) {
657
- return false;
658
- }
866
+ if (!rectsOverlap(scrollbarInk(bar, this.scale), bounds)) continue;
867
+ // a thumb drawn over a node's layer goes on a layer of its own
868
+ // above it, where it can (`_thumbsLiftable`); an element's part
869
+ // keeps clear of it
870
+ if (!cut?.thumbs || !this._thumbsLiftable(parent)) return false;
871
+ cut.thumbs.add(parent);
659
872
  }
660
873
  }
661
874
  }
@@ -682,12 +895,143 @@ export class CocoaPromotion {
682
895
  return false;
683
896
  }
684
897
 
898
+ // --- a pane's thumbs above the rows they are drawn over ----------------------
899
+
900
+ /**
901
+ * Can `pane`'s thumbs go on a layer above everything inside it? The bars
902
+ * are painted after the pane's children, so a layer of theirs stands over
903
+ * the layers of the rows in the pane as the thumb stands over the rows —
904
+ * where nothing painted after the pane reaches anywhere the thumb can go,
905
+ * nothing above it fades, and every clip above it holds the track whole:
906
+ * `_clear` asked of the pane at its tracks. A pane that fades has its
907
+ * bars in its group.
908
+ */
909
+ _thumbsLiftable(pane) {
910
+ if (fadesAsGroup(pane)) return false;
911
+ for (const bar of pane._scrollbars()) {
912
+ if (!this._clear(pane, scrollbarTrack(bar, this.scale))) return false;
913
+ }
914
+ return true;
915
+ }
916
+
917
+ /** `pane`'s thumbs on layers of their own at `z` among the layers above
918
+ * the bitmap, which stops painting them (`Scrollable._paintScrollbars`):
919
+ * a colour and a radius, as core paints one, where the thumb is now. */
920
+ _syncThumbs(pane, z, root) {
921
+ let layers = this.thumbs.get(pane);
922
+ if (!layers) {
923
+ layers = new Map();
924
+ this.thumbs.set(pane, layers);
925
+ pane._thumbsLifted = true;
926
+ // the bitmap under it, repainted without it
927
+ for (const bar of pane._scrollbars()) {
928
+ this._claim(root, scrollbarInk(bar, this.scale));
929
+ }
930
+ }
931
+ const s = this.scale;
932
+ const backgroundColor = this.app._parseColor(
933
+ String(pane.props.scrollbarColor || THUMB_COLOUR),
934
+ ) ?? [0, 0, 0, 0.25];
935
+ const seen = new Set();
936
+ for (const bar of pane._scrollbars()) {
937
+ seen.add(bar.axis);
938
+ let entry = layers.get(bar.axis);
939
+ if (!entry) {
940
+ entry = { layer: this.native.createLayer(), props: {} };
941
+ this.native.addSublayer(this.rootVisual.layer, entry.layer);
942
+ layers.set(bar.axis, entry);
943
+ }
944
+ const next = {
945
+ frame: [bar.x / s, bar.y / s, bar.width / s, bar.height / s],
946
+ backgroundColor,
947
+ cornerRadius: 3,
948
+ zPosition: z,
949
+ };
950
+ const out = {};
951
+ let any = false;
952
+ for (const key of Object.keys(next)) {
953
+ const was = entry.props[key];
954
+ const value = next[key];
955
+ const same = Array.isArray(value)
956
+ ? Array.isArray(was) && value.every((v, i) => v === was[i])
957
+ : was === value;
958
+ if (!same) {
959
+ out[key] = value;
960
+ any = true;
961
+ }
962
+ }
963
+ if (any) {
964
+ this.native.setLayerProps(entry.layer, out);
965
+ entry.props = next;
966
+ }
967
+ }
968
+ for (const [axis, entry] of layers) {
969
+ if (seen.has(axis)) continue;
970
+ this.native.removeFromSuperlayer(entry.layer);
971
+ layers.delete(axis);
972
+ }
973
+ }
974
+
975
+ /** `pane`'s thumbs back into the bitmap, which paints them again where
976
+ * they are now. */
977
+ _dropThumbs(pane, root) {
978
+ const layers = this.thumbs.get(pane);
979
+ if (!layers) return;
980
+ this.thumbs.delete(pane);
981
+ for (const entry of layers.values()) {
982
+ this.native.removeFromSuperlayer(entry.layer);
983
+ }
984
+ pane._thumbsLifted = false;
985
+ if (pane.destroyed) return;
986
+ for (const bar of pane._scrollbars()) {
987
+ this._claim(root, scrollbarInk(bar, this.scale));
988
+ }
989
+ }
990
+
685
991
  // --- what the layer shows ----------------------------------------------------
686
992
 
687
993
  _sync(node, order, root, layoutRan) {
688
994
  const p = this.promoted.get(node);
689
- p.visual.set(propBoxProps(node, this.app, this.scale, ORIGIN, order));
690
- // after the model value went out, inside the same transaction
995
+ this._place(p);
996
+ let origin = ORIGIN;
997
+ let z = order;
998
+ if (p.box) {
999
+ // the box stands at the node's place among the layers — or inside
1000
+ // the box of the square clips around it, which does — and the node's
1001
+ // layer is placed from the box's corner inside it
1002
+ const s = this.scale;
1003
+ const { rect: clip, radius, outer } = p.clip;
1004
+ let base = ORIGIN;
1005
+ let boxZ = order;
1006
+ if (p.outerBox) {
1007
+ this._setBox(p.outerBox, p, 'outerProps', {
1008
+ frame: [outer.x / s, outer.y / s, outer.width / s, outer.height / s],
1009
+ masksToBounds: true,
1010
+ zPosition: order,
1011
+ });
1012
+ base = outer;
1013
+ boxZ = 0;
1014
+ }
1015
+ this._setBox(p.box, p, 'boxProps', {
1016
+ frame: [
1017
+ (clip.x - base.x) / s,
1018
+ (clip.y - base.y) / s,
1019
+ clip.width / s,
1020
+ clip.height / s,
1021
+ ],
1022
+ masksToBounds: true,
1023
+ // a circle's arc, as wide as the box allows, where the clip is
1024
+ // rounded
1025
+ cornerRadius: Math.min(radius, clip.width / 2, clip.height / 2) / s,
1026
+ zPosition: boxZ,
1027
+ });
1028
+ origin = clip;
1029
+ z = 0;
1030
+ }
1031
+ p.visual.set(propBoxProps(node, this.app, this.scale, origin, z));
1032
+ // after the model value went out, inside the same transaction: what
1033
+ // moves the node, as the layout has its parent now, then what waits
1034
+ this.animations.follow(node, p.visual.layer);
691
1035
  this.animations.apply(node, p.visual.layer);
692
1036
  this._syncContent(node, p, root, layoutRan);
693
1037
  }
@@ -802,6 +1146,13 @@ export class CocoaPromotion {
802
1146
  /** Everything off the root layer and freed: the window is going. */
803
1147
  destroy() {
804
1148
  this.sprites.destroy();
1149
+ for (const [pane, layers] of this.thumbs) {
1150
+ for (const entry of layers.values()) {
1151
+ this.native.removeFromSuperlayer(entry.layer);
1152
+ }
1153
+ pane._thumbsLifted = false;
1154
+ }
1155
+ this.thumbs.clear();
805
1156
  for (const timer of this.idle.values()) clearTimeout(timer);
806
1157
  this.idle.clear();
807
1158
  this.releasing.clear();
@@ -810,6 +1161,7 @@ export class CocoaPromotion {
810
1161
  this.animations.drop(node, false);
811
1162
  this._dropContent(p);
812
1163
  p.visual.destroy();
1164
+ if (p.box) this.native.removeFromSuperlayer(p.outerBox ?? p.box);
813
1165
  }
814
1166
  this.promoted.clear();
815
1167
  this.candidates.clear();