react-x11 2.35.0 → 2.37.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
@@ -207,9 +220,23 @@ function promotableNode(node) {
207
220
  return !paintsOutline(node);
208
221
  }
209
222
 
210
- /** The strip a scrollbar's track occupies, with the pad the thumb's
211
- * antialiasing needs. Mirrors the layer presenter's bar strip. */
212
- 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) {
213
240
  const pad = Math.ceil(2 * scale);
214
241
  return bar.axis === 'x'
215
242
  ? {
@@ -226,6 +253,38 @@ function scrollbarStrip(bar, scale) {
226
253
  };
227
254
  }
228
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
+
229
288
  /**
230
289
  * The surface window's promoted nodes: which ones have a layer, what each
231
290
  * layer shows, and the animations the render server runs on them.
@@ -263,6 +322,11 @@ export class CocoaPromotion {
263
322
  // the parts of elements' drawing they offer (`Node.sprites()`), each on
264
323
  // a layer of its own under the same rules (src/cocoa/sprites.js)
265
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;
266
330
  this._claiming = false;
267
331
  }
268
332
 
@@ -417,12 +481,14 @@ export class CocoaPromotion {
417
481
  this.promoted.size === 0 &&
418
482
  this.candidates.size === 0 &&
419
483
  offers.size === 0 &&
420
- this.sprites.hosts.size === 0
484
+ this.sprites.hosts.size === 0 &&
485
+ this.thumbs.size === 0
421
486
  ) {
422
487
  return;
423
488
  }
424
489
  const native = this.native;
425
490
  let lifted = null;
491
+ const wanted = (this._thumbsWanted = new Set());
426
492
  native.txBegin({ disableActions: true });
427
493
  try {
428
494
  // Later-painted first: a node painted after this one that is coming
@@ -439,8 +505,11 @@ export class CocoaPromotion {
439
505
  for (let i = candidates.length - 1; i >= 0; i--) {
440
506
  const { node, key } = candidates[i];
441
507
  if (this.promoted.has(node) || !this.animations.has(node)) continue;
442
- if (key && promotableNode(node) && this._clear(node)) {
443
- 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));
444
513
  } else {
445
514
  this.denied.set(node, this.layoutGen);
446
515
  this.animations.drop(node, true);
@@ -450,18 +519,29 @@ export class CocoaPromotion {
450
519
  // Then the parts, against the layers this frame keeps: a node
451
520
  // promoted over a part is a layer above it, not ink on it.
452
521
  lifted = this.sprites.decide(root, offers);
453
- // 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.
454
528
  const order = this._inPaintOrder([...this.promoted.keys()]);
455
529
  for (const state of this.sprites.entries()) {
456
530
  order.push({ state, key: state.order });
457
531
  }
532
+ for (const pane of wanted) {
533
+ const key = this._paintKey(pane);
534
+ if (key) order.push({ pane, key: [...key, AFTER_CHILDREN] });
535
+ }
458
536
  order.sort(byPaintKey);
459
537
  for (let i = 0; i < order.length; i++) {
460
- const { node, state } = order[i];
538
+ const { node, state, pane } = order[i];
461
539
  if (node) this._sync(node, i, root, layoutRan);
540
+ else if (pane) this._syncThumbs(pane, i, root);
462
541
  else this.sprites.sync(state, i);
463
542
  }
464
543
  } finally {
544
+ this._thumbsWanted = null;
465
545
  native.txCommit();
466
546
  }
467
547
  // An element told which of its parts are on layers now may claim,
@@ -472,17 +552,55 @@ export class CocoaPromotion {
472
552
  _mayStay(node, key) {
473
553
  if (!key || !promotableNode(node)) return false;
474
554
  if (this.releasing.has(node)) return false; // its grace ran out
475
- 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;
476
561
  }
477
562
 
478
- _promote(node, root) {
479
- const visual = new Visual(this, node);
480
- visual.attach(this.rootVisual);
481
- this.promoted.set(node, {
482
- 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),
483
591
  content: null,
484
592
  dirty: { all: true, rects: [] },
485
- });
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);
486
604
  node._promoted = true;
487
605
  // the bitmap under it repaints without it, from this frame on — and so
488
606
  // does the raster of a promoted node it is in, which the claim that
@@ -505,12 +623,64 @@ export class CocoaPromotion {
505
623
  this.animations.drop(node, reclaim && !node.destroyed);
506
624
  this._dropContent(p);
507
625
  p.visual.destroy();
626
+ if (p.box) this.native.removeFromSuperlayer(p.outerBox ?? p.box);
508
627
  if (!node.destroyed && node.abs) {
509
628
  this._repaintHolder(node);
510
629
  this._claim(root, node.paintBounds());
511
630
  }
512
631
  }
513
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
+
514
684
  /**
515
685
  * A node coming off its layer inside a promoted node: the bitmap holds a
516
686
  * hole there, and the node's pixels belong in that node's raster — so the
@@ -597,8 +767,9 @@ export class CocoaPromotion {
597
767
  * it, and the parent must not fade: its group is drawn on the bitmap or on
598
768
  * its own layer, and either way a layer of the node's would be outside it.
599
769
  *
600
- * With `cut`, the reach is an element's part (src/cocoa/sprites.js),
601
- * 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
602
773
  * corners narrows `cut.clip` to its padding box — its border is drawn
603
774
  * over what it holds anyway — instead of having to hold the part whole,
604
775
  * and every test from there up is asked of what shows through the clip.
@@ -638,6 +809,30 @@ export class CocoaPromotion {
638
809
  return true;
639
810
  }
640
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;
834
+ }
835
+ }
641
836
  const order = parent.paintOrder();
642
837
  for (let j = order.indexOf(n) + 1; j < order.length; j++) {
643
838
  if (this._reaches(order[j], bounds)) return false;
@@ -657,7 +852,7 @@ export class CocoaPromotion {
657
852
  if (!containsRect(insetRect(parent.abs, inside), bounds))
658
853
  return false;
659
854
  }
660
- if (parent.clipsChildren?.()) {
855
+ if (parent.clipsChildren?.() && !roundCut) {
661
856
  const radius = parent.style?.borderRadius;
662
857
  const clip =
663
858
  typeof radius === 'number' && radius > 0
@@ -668,9 +863,12 @@ export class CocoaPromotion {
668
863
  }
669
864
  if (typeof parent._scrollbars === 'function') {
670
865
  for (const bar of parent._scrollbars()) {
671
- if (rectsOverlap(scrollbarStrip(bar, this.scale), bounds)) {
672
- return false;
673
- }
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);
674
872
  }
675
873
  }
676
874
  }
@@ -697,12 +895,143 @@ export class CocoaPromotion {
697
895
  return false;
698
896
  }
699
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
+
700
991
  // --- what the layer shows ----------------------------------------------------
701
992
 
702
993
  _sync(node, order, root, layoutRan) {
703
994
  const p = this.promoted.get(node);
704
- p.visual.set(propBoxProps(node, this.app, this.scale, ORIGIN, order));
705
- // 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);
706
1035
  this.animations.apply(node, p.visual.layer);
707
1036
  this._syncContent(node, p, root, layoutRan);
708
1037
  }
@@ -817,6 +1146,13 @@ export class CocoaPromotion {
817
1146
  /** Everything off the root layer and freed: the window is going. */
818
1147
  destroy() {
819
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();
820
1156
  for (const timer of this.idle.values()) clearTimeout(timer);
821
1157
  this.idle.clear();
822
1158
  this.releasing.clear();
@@ -825,6 +1161,7 @@ export class CocoaPromotion {
825
1161
  this.animations.drop(node, false);
826
1162
  this._dropContent(p);
827
1163
  p.visual.destroy();
1164
+ if (p.box) this.native.removeFromSuperlayer(p.outerBox ?? p.box);
828
1165
  }
829
1166
  this.promoted.clear();
830
1167
  this.candidates.clear();
@@ -48,7 +48,7 @@
48
48
  import { RASTER_PAD, RasterState } from './presenter.js';
49
49
  import { CocoaPlayer, PlayerLift } from './player.js';
50
50
  import { VideoLift, canLiftVideo } from './video.js';
51
- import { intersectRects, rectContains } from '../nodes/rects.js';
51
+ import { intersectRects, rectContains, rectsOverlap } from '../nodes/rects.js';
52
52
  import { DEV } from '../nodes/util.js';
53
53
  import { isVideoFrames } from '../videoframes.js';
54
54
 
@@ -497,6 +497,7 @@ export class SpriteLayers {
497
497
  state.extent = shown;
498
498
  now.set(sprite.key, state);
499
499
  }
500
+ if (place) this._underDrawn(host, list, now, was, root);
500
501
  if (was) {
501
502
  for (const [key, state] of was) {
502
503
  if (!now.has(key)) this._drop(state, root);
@@ -512,6 +513,44 @@ export class SpriteLayers {
512
513
  return changed;
513
514
  }
514
515
 
516
+ /**
517
+ * An element lists its parts in the order it paints them, and its layers
518
+ * stand in that order above the bitmap. A part this frame turned down is
519
+ * the element's to draw, in the bitmap, under every layer, so a part
520
+ * listed before it — painted under it — may not stay on a layer over it
521
+ * where the two meet: the later part's pixels would be under the earlier
522
+ * one's. An element may then offer a part that a later one of its own
523
+ * overlaps, on the word that the later one is lifted too, and this keeps
524
+ * that word. Walked from the end of the list, so a part given back here
525
+ * is under the ones before it in turn. One inside another's layer goes
526
+ * with that layer, and is not asked.
527
+ */
528
+ _underDrawn(host, list, now, was, root) {
529
+ const drawn = []; // where later parts are in the bitmap
530
+ const giveBack = (state) => {
531
+ for (const kid of state.kids) giveBack(kid);
532
+ now.delete(state.key);
533
+ // one lifted in an earlier frame goes with the rest of those
534
+ if (was?.get(state.key) !== state) this._drop(state, root);
535
+ };
536
+ for (let index = list.length - 1; index >= 0; index--) {
537
+ const sprite = list[index];
538
+ if (!isSprite(sprite) || sprite.parent !== undefined) continue;
539
+ const state = now.get(sprite.key);
540
+ if (state?.sprite === sprite) {
541
+ if (!state.extent) continue; // it shows nothing to be over
542
+ if (!drawn.some((rect) => rectsOverlap(rect, state.extent))) continue;
543
+ giveBack(state);
544
+ drawn.push(state.extent);
545
+ continue;
546
+ }
547
+ // the element draws it, where its own clip lets it show
548
+ const extent = extentOf(sprite);
549
+ const shows = sprite.clip ? intersectRects(extent, sprite.clip) : extent;
550
+ if (shows) drawn.push(shows);
551
+ }
552
+ }
553
+
515
554
  /** Every lifted part above the bitmap, with its place in the paint
516
555
  * order: not one inside another's layer, which goes with it (`sync`). */
517
556
  entries() {
@@ -256,6 +256,10 @@ export class CocoaWindow {
256
256
  this._promotion.animate(node, prop, entry);
257
257
  this.cancelNodeAnimation = (node, prop) =>
258
258
  this._promotion.cancel(node, prop);
259
+ // …and whether a loop the clock runs, turned down or given back by
260
+ // an earlier frame, would be taken now (nodes/animation.js
261
+ // `_offerLoopsAgain`)
262
+ this.wouldAnimateNode = (node) => this._promotion.wouldTake(node);
259
263
  this.noteInvalidate = (damage, layoutChanged) =>
260
264
  this._promotion.noteInvalidate(damage, layoutChanged);
261
265
  this.prepareFrame = (root, layoutRan) =>
@@ -27,9 +27,9 @@ const CROSSING_MS = 1100;
27
27
  * [styling.md](../../docs/styling.md#loops)), not a timer here: it runs on
28
28
  * the window's own frame clock, claims the block as its damage every frame
29
29
  * instead of invalidating the window, and stops itself when the window is
30
- * unmapped, minimized or buried. A `setInterval` calling `setState` would
31
- * do none of those three, and would re-render this component sixty times a
32
- * second to move a rectangle.
30
+ * unmapped, minimized or buried, or the bar is scrolled out of sight. A
31
+ * `setInterval` calling `setState` would do none of those, and would
32
+ * re-render this component sixty times a second to move a rectangle.
33
33
  *
34
34
  * The travel is in **percentages of the track**, so nothing here measures
35
35
  * anything: the bar is right at whatever width its container gives it and
@@ -88,6 +88,10 @@ export function ProgressBar({
88
88
  start: moving ? `-${BLOCK}` : '30%',
89
89
  borderRadius: height / 2,
90
90
  backgroundColor: color ?? theme.accent,
91
+ // Nothing to press: the bar is the track. Said so, a presenter
92
+ // that runs the slide as the block's position (macOS) may, since
93
+ // input would otherwise be hit where the layout left the block.
94
+ pointerEvents: 'none',
91
95
  ...(moving && {
92
96
  animation: { start: { to: '100%', duration: CROSSING_MS } },
93
97
  }),