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.
- package/package.json +1 -1
- package/src/appearance.js +48 -15
- package/src/appearancehooks.js +1 -1
- package/src/backend/context2d.js +94 -3
- package/src/cocoa/presenter.js +148 -13
- package/src/cocoa/promotion.js +370 -33
- package/src/cocoa/sprites.js +40 -1
- 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/testing/harness.js +148 -9
- package/src/testing/index.d.ts +46 -1
- package/src/testing/index.js +1 -0
- package/src/types/appearance.d.ts +2 -2
- package/src/types/system.d.ts +6 -0
package/package.json
CHANGED
package/src/appearance.js
CHANGED
|
@@ -135,12 +135,22 @@ const SAME = (a, b) =>
|
|
|
135
135
|
* and a re-render per announcement would be churn with nothing behind it.
|
|
136
136
|
*/
|
|
137
137
|
function publish(values, source) {
|
|
138
|
+
// **A pin is a pin.** A rung can answer long after it was asked — the
|
|
139
|
+
// ladder a test started before releasing its pin, a watcher's next line, a
|
|
140
|
+
// portal signal — and its answer lands in whatever test is running by
|
|
141
|
+
// then. Taken, that turns the next test's pixels into this machine's
|
|
142
|
+
// desktop, which is the one thing the pin exists to prevent.
|
|
143
|
+
if (owner === 'test' && source !== 'test') return;
|
|
138
144
|
const next = Object.freeze({ ...NOTHING, ...values, source });
|
|
139
145
|
if (SAME(next, snapshot)) return;
|
|
140
146
|
snapshot = next;
|
|
141
|
-
// What came *off* the disk does
|
|
142
|
-
// value must never reach a developer's
|
|
143
|
-
|
|
147
|
+
// Only what a rung answered is remembered. What came *off* the disk does
|
|
148
|
+
// not go back onto it, a pinned test value must never reach a developer's
|
|
149
|
+
// real cache file — and nor must the defaults a released pin falls back
|
|
150
|
+
// to (`source: null`), which every `cleanup()` publishes: written, they
|
|
151
|
+
// replaced the developer's remembered desktop after every test, and their
|
|
152
|
+
// next app drew its first frame in the wrong colours.
|
|
153
|
+
if (source && source !== 'cache' && source !== 'test') save(next);
|
|
144
154
|
for (const fn of [...watchers]) {
|
|
145
155
|
try {
|
|
146
156
|
fn(snapshot);
|
|
@@ -162,27 +172,42 @@ function publish(values, source) {
|
|
|
162
172
|
* Pass `null` to release the pin and let the ladder run again.
|
|
163
173
|
*/
|
|
164
174
|
export function setAppearanceForTests(values) {
|
|
165
|
-
probe = null;
|
|
166
|
-
// Never touch the developer's own remembered answer either, in either
|
|
167
|
-
// direction: `publish` will not write a pinned value, and marking the cache
|
|
168
|
-
// as already checked keeps `load()` from reading one back over it.
|
|
169
|
-
cacheChecked = true;
|
|
170
175
|
if (values === null) {
|
|
171
|
-
cacheChecked = false;
|
|
172
176
|
// Releasing undoes a *pin*, and only a pin. A real rung's answer is not
|
|
173
177
|
// this function's to throw away: `cleanup()` releases after every test,
|
|
174
178
|
// and a suite that resolved the appearance for real would otherwise find
|
|
175
|
-
// it wiped by the harness that was meant to leave it alone
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
179
|
+
// it wiped by the harness that was meant to leave it alone — or, with
|
|
180
|
+
// the cache marked unread, replaced on the next read by what the disk
|
|
181
|
+
// says, which is a verified answer turned back into a remembered one.
|
|
182
|
+
if (owner !== 'test') return;
|
|
183
|
+
owner = null;
|
|
184
|
+
probe = null;
|
|
185
|
+
// Unpinned, the remembered answer is this machine's to read again —
|
|
186
|
+
// intact, because neither the pin nor this release is ever written
|
|
187
|
+
// (`publish`).
|
|
188
|
+
cacheChecked = false;
|
|
189
|
+
publish({}, null);
|
|
180
190
|
return;
|
|
181
191
|
}
|
|
192
|
+
probe = null;
|
|
193
|
+
// Never touch the developer's own remembered answer either, in either
|
|
194
|
+
// direction: `publish` will not write a pinned value, and marking the cache
|
|
195
|
+
// as already checked keeps `load()` from reading one back over it.
|
|
196
|
+
cacheChecked = true;
|
|
182
197
|
owner = 'test';
|
|
183
198
|
publish(values, 'test');
|
|
184
199
|
}
|
|
185
200
|
|
|
201
|
+
/**
|
|
202
|
+
* What is pinned right now, or null where nothing is. `react-x11/test`'s
|
|
203
|
+
* `setAppearance` merges over it, so changing one value keeps the rest of the
|
|
204
|
+
* pin — and asks the store rather than keeping a copy, which a direct
|
|
205
|
+
* `setAppearanceForTests` call would leave stale.
|
|
206
|
+
*/
|
|
207
|
+
export function pinnedAppearanceForTests() {
|
|
208
|
+
return owner === 'test' ? snapshot : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
186
211
|
// --------------------------------------------------------------------------
|
|
187
212
|
// The last known answer
|
|
188
213
|
// --------------------------------------------------------------------------
|
|
@@ -929,6 +954,11 @@ async function runWatcher() {
|
|
|
929
954
|
answered = true;
|
|
930
955
|
publish(values, 'macos');
|
|
931
956
|
done(true);
|
|
957
|
+
// Refused, because a test pinned the store while this was starting
|
|
958
|
+
// (`publish`): nothing will hear this watcher again, and while it
|
|
959
|
+
// lives the rung starts no other — so the ladder that runs once the
|
|
960
|
+
// pin is released would find the Mac silent.
|
|
961
|
+
if (owner === 'test') proc.kill();
|
|
932
962
|
}
|
|
933
963
|
};
|
|
934
964
|
proc.stdout.setEncoding?.('utf8');
|
|
@@ -1020,7 +1050,10 @@ async function runLadder(app) {
|
|
|
1020
1050
|
answered = false;
|
|
1021
1051
|
}
|
|
1022
1052
|
if (answered) {
|
|
1023
|
-
|
|
1053
|
+
// A pin set while this rung was being asked keeps the store: the
|
|
1054
|
+
// rung's answer was refused (`publish`), and owning it would hand the
|
|
1055
|
+
// rung's live updates a store they must not write to.
|
|
1056
|
+
if (owner !== 'test') owner = name;
|
|
1024
1057
|
return snapshot;
|
|
1025
1058
|
}
|
|
1026
1059
|
}
|
package/src/appearancehooks.js
CHANGED
|
@@ -73,7 +73,7 @@ export function useAppearanceWhen(enabled) {
|
|
|
73
73
|
* | `selection` | the fill under a selected menu or list row — a darker cut of the accent on macOS — or **null** |
|
|
74
74
|
* | `contrast` | `'normal'` or `'high'` |
|
|
75
75
|
* | `reducedMotion` | `true` when the user asked for less animation |
|
|
76
|
-
* | `source` | `'portal'`, `'xsettings'`, `'macos'`, `'cache'`, or null |
|
|
76
|
+
* | `source` | `'portal'`, `'xsettings'`, `'macos'`, `'windows'`, `'cache'`, `'test'` (pinned by `react-x11/test`), or null |
|
|
77
77
|
*
|
|
78
78
|
* **`'no-preference'` means use your own default**, not "use light". And
|
|
79
79
|
* `accent` is null far more often than not, so fall back to your own brand
|
package/src/backend/context2d.js
CHANGED
|
@@ -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
|
|
1936
|
-
|
|
1937
|
-
|
|
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) {
|
package/src/cocoa/presenter.js
CHANGED
|
@@ -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 {
|
|
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 (
|
|
552
|
-
|
|
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)
|
|
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
|
-
|
|
604
|
-
|
|
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
|
-
|
|
611
|
-
|
|
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
|
|