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.
- package/package.json +2 -2
- package/src/backend/context2d.js +94 -3
- package/src/cocoa/presenter.js +148 -13
- package/src/cocoa/promotion.js +391 -39
- package/src/cocoa/sprites.js +168 -19
- package/src/cocoa/window.js +4 -0
- package/src/components/ProgressBar.js +7 -3
- package/src/nodes/animation.js +171 -12
- package/src/nodes/scrollable.js +3 -0
- package/src/nodes/window/flush.js +9 -0
- package/src/types/system.d.ts +6 -0
package/src/cocoa/sprites.js
CHANGED
|
@@ -29,6 +29,13 @@
|
|
|
29
29
|
// ask for: on an older one nothing is asked and every part is drawn by its
|
|
30
30
|
// element.
|
|
31
31
|
//
|
|
32
|
+
// A part none of which shows — scrolled out of its pane, or outside its own
|
|
33
|
+
// clip — keeps its layer, cut to show nothing, up to a budget of raster for
|
|
34
|
+
// such parts: its element's clock would otherwise run for it, and it is
|
|
35
|
+
// already where its animation has it the frame a scroll brings it back. A
|
|
36
|
+
// clip with round corners that a square ancestor cuts again is a box of its
|
|
37
|
+
// own inside a box of the square one's.
|
|
38
|
+
//
|
|
32
39
|
// A part may be inside another of its element's (`parent`): a spinner in a
|
|
33
40
|
// card that fades. Its layer is lifted inside the parent's, so it fades,
|
|
34
41
|
// turns and is cut with it, and the parent's raster leaves it out — the
|
|
@@ -41,7 +48,7 @@
|
|
|
41
48
|
import { RASTER_PAD, RasterState } from './presenter.js';
|
|
42
49
|
import { CocoaPlayer, PlayerLift } from './player.js';
|
|
43
50
|
import { VideoLift, canLiftVideo } from './video.js';
|
|
44
|
-
import { intersectRects, rectContains } from '../nodes/rects.js';
|
|
51
|
+
import { intersectRects, rectContains, rectsOverlap } from '../nodes/rects.js';
|
|
45
52
|
import { DEV } from '../nodes/util.js';
|
|
46
53
|
import { isVideoFrames } from '../videoframes.js';
|
|
47
54
|
|
|
@@ -50,6 +57,11 @@ import { isVideoFrames } from '../videoframes.js';
|
|
|
50
57
|
// upload for every new version.
|
|
51
58
|
const MAX_SIDE = 8192;
|
|
52
59
|
|
|
60
|
+
// The raster a window keeps for parts that show nothing — scrolled out of
|
|
61
|
+
// their pane, or outside their own clip — in device pixels: 32 MB. Past it
|
|
62
|
+
// such a part is its element's, which runs it on its own clock.
|
|
63
|
+
const HIDDEN_BUDGET = 8 * 1024 * 1024;
|
|
64
|
+
|
|
53
65
|
// Sorted after the element's children, which its own drawing comes after
|
|
54
66
|
// (`Node.paint` paints the children first), and before its next sibling.
|
|
55
67
|
const AFTER_CHILDREN = Number.MAX_SAFE_INTEGER;
|
|
@@ -61,6 +73,13 @@ const ORIGIN = Object.freeze({ x: 0, y: 0 });
|
|
|
61
73
|
// what a part with no part lifted inside it is handed
|
|
62
74
|
const NO_KIDS = Object.freeze(new Set());
|
|
63
75
|
|
|
76
|
+
// keyframes already found good, by the property they were found good for
|
|
77
|
+
const CHECKED = new WeakMap();
|
|
78
|
+
|
|
79
|
+
// where a part can be about its origin, by its one transform animation's
|
|
80
|
+
// keyframes, and what that was worked out for (`extentOf`)
|
|
81
|
+
const EXTENTS = new WeakMap();
|
|
82
|
+
|
|
64
83
|
// what a part's raster holds before its first paint
|
|
65
84
|
const UNPAINTED = Symbol('unpainted');
|
|
66
85
|
|
|
@@ -99,8 +118,13 @@ function isAnimation(a) {
|
|
|
99
118
|
if (!finite(a.duration) || !(a.duration > 0)) return false;
|
|
100
119
|
const values = a.values;
|
|
101
120
|
if (!Array.isArray(values) || values.length < 2) return false;
|
|
102
|
-
|
|
103
|
-
|
|
121
|
+
// an element hands the same keyframes over from frame to frame: they
|
|
122
|
+
// are read once
|
|
123
|
+
if (CHECKED.get(values) !== a.property) {
|
|
124
|
+
const value = a.property === 'opacity' ? finite : isMatrix;
|
|
125
|
+
if (!values.every(value)) return false;
|
|
126
|
+
CHECKED.set(values, a.property);
|
|
127
|
+
}
|
|
104
128
|
if (a.keyTimes !== undefined) {
|
|
105
129
|
const t = a.keyTimes;
|
|
106
130
|
if (!Array.isArray(t) || t.length !== values.length || t[0] !== 0) {
|
|
@@ -227,11 +251,38 @@ function unionRect(a, b) {
|
|
|
227
251
|
function extentOf(sprite) {
|
|
228
252
|
const reach = sprite.reach ?? sprite.rect;
|
|
229
253
|
const origin = originOf(sprite);
|
|
230
|
-
|
|
254
|
+
const m0 = sprite.transform ?? IDENTITY;
|
|
255
|
+
// About its origin, where it can be is the same for a part that only
|
|
256
|
+
// moved: kept by its keyframes, which an element hands over from frame
|
|
257
|
+
// to frame, rather than every keyframe mapped every frame.
|
|
258
|
+
const rel = {
|
|
259
|
+
x: reach.x - origin.x,
|
|
260
|
+
y: reach.y - origin.y,
|
|
261
|
+
width: reach.width,
|
|
262
|
+
height: reach.height,
|
|
263
|
+
};
|
|
264
|
+
let turns = null;
|
|
231
265
|
for (const a of sprite.animations ?? []) {
|
|
232
266
|
if (a.property !== 'transform') continue;
|
|
233
|
-
|
|
267
|
+
turns = turns === null ? a.values : undefined;
|
|
268
|
+
}
|
|
269
|
+
const key = `${rel.x},${rel.y},${rel.width},${rel.height},${m0.join(',')}`;
|
|
270
|
+
let about = turns ? EXTENTS.get(turns) : null;
|
|
271
|
+
if (about?.key !== key) {
|
|
272
|
+
let box = mapRect(rel, m0, ORIGIN);
|
|
273
|
+
for (const a of sprite.animations ?? []) {
|
|
274
|
+
if (a.property !== 'transform') continue;
|
|
275
|
+
for (const m of a.values) box = unionRect(box, mapRect(rel, m, ORIGIN));
|
|
276
|
+
}
|
|
277
|
+
about = { key, box };
|
|
278
|
+
if (turns) EXTENTS.set(turns, about);
|
|
234
279
|
}
|
|
280
|
+
const box = {
|
|
281
|
+
x: about.box.x + origin.x,
|
|
282
|
+
y: about.box.y + origin.y,
|
|
283
|
+
width: about.box.width,
|
|
284
|
+
height: about.box.height,
|
|
285
|
+
};
|
|
235
286
|
const x = Math.floor(box.x) - RASTER_PAD;
|
|
236
287
|
const y = Math.floor(box.y) - RASTER_PAD;
|
|
237
288
|
return {
|
|
@@ -332,6 +383,8 @@ export class SpriteLayers {
|
|
|
332
383
|
this.hosts.delete(host);
|
|
333
384
|
if (!host.destroyed) changed.add(host);
|
|
334
385
|
}
|
|
386
|
+
// the raster kept for parts that show nothing, this frame
|
|
387
|
+
let hidden = 0;
|
|
335
388
|
for (const [host, list] of offers) {
|
|
336
389
|
const was = this.hosts.get(host);
|
|
337
390
|
const now = new Map();
|
|
@@ -378,17 +431,31 @@ export class SpriteLayers {
|
|
|
378
431
|
};
|
|
379
432
|
const extent = extentOf(sprite);
|
|
380
433
|
const bounds = cut.clip ? intersectRects(extent, cut.clip) : extent;
|
|
381
|
-
if (!bounds) continue;
|
|
382
434
|
if (parent) {
|
|
383
435
|
// the parent was asked about everywhere it can be, and its layer
|
|
384
436
|
// carries this one: nothing more to ask where it stays inside the
|
|
385
437
|
// parent's raster, and its element draws it where it does not
|
|
386
|
-
if (!rectContains(rasterRectOf(parent.sprite), bounds))
|
|
438
|
+
if (bounds && !rectContains(rasterRectOf(parent.sprite), bounds)) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
387
441
|
cut.bounds = bounds;
|
|
442
|
+
} else if (!bounds) {
|
|
443
|
+
// its own clip shows nothing of it
|
|
444
|
+
cut.bounds = null;
|
|
388
445
|
} else if (!this.promotion._clear(host, bounds, cut)) {
|
|
389
446
|
continue;
|
|
390
447
|
}
|
|
391
448
|
const shown = cut.bounds;
|
|
449
|
+
// A part that shows nothing stays on its layer, cut to show nothing,
|
|
450
|
+
// so that its element's clock is not kept running for it, and is
|
|
451
|
+
// where it should be the frame a scroll brings it into view — but
|
|
452
|
+
// for a source, and past the budget of raster kept for such parts.
|
|
453
|
+
if (!shown) {
|
|
454
|
+
if (contents !== null) continue;
|
|
455
|
+
const px = raster.width * raster.height;
|
|
456
|
+
if (hidden + px > HIDDEN_BUDGET) continue;
|
|
457
|
+
hidden += px;
|
|
458
|
+
}
|
|
392
459
|
let state = was?.get(sprite.key);
|
|
393
460
|
// a clip that comes or goes is a layer in a box or out of one: lifted
|
|
394
461
|
// again, rather than a running layer moved between parents — and so
|
|
@@ -397,6 +464,7 @@ export class SpriteLayers {
|
|
|
397
464
|
if (
|
|
398
465
|
state &&
|
|
399
466
|
(Boolean(state.clip) !== Boolean(cut.clip) ||
|
|
467
|
+
Boolean(state.outer) !== Boolean(cut.outer) ||
|
|
400
468
|
state.contents !== contents ||
|
|
401
469
|
state.parent !== parent)
|
|
402
470
|
) {
|
|
@@ -411,10 +479,12 @@ export class SpriteLayers {
|
|
|
411
479
|
cut.clip !== null,
|
|
412
480
|
contents,
|
|
413
481
|
parent,
|
|
482
|
+
Boolean(cut.outer),
|
|
414
483
|
);
|
|
415
484
|
}
|
|
416
485
|
state.sprite = sprite;
|
|
417
486
|
state.clip = cut.clip;
|
|
487
|
+
state.outer = cut.outer ?? null;
|
|
418
488
|
state.radius = cut.radius;
|
|
419
489
|
state.order = parent ? null : [...place, AFTER_CHILDREN, index];
|
|
420
490
|
state.kids = [];
|
|
@@ -423,10 +493,11 @@ export class SpriteLayers {
|
|
|
423
493
|
// frame on; where it went since, it is the element's own claims
|
|
424
494
|
// that move the hole. A child is under its parent's layer, whose
|
|
425
495
|
// raster leaves it out instead (`sync`).
|
|
426
|
-
if (lifting && !parent) this.promotion._claim(root, shown);
|
|
496
|
+
if (lifting && !parent && shown) this.promotion._claim(root, shown);
|
|
427
497
|
state.extent = shown;
|
|
428
498
|
now.set(sprite.key, state);
|
|
429
499
|
}
|
|
500
|
+
if (place) this._underDrawn(host, list, now, was, root);
|
|
430
501
|
if (was) {
|
|
431
502
|
for (const [key, state] of was) {
|
|
432
503
|
if (!now.has(key)) this._drop(state, root);
|
|
@@ -442,6 +513,44 @@ export class SpriteLayers {
|
|
|
442
513
|
return changed;
|
|
443
514
|
}
|
|
444
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
|
+
|
|
445
554
|
/** Every lifted part above the bitmap, with its place in the paint
|
|
446
555
|
* order: not one inside another's layer, which goes with it (`sync`). */
|
|
447
556
|
entries() {
|
|
@@ -473,16 +582,25 @@ export class SpriteLayers {
|
|
|
473
582
|
return isVideoFrames(contents) && this.videos;
|
|
474
583
|
}
|
|
475
584
|
|
|
476
|
-
_lift(host, key, clipped, contents = null, parent = null) {
|
|
585
|
+
_lift(host, key, clipped, contents = null, parent = null, outered = false) {
|
|
477
586
|
const layer = this.native.createLayer();
|
|
478
587
|
// a part cut to a clip is in a box of the clip's size that masks to it,
|
|
479
588
|
// and the box is what stands among the layers above the bitmap — or
|
|
480
|
-
// among its parent's children, for a part inside another's layer
|
|
589
|
+
// among its parent's children, for a part inside another's layer. A
|
|
590
|
+
// clip with round corners that a square one cuts again is in a box of
|
|
591
|
+
// its own inside a box of the square one's (`outer`).
|
|
481
592
|
const above = parent ? parent.layer : this.promotion.rootVisual.layer;
|
|
482
593
|
let box = null;
|
|
594
|
+
let outerBox = null;
|
|
483
595
|
if (clipped) {
|
|
484
596
|
box = this.native.createLayer();
|
|
485
|
-
|
|
597
|
+
if (outered) {
|
|
598
|
+
outerBox = this.native.createLayer();
|
|
599
|
+
this.native.addSublayer(above, outerBox);
|
|
600
|
+
this.native.addSublayer(outerBox, box);
|
|
601
|
+
} else {
|
|
602
|
+
this.native.addSublayer(above, box);
|
|
603
|
+
}
|
|
486
604
|
this.native.addSublayer(box, layer);
|
|
487
605
|
} else {
|
|
488
606
|
this.native.addSublayer(above, layer);
|
|
@@ -492,6 +610,9 @@ export class SpriteLayers {
|
|
|
492
610
|
key,
|
|
493
611
|
layer,
|
|
494
612
|
box,
|
|
613
|
+
outerBox,
|
|
614
|
+
outerProps: {},
|
|
615
|
+
outer: null, // the square clips around a rounded one, device pixels
|
|
495
616
|
// the part whose layer this one is in, and the parts in this one's
|
|
496
617
|
parent,
|
|
497
618
|
kids: [],
|
|
@@ -528,7 +649,9 @@ export class SpriteLayers {
|
|
|
528
649
|
* element drawing it again. */
|
|
529
650
|
_drop(state, root) {
|
|
530
651
|
state.lift?.release();
|
|
531
|
-
this.native.removeFromSuperlayer(
|
|
652
|
+
this.native.removeFromSuperlayer(
|
|
653
|
+
state.outerBox ?? state.box ?? state.layer,
|
|
654
|
+
);
|
|
532
655
|
state.raster.release(this.native);
|
|
533
656
|
for (const run of state.runs.values()) this._ends().delete(run.caId);
|
|
534
657
|
state.runs.clear();
|
|
@@ -571,16 +694,40 @@ export class SpriteLayers {
|
|
|
571
694
|
// where its layer's coordinates start: the window's corner, or the
|
|
572
695
|
// corner of its parent's raster, for a part inside another's layer
|
|
573
696
|
const base = state.parent?.rect ?? ORIGIN;
|
|
697
|
+
// in a box of the square clips around its own, where a square one cuts
|
|
698
|
+
// a rounded clip again: placed from the window's corner, at the part's
|
|
699
|
+
// place among the layers
|
|
700
|
+
const outer = state.outer;
|
|
701
|
+
if (state.outerBox) {
|
|
702
|
+
const frame = [
|
|
703
|
+
(outer.x - base.x) / s,
|
|
704
|
+
(outer.y - base.y) / s,
|
|
705
|
+
outer.width / s,
|
|
706
|
+
outer.height / s,
|
|
707
|
+
];
|
|
708
|
+
const was = state.outerProps;
|
|
709
|
+
if (!sameMatrix(was.frame, frame) || was.zPosition !== z) {
|
|
710
|
+
this.native.setLayerProps(state.outerBox, {
|
|
711
|
+
frame,
|
|
712
|
+
masksToBounds: true,
|
|
713
|
+
zPosition: z,
|
|
714
|
+
});
|
|
715
|
+
state.outerProps = { frame, zPosition: z };
|
|
716
|
+
}
|
|
717
|
+
}
|
|
574
718
|
// in its box, where it is cut to a clip: placed from the clip's corner,
|
|
575
|
-
// and the box at the part's place among the layers
|
|
719
|
+
// and the box at the part's place among the layers, or from the outer
|
|
720
|
+
// box's corner inside it
|
|
576
721
|
const clip = state.clip;
|
|
577
722
|
if (state.box) {
|
|
723
|
+
const from = state.outerBox ? outer : base;
|
|
578
724
|
const frame = [
|
|
579
|
-
(clip.x -
|
|
580
|
-
(clip.y -
|
|
725
|
+
(clip.x - from.x) / s,
|
|
726
|
+
(clip.y - from.y) / s,
|
|
581
727
|
clip.width / s,
|
|
582
728
|
clip.height / s,
|
|
583
729
|
];
|
|
730
|
+
const boxZ = state.outerBox ? 0 : z;
|
|
584
731
|
// its corners rounded where the clip's are: a circle's arc, as wide
|
|
585
732
|
// as the box allows
|
|
586
733
|
const cornerRadius =
|
|
@@ -588,16 +735,16 @@ export class SpriteLayers {
|
|
|
588
735
|
const was = state.boxProps;
|
|
589
736
|
if (
|
|
590
737
|
!sameMatrix(was.frame, frame) ||
|
|
591
|
-
was.zPosition !==
|
|
738
|
+
was.zPosition !== boxZ ||
|
|
592
739
|
was.cornerRadius !== cornerRadius
|
|
593
740
|
) {
|
|
594
741
|
this.native.setLayerProps(state.box, {
|
|
595
742
|
frame,
|
|
596
743
|
masksToBounds: true,
|
|
597
744
|
cornerRadius,
|
|
598
|
-
zPosition:
|
|
745
|
+
zPosition: boxZ,
|
|
599
746
|
});
|
|
600
|
-
state.boxProps = { frame, zPosition:
|
|
747
|
+
state.boxProps = { frame, zPosition: boxZ, cornerRadius };
|
|
601
748
|
}
|
|
602
749
|
}
|
|
603
750
|
const ox = state.box ? clip.x : base.x;
|
|
@@ -749,7 +896,9 @@ export class SpriteLayers {
|
|
|
749
896
|
for (const lifted of this.hosts.values()) {
|
|
750
897
|
for (const state of lifted.values()) {
|
|
751
898
|
state.lift?.release();
|
|
752
|
-
this.native.removeFromSuperlayer(
|
|
899
|
+
this.native.removeFromSuperlayer(
|
|
900
|
+
state.outerBox ?? state.box ?? state.layer,
|
|
901
|
+
);
|
|
753
902
|
state.raster.release(this.native);
|
|
754
903
|
for (const run of state.runs.values()) this._ends().delete(run.caId);
|
|
755
904
|
}
|
package/src/cocoa/window.js
CHANGED
|
@@ -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
|
|
31
|
-
* do none of those
|
|
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
|
}),
|
package/src/nodes/animation.js
CHANGED
|
@@ -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
|
|
303
|
-
* the top
|
|
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.
|
|
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
|
|
387
|
-
// one declared at mount runs from `_setRoot`, before
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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);
|
package/src/nodes/scrollable.js
CHANGED
|
@@ -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,
|
package/src/types/system.d.ts
CHANGED
|
@@ -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
|