react-x11 2.35.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.35.0",
3
+ "version": "2.36.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -194,6 +194,19 @@ class LinearGradient {
194
194
 
195
195
  const clamp01 = (v) => Math.min(1, Math.max(0, Number(v) || 0));
196
196
 
197
+ // What `textAlign`, `textBaseline` and `direction` take. A value outside
198
+ // them is ignored and the last one stays, as canvas has it.
199
+ const TEXT_ALIGNS = new Set(['start', 'end', 'left', 'right', 'center']);
200
+ const TEXT_BASELINES = new Set([
201
+ 'top',
202
+ 'hanging',
203
+ 'middle',
204
+ 'alphabetic',
205
+ 'ideographic',
206
+ 'bottom',
207
+ ]);
208
+ const DIRECTIONS = new Set(['ltr', 'rtl', 'inherit']);
209
+
197
210
  /**
198
211
  * The Render ops text draws with, numbered as XRender numbers them so a
199
212
  * caller's `ctx.Render?.PictOp?.Over ?? 3` reads the same on both
@@ -428,6 +441,9 @@ export class BackendContext2D {
428
441
  dash: [],
429
442
  dashOffset: 0,
430
443
  font: '10px sans-serif',
444
+ textAlign: 'start',
445
+ textBaseline: 'alphabetic',
446
+ direction: 'inherit',
431
447
  shadowBlur: 0,
432
448
  shadowOffsetX: 0,
433
449
  shadowOffsetY: 0,
@@ -654,6 +670,37 @@ export class BackendContext2D {
654
670
  this._state.font = String(value);
655
671
  }
656
672
 
673
+ /** Where `fillText`'s x is on the text: its start, end, left, right or
674
+ * centre. */
675
+ get textAlign() {
676
+ return this._state.textAlign;
677
+ }
678
+
679
+ set textAlign(value) {
680
+ if (TEXT_ALIGNS.has(value)) this._state.textAlign = value;
681
+ }
682
+
683
+ /** Where `fillText`'s y is on the text: its alphabetic baseline, or the
684
+ * top, middle or bottom of its em box, or its hanging or ideographic
685
+ * baseline. */
686
+ get textBaseline() {
687
+ return this._state.textBaseline;
688
+ }
689
+
690
+ set textBaseline(value) {
691
+ if (TEXT_BASELINES.has(value)) this._state.textBaseline = value;
692
+ }
693
+
694
+ /** Which way `start` and `end` face. `inherit`, with no element to take
695
+ * it from, is the text's own: right to left where it starts so. */
696
+ get direction() {
697
+ return this._state.direction;
698
+ }
699
+
700
+ set direction(value) {
701
+ if (DIRECTIONS.has(value)) this._state.direction = value;
702
+ }
703
+
657
704
  get shadowBlur() {
658
705
  return this._state.shadowBlur;
659
706
  }
@@ -1932,9 +1979,53 @@ export class BackendContext2D {
1932
1979
  fillText(text, x, y) {
1933
1980
  const layout = this._fontLayout(text);
1934
1981
  if (!layout) return;
1935
- // canvas fillText's y is the baseline; layouts draw from their top
1936
- const baseline = layout.lines[0]?.baseline ?? 0;
1937
- this._drawLayout(layout, x, y - baseline);
1982
+ // canvas fillText's y is on the baseline `textBaseline` names, and x on
1983
+ // the side `textAlign` does; a layout draws from its top left
1984
+ const line = layout.lines[0];
1985
+ const baseline = line?.baseline ?? 0;
1986
+ this._drawLayout(
1987
+ layout,
1988
+ x + this._alignOffset(layout, line),
1989
+ y - baseline + this._baselineOffset(line),
1990
+ );
1991
+ }
1992
+
1993
+ /** How far left of x the text starts: ntk's `_alignOffset`, so a label
1994
+ * centred on X11 is centred here too. */
1995
+ _alignOffset(layout, line) {
1996
+ let align = this._state.textAlign;
1997
+ if (align === 'start' || align === 'end') {
1998
+ const direction = this._state.direction;
1999
+ const rtl =
2000
+ direction === 'rtl' ||
2001
+ (direction === 'inherit' &&
2002
+ !!line?.runs?.find((r) => r.start === 0)?.rtl);
2003
+ align = (align === 'start') !== rtl ? 'left' : 'right';
2004
+ }
2005
+ const width = layout.width ?? 0;
2006
+ if (align === 'center') return -width / 2;
2007
+ if (align === 'right') return -width;
2008
+ return 0;
2009
+ }
2010
+
2011
+ /** How far below y the alphabetic baseline is: ntk's `_baselineOffset`,
2012
+ * from the face's ascent and descent. */
2013
+ _baselineOffset(line) {
2014
+ const ascent = line?.ascent ?? 0;
2015
+ const descent = line?.descent ?? 0;
2016
+ switch (this._state.textBaseline) {
2017
+ case 'top':
2018
+ return ascent;
2019
+ case 'hanging':
2020
+ return ascent * 0.8;
2021
+ case 'middle':
2022
+ return (ascent - descent) / 2;
2023
+ case 'bottom':
2024
+ case 'ideographic':
2025
+ return -descent;
2026
+ default:
2027
+ return 0;
2028
+ }
1938
2029
  }
1939
2030
 
1940
2031
  _fontLayout(text, color) {
@@ -29,7 +29,12 @@ import { cssColorStraight } from 'ntk/color';
29
29
  import { Node } from '../nodes/node.js';
30
30
  import { addDamageRect, damageToPaint } from '../nodes/damage.js';
31
31
  import { intersectRects } from '../nodes/rects.js';
32
- import { EASING_CONTROL_POINTS, TRANSITION_CONTROL_POINTS } from '../styles.js';
32
+ import { now as animationNow } from '../nodes/animation.js';
33
+ import {
34
+ EASING_CONTROL_POINTS,
35
+ TRANSITION_CONTROL_POINTS,
36
+ resolveBorderWidths,
37
+ } from '../styles.js';
33
38
  import { BackendContext2D } from '../backend/context2d.js';
34
39
 
35
40
  export const RASTER_PAD = 2; // antialiasing/italic overhang outside the ink bounds
@@ -361,6 +366,33 @@ const ANIMATED_KEY_PATHS = Object.freeze({
361
366
  opacity: { keyPath: 'opacity' },
362
367
  });
363
368
 
369
+ /**
370
+ * An inset of a node out of the flow moves that node and nothing else — no
371
+ * sibling, not its parent — so a loop on one is its layer's position: an
372
+ * offset added to where the layout put the node, which is at the inset's
373
+ * declared value while the render server runs the loop (the style is the
374
+ * model). Only for a node no pointer lands on (`pointerEvents: 'none'`),
375
+ * since input is hit against the layout's rect, which holds still: an
376
+ * indeterminate bar's block, a highlight that sweeps along a track. Which
377
+ * axis, and which way a larger value moves the layer; `start` and `end` are
378
+ * mirrored under RTL.
379
+ */
380
+ const INSET_KEY_PATHS = Object.freeze({
381
+ left: { keyPath: 'position.x', axis: 'x', sign: 1 },
382
+ right: { keyPath: 'position.x', axis: 'x', sign: -1 },
383
+ start: { keyPath: 'position.x', axis: 'x', sign: 1, logical: true },
384
+ end: { keyPath: 'position.x', axis: 'x', sign: -1, logical: true },
385
+ top: { keyPath: 'position.y', axis: 'y', sign: 1 },
386
+ bottom: { keyPath: 'position.y', axis: 'y', sign: -1 },
387
+ });
388
+
389
+ /** Does a loop on `prop` move its node, where a presenter runs it
390
+ * (`INSET_KEY_PATHS`)? */
391
+ export const movesNode = (prop) => Object.hasOwn(INSET_KEY_PATHS, prop);
392
+
393
+ // a length or a share of the room: `12`, `'-40%'`
394
+ const PERCENT = /^\s*(-?(?:\d+\.?\d*|\.\d+))%\s*$/;
395
+
364
396
  // the ids the bridge reports an animation's end under: unique per process,
365
397
  // looked up per app (`_animationEnds`)
366
398
  let animationSeq = 0;
@@ -544,12 +576,16 @@ export class LayerAnimations {
544
576
  * is in its bitmap, not on its layer.
545
577
  */
546
578
  take(node, prop, entry) {
547
- const map = ANIMATED_KEY_PATHS[prop];
579
+ const map = ANIMATED_KEY_PATHS[prop] ?? INSET_KEY_PATHS[prop];
548
580
  if (!map || !stylePaintsPlain(node, node._targetStyle ?? node.style)) {
549
581
  return false;
550
582
  }
551
- if (this._value(map, entry.from) == null) return false;
552
- if (this._value(map, entry.to) == null) return false;
583
+ if (map.axis) {
584
+ if (!this._insetOffsets(node, prop, entry)) return false;
585
+ } else {
586
+ if (this._value(map, entry.from) == null) return false;
587
+ if (this._value(map, entry.to) == null) return false;
588
+ }
553
589
  let pending = this.pending.get(node);
554
590
  if (!pending) this.pending.set(node, (pending = new Map()));
555
591
  pending.set(prop, entry);
@@ -559,7 +595,12 @@ export class LayerAnimations {
559
595
  /** Stop what runs for `prop` on `node`: a loop the window lost sight of,
560
596
  * a declaration that changed, a transition the clock takes back. */
561
597
  cancel(node, prop) {
562
- this.pending.get(node)?.delete(prop);
598
+ const pending = this.pending.get(node);
599
+ // …and nothing left waiting is no entry at all: an empty map still
600
+ // answers `has`, and a node with nothing to run went onto a layer it
601
+ // never gave back — a loop that went out of sight between its offer
602
+ // and its frame did
603
+ if (pending?.delete(prop) && pending.size === 0) this.pending.delete(node);
563
604
  this._removeLive(node, prop);
564
605
  }
565
606
 
@@ -585,6 +626,78 @@ export class LayerAnimations {
585
626
  : null;
586
627
  }
587
628
 
629
+ /**
630
+ * A loop on an inset of `node` as its layer's offsets from where the
631
+ * layout puts it, `[from, to]` in points — or null where it cannot be
632
+ * one: not a loop, a node in the flow or one a pointer can land on, an
633
+ * inset with no declared value to rest at, or a value that is neither a
634
+ * length nor a percentage. A percentage is a share of the parent's
635
+ * padding box, which is what the layout places a node out of the flow
636
+ * in, so the offsets change when the parent's size does (`follow`).
637
+ */
638
+ _insetOffsets(node, prop, entry) {
639
+ const map = INSET_KEY_PATHS[prop];
640
+ const style = node._targetStyle ?? node.style ?? {};
641
+ if (!entry.loop || style.position !== 'absolute') return null;
642
+ if (style.pointerEvents !== 'none') return null;
643
+ const parent = node.parent;
644
+ if (!parent?.abs) return null;
645
+ const edges = resolveBorderWidths(parent.style ?? {}, parent.direction);
646
+ const room =
647
+ map.axis === 'x'
648
+ ? parent.abs.width - edges.left - edges.right
649
+ : parent.abs.height - edges.top - edges.bottom;
650
+ const length = (value) => {
651
+ if (typeof value === 'number')
652
+ return Number.isFinite(value) ? value : null;
653
+ const share = typeof value === 'string' ? PERCENT.exec(value) : null;
654
+ return share ? (Number(share[1]) / 100) * room : null;
655
+ };
656
+ const rest = length(style[prop]);
657
+ const from = length(entry.from);
658
+ const to = length(entry.to);
659
+ if (rest === null || from === null || to === null) return null;
660
+ const sign = map.logical && node.direction === 'rtl' ? -map.sign : map.sign;
661
+ return [
662
+ (sign * (from - rest)) / this.scale,
663
+ (sign * (to - rest)) / this.scale,
664
+ ];
665
+ }
666
+
667
+ /**
668
+ * The offsets of a loop moving `node` as the layout has them now: one
669
+ * whose parent changed size runs again where it has got to in its cycle
670
+ * (`headStart`), and one that cannot run there any more goes back to the
671
+ * clock. Before `apply`, which attaches what this hands back.
672
+ */
673
+ follow(node, layer) {
674
+ const live = this.live.get(node);
675
+ if (!live) return;
676
+ for (const [id, run] of [...live]) {
677
+ if (!run.offsets) continue;
678
+ const offsets = this._insetOffsets(node, run.prop, run.entry);
679
+ if (
680
+ offsets &&
681
+ offsets[0] === run.offsets[0] &&
682
+ offsets[1] === run.offsets[1]
683
+ ) {
684
+ continue;
685
+ }
686
+ live.delete(id);
687
+ this._ends().delete(id);
688
+ this.native.removeAnimation(layer, run.key);
689
+ if (!offsets) {
690
+ node._offloadEnded(run.prop, run.entry);
691
+ continue;
692
+ }
693
+ run.entry.headStart = animationNow() - run.entry.start;
694
+ let pending = this.pending.get(node);
695
+ if (!pending) this.pending.set(node, (pending = new Map()));
696
+ pending.set(run.prop, run.entry);
697
+ }
698
+ if (live.size === 0) this.live.delete(node);
699
+ }
700
+
588
701
  /** The frame's half: attach what `take` accepted to `layer`, after the
589
702
  * model value went out, inside the same transaction. */
590
703
  apply(node, layer) {
@@ -592,23 +705,43 @@ export class LayerAnimations {
592
705
  if (!pending) return;
593
706
  this.pending.delete(node);
594
707
  for (const [prop, entry] of pending) {
595
- const map = ANIMATED_KEY_PATHS[prop];
708
+ const map = ANIMATED_KEY_PATHS[prop] ?? INSET_KEY_PATHS[prop];
596
709
  const id = `rx${++animationSeq}`;
597
710
  const key = `${prop}:${id}`;
598
711
  const opts = { duration: entry.duration / 1000, id };
712
+ let offsets = null;
713
+ if (map.axis) {
714
+ // an inset: the layer's offset from where the layout put it, added
715
+ // to its position — as the layout has the parent now, which may not
716
+ // be what it was at the swap
717
+ offsets = this._insetOffsets(node, prop, entry);
718
+ if (!offsets) {
719
+ node._offloadDeclined(prop, entry);
720
+ continue;
721
+ }
722
+ }
599
723
  if (entry.loop) {
600
724
  // a loop replaces whatever ran for the property: its declaration
601
725
  // changed, and a loop restarts from the top when it does
602
726
  this._removeLive(node, prop);
603
- opts.from = this._value(map, entry.from);
604
- opts.to = this._value(map, entry.to);
727
+ if (offsets) {
728
+ [opts.from, opts.to] = offsets;
729
+ opts.additive = true;
730
+ } else {
731
+ opts.from = this._value(map, entry.from);
732
+ opts.to = this._value(map, entry.to);
733
+ }
605
734
  opts.timing = EASING_CONTROL_POINTS[entry.easing];
606
735
  opts.repeat = Infinity;
607
736
  opts.autoreverse = entry.alternate;
608
737
  // CA's two halves of one CSS delay: a begin time the layer waits
609
- // for showing `from`, or a time offset that starts it that far in
610
- if (entry.delay > 0) opts.delay = entry.delay / 1000;
611
- else if (entry.delay < 0) opts.timeOffset = -entry.delay / 1000;
738
+ // for showing `from`, or a time offset that starts it that far in.
739
+ // A loop the clock has been running comes over where it has got to
740
+ // (`headStart`, nodes/animation.js `_offerLoopsAgain`), which is
741
+ // the same delay that much shorter.
742
+ const wait = (entry.delay ?? 0) - (entry.headStart ?? 0);
743
+ if (wait > 0) opts.delay = wait / 1000;
744
+ else if (wait < 0) opts.timeOffset = -wait / 1000;
612
745
  } else if (map.colour) {
613
746
  // From where the pixels are — which is what "an interrupted
614
747
  // transition reverses from where it got to" means here. A colour
@@ -636,7 +769,7 @@ export class LayerAnimations {
636
769
  // looked up after the removals above, which may have pruned the map
637
770
  let live = this.live.get(node);
638
771
  if (!live) this.live.set(node, (live = new Map()));
639
- live.set(id, { prop, key, entry });
772
+ live.set(id, { prop, key, entry, offsets });
640
773
  this._ends().set(id, (ev) => this._animationEnded(node, id, ev));
641
774
  }
642
775
  }
@@ -983,7 +1116,9 @@ export class CocoaLayerPresenter {
983
1116
  visual.set(
984
1117
  propBoxProps(node, this.window.app, this.scale, parentOrigin, order),
985
1118
  );
986
- // after the model value went out, inside the same transaction
1119
+ // after the model value went out, inside the same transaction: what
1120
+ // moves the node, as the layout has its parent now, then what waits
1121
+ this.animations.follow(node, visual.layer);
987
1122
  this.animations.apply(node, visual.layer);
988
1123
  }
989
1124
 
@@ -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
  }),
@@ -12,6 +12,7 @@ import {
12
12
  interpolate,
13
13
  ease,
14
14
  isLayoutProp,
15
+ TEXT_LAYOUT_PROPS,
15
16
  } from '../styles.js';
16
17
  import { GRID_CONTAINER_PROPS, GRID_ITEM_PROPS } from '../grid.js';
17
18
  import { isPlaced } from '../layouts.js';
@@ -19,9 +20,19 @@ import { desktopSettings, watchDesktopSettings } from '../desktopsettings.js';
19
20
  import { watchWindowState, windowStateSnapshot } from '../windowstate.js';
20
21
  import { shadowExtentOf } from './boxpaint.js';
21
22
  import { inThemeWalk } from './cascade.js';
22
- import { insetRect } from './rects.js';
23
+ import { insetRect, intersectRects, outerPixels } from './rects.js';
23
24
  import { shallowEqual } from './util.js';
24
25
 
26
+ /** How often a loop the clock runs is offered to a presenter again, at
27
+ * most (`_recheckLoops`): a check is a walk up the node's ancestors and
28
+ * along what is painted after it, and the loop is a frame every refresh
29
+ * for as long as it waits. */
30
+ const REOFFER_MS = 250;
31
+
32
+ /** The loopable properties that reach past the node's box without moving
33
+ * anything: a ring that grows draws further out than the node did. */
34
+ const REACH_PROPS = new Set(['outlineWidth', 'outlineOffset']);
35
+
25
36
  /** Two values of a grid property that lay out the same: an inline
26
37
  * `gridTemplateAreas` array is a new array every render. */
27
38
  const sameGridValue = (a, b) =>
@@ -299,16 +310,53 @@ export class NodeAnimation {
299
310
  }
300
311
 
301
312
  /** The presenter could not run `entry` after all — the node turned into a
302
- * raster between the swap and the frame. The frame clock takes it from
303
- * the top; the property's declared start is where the pixels still are. */
313
+ * raster between the swap and the frame, or its layer went. A transition
314
+ * starts again from the top, since the property's declared start is
315
+ * where its pixels still are. A loop goes on where it is in its cycle:
316
+ * the render server ran it from `start` (`LayerAnimations.apply`), or it
317
+ * never left the clock, so a spinner given back does not jump. */
304
318
  _offloadDeclined(prop, entry) {
305
319
  if (this._anim?.get(prop) !== entry || this.destroyed) return;
306
320
  entry.offloaded = false;
307
- entry.start = now();
321
+ entry.headStart = undefined;
322
+ if (entry.loop) entry.value = animationValueAt(entry, now() - entry.start);
323
+ else entry.start = now();
308
324
  this.style = { ...this._targetStyle, ...this._animatedValues() };
309
325
  this.root?._startAnimating(this);
310
326
  }
311
327
 
328
+ /**
329
+ * Loops the frame clock runs that the presenter would take now: turned
330
+ * down by a frame, or given back, by a scene that has changed since — an
331
+ * ancestor done fading, a sibling gone, a thumb scrolled away. Nothing
332
+ * about a loop would ever ask again, so the window asks (`_recheckLoops`),
333
+ * and only where the presenter says a frame would take it
334
+ * (`wouldAnimateNode`), so a loop it would turn down again costs no swap.
335
+ * Handed over where it is in its cycle (`headStart`).
336
+ */
337
+ _offerLoopsAgain() {
338
+ const wnd = this.root?.window;
339
+ let asked = false;
340
+ let moved = false;
341
+ for (const [prop, a] of this._anim ?? []) {
342
+ if (!a.loop || a.offloaded) continue;
343
+ if (!asked) {
344
+ if (!wnd.wouldAnimateNode(this)) return;
345
+ asked = true;
346
+ }
347
+ a.headStart = now() - a.start;
348
+ if (this._offload(prop, a)) {
349
+ a.offloaded = true;
350
+ moved = true;
351
+ } else {
352
+ a.headStart = undefined;
353
+ }
354
+ }
355
+ if (!moved) return;
356
+ this.style = { ...this._targetStyle, ...this._animatedValues() };
357
+ this._scheduleAnimationFrames();
358
+ }
359
+
312
360
  /** Keep the frame clock running only for what the clock itself animates;
313
361
  * an offloaded-only node needs one frame — the one that sends the model
314
362
  * and the animation — and not a loop of them. */
@@ -383,13 +431,19 @@ export class NodeAnimation {
383
431
  // re-rendered — which is the frame after every state change in the
384
432
  // app.
385
433
  if (current?.loop && sameAnimation(current, spec)) {
386
- // A loop the clock started before the window had a presenter —
387
- // one declared at mount runs from `_setRoot`, before `realize` —
388
- // moves over the first time a presenter can take it. Its phase is
389
- // the render server's from here, which is what a restart costs.
390
- if (!current.offloaded && this._offload(spec.prop, current)) {
391
- current.offloaded = true;
392
- changed = true;
434
+ // A loop the clock runs moves over the first time a presenter
435
+ // takes it: one declared at mount runs from `_setRoot`, before
436
+ // `realize`, and one a frame turned down runs until the next swap
437
+ // offers it again (or `_offerLoopsAgain` does). It goes where it
438
+ // has got to in its cycle (`headStart`), so nothing jumps.
439
+ if (!current.offloaded) {
440
+ current.headStart = now() - current.start;
441
+ if (this._offload(spec.prop, current)) {
442
+ current.offloaded = true;
443
+ changed = true;
444
+ } else {
445
+ current.headStart = undefined;
446
+ }
393
447
  }
394
448
  continue;
395
449
  }
@@ -443,7 +497,76 @@ export class NodeAnimation {
443
497
  return false;
444
498
  }
445
499
  if (desktopSettings(root.app).animations === false) return false;
446
- return !this._hiddenInTree();
500
+ if (this._hiddenInTree()) return false;
501
+ // last, since it is the one that measures — and a hidden subtree's
502
+ // rects are wherever it was when it went
503
+ this._outOfSight = this._loopsOutOfSight();
504
+ return !this._outOfSight;
505
+ }
506
+
507
+ /**
508
+ * Whether nothing this node's loops draw can be seen: all of it is cut
509
+ * away by the boxes that clip it, or lies outside the window — a spinner
510
+ * in a row scrolled out of its list, a bar in a panel scrolled away.
511
+ * Judged against the arrangement the last layout pass left, and again
512
+ * after every pass that moved anything (`_recheckLoops`).
513
+ *
514
+ * Never from where a frame of the loop has the node *now*: a block that
515
+ * slides across a track is off it at the start of every crossing, and a
516
+ * loop stopped there would rest there, unseen, for good. What is judged is
517
+ * what holds every frame (`_loopReach`), and a loop whose frames can move
518
+ * what is around it is never judged out of sight at all.
519
+ */
520
+ _loopsOutOfSight() {
521
+ const window = this.root?.abs;
522
+ if (!this._placed || !window) return false;
523
+ const reach = this._loopReach();
524
+ if (!reach) return false;
525
+ let out = reach.rect;
526
+ for (let n = reach.from.parent; n && out; n = n.parent) {
527
+ if (n.isWindow) break;
528
+ if (n.clipsChildren()) out = intersectRects(out, outerPixels(n.abs));
529
+ }
530
+ return (
531
+ !out ||
532
+ !intersectRects(out, {
533
+ x: 0,
534
+ y: 0,
535
+ width: window.width,
536
+ height: window.height,
537
+ })
538
+ );
539
+ }
540
+
541
+ /**
542
+ * What every frame of this node's loops stays inside, as `{ rect, from }`
543
+ * — `from` is the node whose clipping ancestors cut `rect` — or null when
544
+ * nothing short of the window holds it.
545
+ *
546
+ * A loop on a colour, an opacity or a radius moves nothing and grows
547
+ * nothing, so it draws where the node and what it holds already reach.
548
+ * One that grows the node's outline, or moves or sizes a node out of the
549
+ * flow, can put it anywhere in the nearest box that clips it — and nowhere
550
+ * else, since a node out of the flow sizes nothing around it; with no such
551
+ * box, anywhere in the window. One that moves or sizes a node in the flow
552
+ * moves its siblings and can resize every box above it, the clipping ones
553
+ * among them, so no box holds it.
554
+ */
555
+ _loopReach() {
556
+ let inPlace = true;
557
+ for (const { prop } of this._loops ?? []) {
558
+ if (REACH_PROPS.has(prop)) {
559
+ inPlace = false;
560
+ } else if (isLayoutProp(prop) || TEXT_LAYOUT_PROPS.has(prop)) {
561
+ if (this.style?.position !== 'absolute') return null;
562
+ inPlace = false;
563
+ }
564
+ }
565
+ if (inPlace) return { rect: this.paintBounds(), from: this };
566
+ for (let n = this.parent; n && !n.isWindow; n = n.parent) {
567
+ if (n.clipsChildren()) return { rect: outerPixels(n.abs), from: n };
568
+ }
569
+ return null;
447
570
  }
448
571
 
449
572
  /** Whether anything between this node and its window has taken it off the
@@ -579,6 +702,42 @@ export class WindowAnimation {
579
702
  for (const node of [...this._loopNodes]) node._updateLoops();
580
703
  }
581
704
 
705
+ /**
706
+ * After the frame's layout and placements, before the presenter's word.
707
+ *
708
+ * Where a pass moved things (`moved`), a loop it put out of sight stops,
709
+ * and one it brought back runs again (`_loopsOutOfSight`) — from the top,
710
+ * as every stop here resumes, since nobody saw where it had got to. Only
711
+ * the loops whose answer changed are asked again, so a pass that moved
712
+ * nothing of theirs costs a walk up from each.
713
+ *
714
+ * And where a presenter can run a loop in the render server, a loop in
715
+ * sight that the clock runs is offered to it again (`_offerLoopsAgain`),
716
+ * each at most every `REOFFER_MS`: what turned it down can go without a
717
+ * layout pass — an ancestor's fade arriving is a paint — and the loop is
718
+ * a frame every refresh until it is taken.
719
+ */
720
+ _recheckLoops(moved) {
721
+ const offer = typeof this.window?.wouldAnimateNode === 'function';
722
+ if (!moved && !offer) return;
723
+ const t = offer ? now() : 0;
724
+ for (const node of [...this._loopNodes]) {
725
+ if (moved) {
726
+ const out = node._loopsOutOfSight();
727
+ if (out !== (node._outOfSight === true)) {
728
+ node._outOfSight = out;
729
+ node._updateLoops();
730
+ continue;
731
+ }
732
+ }
733
+ if (!offer || node._outOfSight === true || node.destroyed) continue;
734
+ if (!(t - (node._offeredAt ?? -Infinity) >= REOFFER_MS)) continue;
735
+ if (!node.root?._animating.has(node)) continue; // the clock runs none
736
+ node._offeredAt = t;
737
+ node._offerLoopsAgain();
738
+ }
739
+ }
740
+
582
741
  /** A node in this window started a transition. */
583
742
  _startAnimating(node) {
584
743
  this._animating.add(node);
@@ -950,6 +950,9 @@ export const Scrollable = (Base) =>
950
950
  * its own route, since it paints through `_paintRegion` and never
951
951
  * through `Node.paint`. */
952
952
  _paintScrollbars(ctx) {
953
+ // on layers of their own, over the layers of the rows they are drawn
954
+ // over (src/cocoa/promotion.js `_syncThumbs`)
955
+ if (this._thumbsLifted) return;
953
956
  for (const bar of this._scrollbars()) {
954
957
  paintScrollbarThumb(ctx, bar, this.props.scrollbarColor);
955
958
  }
@@ -286,9 +286,18 @@ export class WindowFlush {
286
286
  }
287
287
  // An animated placement asked for this frame and nothing laid out: its
288
288
  // pass runs on its own, against the arrangement the last one left
289
+ let placed = false;
289
290
  if (!layoutRan && this._placementsDue && this._placeNodes()) {
290
291
  this.needsPaint = true;
292
+ placed = true;
291
293
  }
294
+ // A loop this arrangement put out of sight stops, and one it brought
295
+ // back runs again; one the clock runs is offered to the presenter
296
+ // again (`_recheckLoops`). After the pass and the placements, so it is
297
+ // judged where things landed, and before the presenter's word, so a
298
+ // loop handed to it, or taken back, is in this frame. Nothing moves
299
+ // anything but these two passes, so sight is judged only after one.
300
+ if (this._loopNodes.size) this._recheckLoops(layoutRan || placed);
292
301
  // A presenter compositing part of the tree on layers of its own — the
293
302
  // surface presenter's promoted nodes (src/cocoa/promotion.js) — gets
294
303
  // its word in here: after layout, so it sees where everything landed,
@@ -247,6 +247,12 @@ export interface DesktopSettings {
247
247
  readonly doubleClickDistance: number;
248
248
  /** How far a press moves before it is a drag rather than a click. */
249
249
  readonly dragThreshold: number;
250
+ /** **False means hold still** — the desktop asked for less motion:
251
+ * `Gtk/EnableAnimations` off over XSETTINGS, reduce motion on macOS.
252
+ * Core's loops do not start then (docs/styling.md, "Loops"); an element
253
+ * that animates on a timer of its own holds still too, and draws its
254
+ * resting frame. True wherever the desktop said nothing. */
255
+ readonly animations: boolean;
250
256
  /** `'xsettings'`; `'macos'` on the Cocoa backend, where only `animations`
251
257
  * comes from the system (reduce motion) and the rest are the defaults; or
252
258
  * null where no settings daemon answered and these are the renderer's own