react-x11 2.11.0 → 2.13.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/README.md +278 -129
- package/package.json +12 -4
- package/src/Reconciler.js +19 -31
- package/src/a11y.js +2 -2
- package/src/anchor.js +7 -5
- package/src/appcontext.js +59 -30
- package/src/bootstrap.js +14 -0
- package/src/clientmessage.js +1 -1
- package/src/cocoa/app.js +303 -49
- package/src/cocoa/bezels.js +175 -30
- package/src/cocoa/dnd.js +27 -13
- package/src/cocoa/fonts.js +3 -3
- package/src/cocoa/glarea.js +24 -5
- package/src/cocoa/main.d.ts +8 -0
- package/src/cocoa/main.js +43 -0
- package/src/cocoa/overlay.js +159 -0
- package/src/cocoa/panehost.js +15 -5
- package/src/cocoa/presenter.js +13 -9
- package/src/cocoa/promotion.js +17 -7
- package/src/cocoa/relaunch.js +207 -0
- package/src/cocoa/threaded.js +246 -0
- package/src/cocoa/window.js +256 -42
- package/src/components/Select.js +2 -2
- package/src/components/anchor.js +3 -3
- package/src/components/native.js +12 -7
- package/src/components/theme.js +2 -2
- package/src/debug.js +1 -1
- package/src/decorations.js +1 -1
- package/src/editmenu.js +2 -2
- package/src/embedding.js +31 -0
- package/src/errors.js +46 -0
- package/src/events.js +78 -18
- package/src/foreignnodes.js +59 -5
- package/src/frames.js +2 -2
- package/src/glnodes.js +172 -41
- package/src/gloverlay.js +383 -0
- package/src/grid.js +1653 -0
- package/src/host.d.ts +230 -1
- package/src/host.js +11 -3
- package/src/imagesource.js +1 -1
- package/src/index.d.ts +34 -4
- package/src/index.js +9 -1
- package/src/layouts.js +721 -0
- package/src/node.d.ts +16 -3
- package/src/node.js +19 -21
- package/src/nodes/animation.js +644 -0
- package/src/nodes/box.js +21 -0
- package/src/nodes/boxpaint.js +473 -0
- package/src/nodes/canvas.js +269 -0
- package/src/nodes/cascade.js +600 -0
- package/src/nodes/damage.js +183 -0
- package/src/nodes/edithistory.js +124 -0
- package/src/nodes/editmenupopup.js +260 -0
- package/src/nodes/hittest.js +185 -0
- package/src/nodes/image.js +266 -0
- package/src/nodes/install.js +75 -0
- package/src/nodes/invalidate.js +465 -0
- package/src/nodes/kinds.js +31 -0
- package/src/nodes/layout.js +439 -0
- package/src/nodes/layouthost.js +949 -0
- package/src/nodes/node.js +868 -0
- package/src/nodes/paint.js +466 -0
- package/src/nodes/position.js +366 -0
- package/src/nodes/preedit.js +127 -0
- package/src/nodes/queries.js +330 -0
- package/src/nodes/rects.js +102 -0
- package/src/nodes/scrollable.js +891 -0
- package/src/nodes/scrollbars.js +138 -0
- package/src/nodes/scrollblit.js +1034 -0
- package/src/nodes/selectable.js +142 -0
- package/src/nodes/styling.js +225 -0
- package/src/nodes/text.js +649 -0
- package/src/nodes/textarea.js +391 -0
- package/src/nodes/textinput.js +1146 -0
- package/src/nodes/util.js +17 -0
- package/src/nodes/window/anchoring.js +161 -0
- package/src/nodes/window/capabilities.js +190 -0
- package/src/nodes/window/debugpaint.js +83 -0
- package/src/nodes/window/droptarget.js +145 -0
- package/src/nodes/window/floors.js +577 -0
- package/src/nodes/window/flush.js +369 -0
- package/src/nodes/window/hints.js +482 -0
- package/src/nodes/window/listeners.js +222 -0
- package/src/nodes/window/popup.js +71 -0
- package/src/nodes/window/size.js +591 -0
- package/src/nodes/window/window.js +954 -0
- package/src/palette.js +1 -1
- package/src/registry.js +7 -3
- package/src/styles.js +137 -15
- package/src/svgnodes.js +2 -1
- package/src/testing/harness.js +2 -2
- package/src/textselection.js +5 -3
- package/src/trace-registry.js +1 -1
- package/src/types/components.d.ts +38 -6
- package/src/types/elements.d.ts +26 -14
- package/src/types/nodes.d.ts +17 -2
- package/src/types/style.d.ts +94 -3
- package/src/windowstate.js +1 -1
- package/src/yoga.js +1 -1
- package/src/nodes.js +0 -13120
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
// Transitions and loops: a style change retargeted into an animation, ticked
|
|
2
|
+
// on the window's frame clock or offloaded to the presenter, and the clock
|
|
3
|
+
// itself, which tests replace (setAnimationClock).
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
animationValueAt,
|
|
7
|
+
animationsOf,
|
|
8
|
+
sameAnimation,
|
|
9
|
+
applyLayoutStyle,
|
|
10
|
+
inheritedTextChanged,
|
|
11
|
+
transitionFor,
|
|
12
|
+
interpolate,
|
|
13
|
+
ease,
|
|
14
|
+
isLayoutProp,
|
|
15
|
+
} from '../styles.js';
|
|
16
|
+
import { GRID_CONTAINER_PROPS, GRID_ITEM_PROPS } from '../grid.js';
|
|
17
|
+
import { isPlaced } from '../layouts.js';
|
|
18
|
+
import { desktopSettings, watchDesktopSettings } from '../desktopsettings.js';
|
|
19
|
+
import { watchWindowState, windowStateSnapshot } from '../windowstate.js';
|
|
20
|
+
import { shadowExtentOf } from './boxpaint.js';
|
|
21
|
+
import { inThemeWalk } from './cascade.js';
|
|
22
|
+
import { insetRect } from './rects.js';
|
|
23
|
+
import { shallowEqual } from './util.js';
|
|
24
|
+
|
|
25
|
+
/** Two values of a grid property that lay out the same: an inline
|
|
26
|
+
* `gridTemplateAreas` array is a new array every render. */
|
|
27
|
+
const sameGridValue = (a, b) =>
|
|
28
|
+
a === b ||
|
|
29
|
+
(Array.isArray(a) &&
|
|
30
|
+
Array.isArray(b) &&
|
|
31
|
+
a.length === b.length &&
|
|
32
|
+
a.every((row, i) => row === b[i]));
|
|
33
|
+
|
|
34
|
+
/** Whether what a grid reads off its own style moved — the properties yoga
|
|
35
|
+
* never sees, so nothing else would ask the layout again. */
|
|
36
|
+
const gridContainerMoved = (was, now) =>
|
|
37
|
+
GRID_CONTAINER_PROPS.some((prop) => !sameGridValue(was[prop], now[prop]));
|
|
38
|
+
|
|
39
|
+
/** …and off one of its children. */
|
|
40
|
+
const gridItemMoved = (was, now) =>
|
|
41
|
+
GRID_ITEM_PROPS.some((prop) => was[prop] !== now[prop]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The node whose bounds cover where an animating node will be next frame, or
|
|
45
|
+
* `null` when that cannot be known and the frame has to repaint everything.
|
|
46
|
+
*
|
|
47
|
+
* Three cases, and the middle one is the interesting one:
|
|
48
|
+
*
|
|
49
|
+
* - **paint-only** (a colour, an opacity): the node stays put, so its own
|
|
50
|
+
* bounds are the damage.
|
|
51
|
+
* - **a layout property on an out-of-flow node** (`position: absolute`, the
|
|
52
|
+
* arrangement a sliding thumb uses): the node moves, so its own bounds
|
|
53
|
+
* cover where it is going but not where it has been. Its *parent* covers
|
|
54
|
+
* both — an absolute child is laid out inside its parent and, being out of
|
|
55
|
+
* flow, moves nothing else when it shifts. This is what keeps a `Switch`
|
|
56
|
+
* from repainting the window on every frame of its 120ms slide.
|
|
57
|
+
* - **a layout property in flow**: a reflow can move any node in the tree,
|
|
58
|
+
* including ones that leave stale pixels outside every bound we could name
|
|
59
|
+
* here. Nothing to do but repaint in full.
|
|
60
|
+
*/
|
|
61
|
+
function damageForAnimation(node) {
|
|
62
|
+
let movesInLayout = false;
|
|
63
|
+
for (const prop of node._anim?.keys() ?? []) {
|
|
64
|
+
if (isLayoutProp(prop)) movesInLayout = true;
|
|
65
|
+
}
|
|
66
|
+
if (!movesInLayout) return node;
|
|
67
|
+
if (node.style?.position !== 'absolute') return null;
|
|
68
|
+
// A window parent bounds nothing useful — its own rect is the whole surface.
|
|
69
|
+
const parent = node.parent;
|
|
70
|
+
return parent && !parent.isWindow ? parent : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Frame timestamps for transitions. Indirected so tests can drive the clock
|
|
74
|
+
// instead of sleeping through real animations.
|
|
75
|
+
export let now = () => Date.now();
|
|
76
|
+
export function setAnimationClock(fn) {
|
|
77
|
+
now = fn;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Node's half of transitions and loops, installed onto `Node.prototype` by node.js. */
|
|
81
|
+
export class NodeAnimation {
|
|
82
|
+
/**
|
|
83
|
+
* Point the node at a new resolved style. Properties with a `transition`
|
|
84
|
+
* animate there from whatever is on screen right now — which is what makes
|
|
85
|
+
* an interrupted transition reverse from where it got to, rather than
|
|
86
|
+
* jumping to the end first. Everything else takes effect immediately.
|
|
87
|
+
*/
|
|
88
|
+
_retarget(target) {
|
|
89
|
+
const displayed = this.style;
|
|
90
|
+
this._targetStyle = target;
|
|
91
|
+
if (displayed === undefined || this.destroyed) {
|
|
92
|
+
this.style = target;
|
|
93
|
+
this._syncLoops(target);
|
|
94
|
+
if (this._anim?.size) {
|
|
95
|
+
this.style = { ...target, ...this._animatedValues() };
|
|
96
|
+
}
|
|
97
|
+
return this.style;
|
|
98
|
+
}
|
|
99
|
+
// Only for a node the user has seen (`_placed`): between construction
|
|
100
|
+
// and the first frame a style is re-resolved several times — attach
|
|
101
|
+
// merges the real theme over the detached resolution's desktop palette,
|
|
102
|
+
// queries settle — and animating any of those would travel from a value
|
|
103
|
+
// that was never on screen. An inserted element *appears* at its style;
|
|
104
|
+
// transitions start on later changes, which is CSS's rule too.
|
|
105
|
+
if (this._placed) {
|
|
106
|
+
for (const prop of Object.keys(target)) {
|
|
107
|
+
const to = target[prop];
|
|
108
|
+
const from = displayed[prop];
|
|
109
|
+
if (from === to || from === undefined) continue;
|
|
110
|
+
const duration = transitionFor(target, prop);
|
|
111
|
+
if (duration <= 0) continue;
|
|
112
|
+
if (interpolate(from, to, 0.5) === null) continue; // no midpoint: snap
|
|
113
|
+
const entry = {
|
|
114
|
+
from,
|
|
115
|
+
to,
|
|
116
|
+
duration,
|
|
117
|
+
// *now*, not the last frame's timestamp: between two user actions
|
|
118
|
+
// the window is idle and draws nothing, so the previous frame can
|
|
119
|
+
// be seconds old — and the first tick would then find the
|
|
120
|
+
// transition already over and jump straight to the end
|
|
121
|
+
start: now(),
|
|
122
|
+
};
|
|
123
|
+
const previous = this._anim?.get(prop);
|
|
124
|
+
(this._anim ??= new Map()).set(prop, entry);
|
|
125
|
+
// A presenter that can run it in the render server takes it here:
|
|
126
|
+
// the node's style then goes straight to the target — the layer's
|
|
127
|
+
// model value — and the one frame that sends it carries the
|
|
128
|
+
// animation with it (src/cocoa/presenter.js). Declined, or with no
|
|
129
|
+
// such presenter, the window's frame clock runs it as it always has.
|
|
130
|
+
if (this._offload(prop, entry)) {
|
|
131
|
+
entry.offloaded = true;
|
|
132
|
+
this.root?.invalidate(false, damageForAnimation(this), 'animation');
|
|
133
|
+
} else {
|
|
134
|
+
// …and one the presenter had must not keep running underneath the
|
|
135
|
+
// values the clock is about to write
|
|
136
|
+
if (previous?.offloaded) this._cancelOffload(prop, previous);
|
|
137
|
+
this.root?._startAnimating(this);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// After the transitions, before the style is assembled: a loop that just
|
|
142
|
+
// arrived contributes a value to this very swap, so the first frame the
|
|
143
|
+
// bar is on screen already has it where the animation says rather than
|
|
144
|
+
// where the resting style does.
|
|
145
|
+
this._syncLoops(target);
|
|
146
|
+
this.style = this._anim?.size
|
|
147
|
+
? { ...target, ...this._animatedValues() }
|
|
148
|
+
: target;
|
|
149
|
+
// Placing is done by the window after each layout pass, which finds the
|
|
150
|
+
// nodes through this registry. One that stops asking stays in it until
|
|
151
|
+
// that pass has put it back where layout has it — and `position` being a
|
|
152
|
+
// layout property, the change that stops it brings that pass along.
|
|
153
|
+
if (isPlaced(this.style)) this.root?._placedNodes?.add(this);
|
|
154
|
+
// A layout arrives, leaves or changes by the same funnel — a commit, a
|
|
155
|
+
// size or container query, a token — so this is where the children are
|
|
156
|
+
// handed to it or taken back. Before the node has a box (the constructor
|
|
157
|
+
// styles it first) there is nothing to hand them from.
|
|
158
|
+
// `display: 'grid'` is the same request under CSS's name for it.
|
|
159
|
+
if (
|
|
160
|
+
this.yoga &&
|
|
161
|
+
(displayed.layout !== this.style.layout ||
|
|
162
|
+
displayed.display !== this.style.display ||
|
|
163
|
+
(this._host !== null && this._host.scale !== this.scale) ||
|
|
164
|
+
((this.style.layout != null || this.style.display === 'grid') &&
|
|
165
|
+
displayed.overflow !== this.style.overflow))
|
|
166
|
+
) {
|
|
167
|
+
this._syncLayoutHost();
|
|
168
|
+
}
|
|
169
|
+
// A grid reads its tracks off the style, where yoga never sees them, so
|
|
170
|
+
// a change to one asks the algorithm again — and gives one that threw
|
|
171
|
+
// another go.
|
|
172
|
+
if (this.yoga && gridContainerMoved(displayed, this.style)) {
|
|
173
|
+
if (this._host !== null) this._hostChanged();
|
|
174
|
+
else if (this._layoutAbandoned !== null) {
|
|
175
|
+
this._layoutAbandoned = null;
|
|
176
|
+
this._syncLayoutHost();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// …and a child of one tells it when what the algorithm reads of it moved
|
|
180
|
+
const host = this.parent?._host;
|
|
181
|
+
if (host != null) {
|
|
182
|
+
if (
|
|
183
|
+
!shallowEqual(displayed.layoutItem, this.style.layoutItem) ||
|
|
184
|
+
gridItemMoved(displayed, this.style)
|
|
185
|
+
) {
|
|
186
|
+
this.parent._hostChanged();
|
|
187
|
+
}
|
|
188
|
+
if (
|
|
189
|
+
(displayed.position === 'absolute') !==
|
|
190
|
+
(this.style.position === 'absolute')
|
|
191
|
+
) {
|
|
192
|
+
this.parent._rehomeHostChild(this);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// The paint reach reads the style now in force — a shadow's spread, an
|
|
196
|
+
// outline's width — so it is dropped on every swap, here, before the
|
|
197
|
+
// old-extent claims below measure the new reach against the old one.
|
|
198
|
+
this._clearPaintBounds();
|
|
199
|
+
// `hitSlop` feeds the cached hit reach and `overflow` decides where its
|
|
200
|
+
// invalidation walks stop, so a swap that changes either clears here —
|
|
201
|
+
// the one funnel every style path goes through. Animation ticks never
|
|
202
|
+
// change them: neither interpolates, so both land on the target value
|
|
203
|
+
// in this very swap, before any tick runs.
|
|
204
|
+
if (
|
|
205
|
+
displayed.hitSlop !== this.style.hitSlop ||
|
|
206
|
+
displayed.overflow !== this.style.overflow
|
|
207
|
+
) {
|
|
208
|
+
this._clearHitBounds();
|
|
209
|
+
}
|
|
210
|
+
// …and a node that just stopped being a scroll container has an offset
|
|
211
|
+
// nothing will ever clamp again (see Scrollable._overflowChanged)
|
|
212
|
+
if (displayed.overflow !== this.style.overflow) {
|
|
213
|
+
this._overflowChanged?.(displayed.overflow);
|
|
214
|
+
}
|
|
215
|
+
// The same funnel is what keeps the text cascade honest: every route a
|
|
216
|
+
// new style arrives by — a commit, a `:hover`, a size query, a token —
|
|
217
|
+
// comes through here, so this is the one place that has to notice the
|
|
218
|
+
// ink or the face moving and push it into the subtree.
|
|
219
|
+
if (!inThemeWalk && inheritedTextChanged(this.style, displayed)) {
|
|
220
|
+
this._retextSubtree();
|
|
221
|
+
}
|
|
222
|
+
// …and the same funnel is the only place a `direction` can arrive by. The
|
|
223
|
+
// *layout* half of it went to yoga through `applyLayoutStyle`; this is
|
|
224
|
+
// everything else that reads a side.
|
|
225
|
+
if (!inThemeWalk && displayed.direction !== this.style.direction) {
|
|
226
|
+
this._redirectSubtree();
|
|
227
|
+
}
|
|
228
|
+
// `display: 'none'` hides a subtree as completely as React's own flag
|
|
229
|
+
// does, whether it arrived from a prop, a state block or a size query —
|
|
230
|
+
// so focus leaves it by the same rule (`_visibilityChanged`).
|
|
231
|
+
if ((displayed.display === 'none') !== (this.style.display === 'none')) {
|
|
232
|
+
this._visibilityChanged(this.style.display !== 'none');
|
|
233
|
+
}
|
|
234
|
+
// A shadow that just got smaller — or went away — has to claim where it
|
|
235
|
+
// *was*. Every claim downstream of here is bounded by `paintBounds()`,
|
|
236
|
+
// which is computed from the style now in force, so a node that drops a
|
|
237
|
+
// `:hover` shadow would repaint its own box and leave the shadow printed
|
|
238
|
+
// around it. This is the only place both extents exist at once.
|
|
239
|
+
if (displayed.boxShadow !== this.style.boxShadow) {
|
|
240
|
+
const shrank =
|
|
241
|
+
shadowExtentOf(displayed, this.scale) -
|
|
242
|
+
shadowExtentOf(this.style, this.scale);
|
|
243
|
+
if (shrank > 0) {
|
|
244
|
+
this.root?.invalidate(
|
|
245
|
+
false,
|
|
246
|
+
insetRect(this.paintBounds(), -shrank),
|
|
247
|
+
'shadow',
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// An outline that just got smaller — or went away — owes the same debt,
|
|
252
|
+
// and it is only ever owed here. Core's own ring rides `:focus-visible`,
|
|
253
|
+
// where `EventManager.focus` claims the region while the ring is still
|
|
254
|
+
// on; what arrives through a style swap is the `outlineWidth` escape
|
|
255
|
+
// hatch (see `_outline`) — an application outlining a node for a reason
|
|
256
|
+
// of its own, or a widget ringing one *part* of itself, the way
|
|
257
|
+
// `<Checkbox>` rings its checked well and drops the ring again on blur.
|
|
258
|
+
if (
|
|
259
|
+
displayed.outlineWidth !== this.style.outlineWidth ||
|
|
260
|
+
displayed.outlineOffset !== this.style.outlineOffset
|
|
261
|
+
) {
|
|
262
|
+
const shrank = this._outlineExtent(displayed) - this._outlineExtent();
|
|
263
|
+
if (shrank > 0) {
|
|
264
|
+
this.root?.invalidate(
|
|
265
|
+
false,
|
|
266
|
+
insetRect(this.paintBounds(), -shrank),
|
|
267
|
+
'outline',
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return this.style;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
_animatedValues() {
|
|
275
|
+
const values = {};
|
|
276
|
+
for (const [prop, a] of this._anim) {
|
|
277
|
+
// an offloaded property shows its target: the render server draws the
|
|
278
|
+
// motion over the model value, and the model is the style
|
|
279
|
+
if (!a.offloaded) values[prop] = a.value ?? a.from;
|
|
280
|
+
}
|
|
281
|
+
return values;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --- the presenter's half of an animation ---------------------------------
|
|
285
|
+
//
|
|
286
|
+
// Two feature-detected hooks on the window (src/cocoa/window.js: the
|
|
287
|
+
// layer presenter, and the surface presenter's layer promotion —
|
|
288
|
+
// src/cocoa/promotion.js): `animateNode(node, prop, entry)` answers true
|
|
289
|
+
// when the presenter will run the entry itself, `cancelNodeAnimation(node,
|
|
290
|
+
// prop)` stops what it runs for the property, and the presenter calls back
|
|
291
|
+
// through `_offloadEnded` / `_offloadDeclined` below. An entry the
|
|
292
|
+
// presenter took is `offloaded`: it stays in `_anim` — so a retarget, a
|
|
293
|
+
// loop-stop rule and `sameAnimation` all see it — but it contributes no
|
|
294
|
+
// value to the style, is skipped by the tick, and keeps the node out of the
|
|
295
|
+
// window's animating set. The X11 path has none of these hooks and is
|
|
296
|
+
// byte-identical (docs/architecture/animation.md §4).
|
|
297
|
+
|
|
298
|
+
_offload(prop, entry) {
|
|
299
|
+
const wnd = this.root?.window;
|
|
300
|
+
if (typeof wnd?.animateNode !== 'function') return false;
|
|
301
|
+
return wnd.animateNode(this, prop, entry) === true;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
_cancelOffload(prop, entry) {
|
|
305
|
+
if (!entry?.offloaded) return;
|
|
306
|
+
this.root?.window?.cancelNodeAnimation?.(this, prop);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The presenter is done with `entry` — it ran out, or its layer went.
|
|
310
|
+
* A transition is over either way (the model is the target). A loop
|
|
311
|
+
* never ends on its own, so a loop that comes back this way lost its
|
|
312
|
+
* layer, and the frame clock takes it over rather than letting it stop. */
|
|
313
|
+
_offloadEnded(prop, entry) {
|
|
314
|
+
if (this._anim?.get(prop) !== entry) return;
|
|
315
|
+
if (entry.loop && !this.destroyed) {
|
|
316
|
+
this._offloadDeclined(prop, entry);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
this._anim.delete(prop);
|
|
320
|
+
if (!this._anim.size) this.root?._animating.delete(this);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The presenter could not run `entry` after all — the node turned into a
|
|
324
|
+
* raster between the swap and the frame. The frame clock takes it from
|
|
325
|
+
* the top; the property's declared start is where the pixels still are. */
|
|
326
|
+
_offloadDeclined(prop, entry) {
|
|
327
|
+
if (this._anim?.get(prop) !== entry || this.destroyed) return;
|
|
328
|
+
entry.offloaded = false;
|
|
329
|
+
entry.start = now();
|
|
330
|
+
this.style = { ...this._targetStyle, ...this._animatedValues() };
|
|
331
|
+
this.root?._startAnimating(this);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Keep the frame clock running only for what the clock itself animates;
|
|
335
|
+
* an offloaded-only node needs one frame — the one that sends the model
|
|
336
|
+
* and the animation — and not a loop of them. */
|
|
337
|
+
_scheduleAnimationFrames() {
|
|
338
|
+
for (const a of this._anim?.values() ?? []) {
|
|
339
|
+
if (!a.offloaded) {
|
|
340
|
+
this.root?._startAnimating(this);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
this.root?._animating.delete(this);
|
|
345
|
+
this.root?.invalidate(false, damageForAnimation(this), 'animation');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The style declared a set of loops (`animation`, styles.js): remember
|
|
350
|
+
* them and reconcile what is running against them.
|
|
351
|
+
*
|
|
352
|
+
* Called from `_retarget`, so from every route a style arrives by — and
|
|
353
|
+
* only from there, because a loop is a property of the *style*. Whether it
|
|
354
|
+
* is allowed to run is a property of everything else, which is
|
|
355
|
+
* `_updateLoops`.
|
|
356
|
+
*/
|
|
357
|
+
_syncLoops(target) {
|
|
358
|
+
// `target` is device pixels by now, so the declared ends of a loop have
|
|
359
|
+
// to arrive in the same unit — the scale rides in rather than being
|
|
360
|
+
// applied after, because a `from` defaulted off the style is already
|
|
361
|
+
// device and must not double (see animationsOf).
|
|
362
|
+
const specs =
|
|
363
|
+
target.animation == null
|
|
364
|
+
? null
|
|
365
|
+
: animationsOf(target, 'a style', this.scale);
|
|
366
|
+
if (!specs && !this._loops) return false;
|
|
367
|
+
this._loops = specs;
|
|
368
|
+
if (!specs) this.root?._forgetLoopNode(this);
|
|
369
|
+
return this._updateLoops(false);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Start, keep or stop this node's loops, and answer whether anything
|
|
374
|
+
* changed. The one funnel: a style swap comes here, and so does every
|
|
375
|
+
* reason a loop must *stop* that has nothing to do with the style — the
|
|
376
|
+
* window unmapping, the desktop asking for less motion, a `display: none`
|
|
377
|
+
* three levels up.
|
|
378
|
+
*
|
|
379
|
+
* `write` is false when `_retarget` is going to assemble the style itself
|
|
380
|
+
* a line later; every other caller owns the repaint.
|
|
381
|
+
*/
|
|
382
|
+
_updateLoops(write = true) {
|
|
383
|
+
const specs = this._loops;
|
|
384
|
+
if (specs) this.root?._registerLoopNode(this);
|
|
385
|
+
const running = Boolean(specs) && this._loopsAllowed();
|
|
386
|
+
const anim = this._anim;
|
|
387
|
+
let changed = false;
|
|
388
|
+
let layoutTouched = false;
|
|
389
|
+
if (anim) {
|
|
390
|
+
for (const [prop, a] of anim) {
|
|
391
|
+
if (!a.loop) continue;
|
|
392
|
+
if (running && specs.some((spec) => spec.prop === prop)) continue;
|
|
393
|
+
anim.delete(prop);
|
|
394
|
+
this._cancelOffload(prop, a);
|
|
395
|
+
changed = true;
|
|
396
|
+
if (isLayoutProp(prop)) layoutTouched = true;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (running) {
|
|
400
|
+
for (const spec of specs) {
|
|
401
|
+
const current = this._anim?.get(spec.prop);
|
|
402
|
+
// An equal declaration keeps its phase. React hands a fresh object
|
|
403
|
+
// down on every render, so restarting on identity would mean a
|
|
404
|
+
// spinner that jumps back to the start whenever anything above it
|
|
405
|
+
// re-rendered — which is the frame after every state change in the
|
|
406
|
+
// app.
|
|
407
|
+
if (current?.loop && sameAnimation(current, spec)) {
|
|
408
|
+
// A loop the clock started before the window had a presenter —
|
|
409
|
+
// one declared at mount runs from `_setRoot`, before `realize` —
|
|
410
|
+
// moves over the first time a presenter can take it. Its phase is
|
|
411
|
+
// the render server's from here, which is what a restart costs.
|
|
412
|
+
if (!current.offloaded && this._offload(spec.prop, current)) {
|
|
413
|
+
current.offloaded = true;
|
|
414
|
+
changed = true;
|
|
415
|
+
}
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
// a changed declaration, or a transition the loop takes over from:
|
|
419
|
+
// whatever the presenter ran for the property stops first
|
|
420
|
+
if (current?.offloaded) this._cancelOffload(spec.prop, current);
|
|
421
|
+
const entry = {
|
|
422
|
+
...spec,
|
|
423
|
+
loop: true,
|
|
424
|
+
start: now(),
|
|
425
|
+
value: animationValueAt(spec, 0),
|
|
426
|
+
};
|
|
427
|
+
(this._anim ??= new Map()).set(spec.prop, entry);
|
|
428
|
+
if (this._offload(spec.prop, entry)) entry.offloaded = true;
|
|
429
|
+
changed = true;
|
|
430
|
+
if (isLayoutProp(spec.prop)) layoutTouched = true;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (!changed) return false;
|
|
434
|
+
const before = this.style;
|
|
435
|
+
this.style = this._anim?.size
|
|
436
|
+
? { ...this._targetStyle, ...this._animatedValues() }
|
|
437
|
+
: this._targetStyle;
|
|
438
|
+
// Out of the window's animating set here rather than on the next tick:
|
|
439
|
+
// a stop has to leave the frame clock idle, and a tick is exactly what
|
|
440
|
+
// there may never be another of.
|
|
441
|
+
if (!this._anim?.size) this.root?._animating.delete(this);
|
|
442
|
+
if (running) this._scheduleAnimationFrames();
|
|
443
|
+
if (!write) return true;
|
|
444
|
+
if (layoutTouched && this.yoga) {
|
|
445
|
+
applyLayoutStyle(this.yoga, this.style, before);
|
|
446
|
+
this._invalidateLayout('animation');
|
|
447
|
+
} else {
|
|
448
|
+
this.root?.invalidate(false, this, 'animation');
|
|
449
|
+
}
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Whether this node's loops may run at all.
|
|
455
|
+
*
|
|
456
|
+
* A transition stops because it arrives; a loop never does, so every one
|
|
457
|
+
* of these is load-bearing rather than an optimisation. A window keeping
|
|
458
|
+
* its frame clock alive for a spinner nobody can see is a laptop battery
|
|
459
|
+
* going down for nothing, and it is invisible by construction — the only
|
|
460
|
+
* way to notice is to look for it.
|
|
461
|
+
*/
|
|
462
|
+
_loopsAllowed() {
|
|
463
|
+
const root = this.root;
|
|
464
|
+
if (this.destroyed || !root || root.destroyed || root._loopsPaused) {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
if (desktopSettings(root.app).animations === false) return false;
|
|
468
|
+
return !this._hiddenInTree();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Whether anything between this node and its window has taken it off the
|
|
472
|
+
* screen — React's own `hidden` flag for `<Suspense>`/`<Activity>`, or a
|
|
473
|
+
* `display: 'none'` from a style, a state block or a size query. */
|
|
474
|
+
_hiddenInTree() {
|
|
475
|
+
for (let n = this; n; n = n.parent) {
|
|
476
|
+
if (n.hidden || n.style?.display === 'none') return true;
|
|
477
|
+
if (n.isWindow) break;
|
|
478
|
+
}
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Advance every in-flight transition to `now`. Returns true while any is
|
|
484
|
+
* still running, so the window keeps asking for frames.
|
|
485
|
+
*/
|
|
486
|
+
_tickAnimations(now) {
|
|
487
|
+
if (!this._anim?.size) return false;
|
|
488
|
+
let layoutChanged = false;
|
|
489
|
+
let ticking = 0; // entries the clock runs, as against the presenter's
|
|
490
|
+
const before = this.style;
|
|
491
|
+
for (const [prop, a] of this._anim) {
|
|
492
|
+
if (a.offloaded) continue;
|
|
493
|
+
if (a.loop) {
|
|
494
|
+
// No end to test for and no rounding to accumulate: the phase is a
|
|
495
|
+
// modulo of the elapsed time, so a bar that has been going for an
|
|
496
|
+
// hour is exactly where the clock says.
|
|
497
|
+
a.value = animationValueAt(a, now - a.start);
|
|
498
|
+
if (isLayoutProp(prop)) layoutChanged = true;
|
|
499
|
+
ticking++;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const t = a.duration > 0 ? Math.min(1, (now - a.start) / a.duration) : 1;
|
|
503
|
+
a.value = t >= 1 ? a.to : (interpolate(a.from, a.to, ease(t)) ?? a.to);
|
|
504
|
+
if (t >= 1) this._anim.delete(prop);
|
|
505
|
+
else ticking++;
|
|
506
|
+
if (isLayoutProp(prop)) layoutChanged = true;
|
|
507
|
+
}
|
|
508
|
+
this.style = this._anim.size
|
|
509
|
+
? { ...this._targetStyle, ...this._animatedValues() }
|
|
510
|
+
: this._targetStyle;
|
|
511
|
+
if (layoutChanged && this.yoga) {
|
|
512
|
+
applyLayoutStyle(this.yoga, this.style, before);
|
|
513
|
+
// a transition on a layout property costs a layout pass per frame —
|
|
514
|
+
// the author asked for that by transitioning one (docs/styling.md) —
|
|
515
|
+
// and a fresh set of content floors with it, since one of the
|
|
516
|
+
// properties it can be animating is a padding the floors were measured
|
|
517
|
+
// through
|
|
518
|
+
if (this.root) {
|
|
519
|
+
this.root.needsLayout = true;
|
|
520
|
+
this.root._floorsDirty = true;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// A tick writes `this.style` without going through `_retarget`, so it
|
|
524
|
+
// owes the cascade the same notice — and it is the only thing that owes
|
|
525
|
+
// it *per frame*: a transitioned `color` is a new ink every frame, for
|
|
526
|
+
// this node and for everything inheriting from it.
|
|
527
|
+
if (inheritedTextChanged(this.style, before)) this._retextSubtree();
|
|
528
|
+
return ticking > 0;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** WindowNode's half of transitions and loops, installed onto `WindowNode.prototype` by window/window.js. */
|
|
533
|
+
export class WindowAnimation {
|
|
534
|
+
/**
|
|
535
|
+
* A node in this window has a loop declared on it. Registration is what
|
|
536
|
+
* makes the window watch its own visibility — and only then: a
|
|
537
|
+
* VisibilityNotify mask bit and a `_NET_WM_STATE` selection are a real
|
|
538
|
+
* cost, and an app with no looping animation must not pay it (the same
|
|
539
|
+
* rule `useWindowState()` follows, for the same reason).
|
|
540
|
+
*/
|
|
541
|
+
_registerLoopNode(node) {
|
|
542
|
+
if (this._loopNodes.has(node)) return;
|
|
543
|
+
this._loopNodes.add(node);
|
|
544
|
+
this._watchLoops();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
_forgetLoopNode(node) {
|
|
548
|
+
if (!this._loopNodes.delete(node)) return;
|
|
549
|
+
if (this._loopNodes.size === 0) this._unwatchLoops();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
_watchLoops() {
|
|
553
|
+
// Before realize() there is no window to select events on, and
|
|
554
|
+
// `watchWindowState` would arm a session against nothing. `flush()`
|
|
555
|
+
// retries, which costs one boolean per frame of an animation that is
|
|
556
|
+
// running anyway.
|
|
557
|
+
if (this._loopWatch || !this.window || this.destroyed) return;
|
|
558
|
+
this._loopWatch = [
|
|
559
|
+
watchWindowState(this.app, this, () => this._loopVisibilityChanged()),
|
|
560
|
+
// Reduce motion is a live setting, not a startup one: turning it on in
|
|
561
|
+
// the accessibility panel has to stop the spinner that is already
|
|
562
|
+
// going round.
|
|
563
|
+
watchDesktopSettings(this.app, () => this._refreshLoops()),
|
|
564
|
+
];
|
|
565
|
+
this._loopVisibilityChanged();
|
|
566
|
+
// The window has a presenter now, which it did not when a loop declared
|
|
567
|
+
// at mount started on the clock (`_setRoot` runs before `realize`):
|
|
568
|
+
// every loop is asked again here, so one a presenter can take moves
|
|
569
|
+
// over on the window's first frame rather than at the next swap that
|
|
570
|
+
// happens to re-resolve its style. Where nothing can take it, the
|
|
571
|
+
// second look at an unchanged declaration is a no-op.
|
|
572
|
+
this._refreshLoops();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
_unwatchLoops() {
|
|
576
|
+
for (const off of this._loopWatch ?? []) {
|
|
577
|
+
try {
|
|
578
|
+
off();
|
|
579
|
+
} catch {
|
|
580
|
+
// a window already destroyed takes its subscriptions with it
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
this._loopWatch = null;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Minimized, fully obscured under a bare window manager, or unmapped —
|
|
587
|
+
* see the compositor caveat at the top of windowstate.js for why
|
|
588
|
+
* `visible` is the field to branch on rather than `obscured`. */
|
|
589
|
+
_loopVisibilityChanged() {
|
|
590
|
+
const { visible } = windowStateSnapshot(this.app, this);
|
|
591
|
+
const paused = this.hidden || !visible;
|
|
592
|
+
if (this._loopsPaused === paused) return;
|
|
593
|
+
this._loopsPaused = paused;
|
|
594
|
+
this._refreshLoops();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Re-ask every loop in this window whether it may run. */
|
|
598
|
+
_refreshLoops() {
|
|
599
|
+
for (const node of [...this._loopNodes]) node._updateLoops();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** A node in this window started a transition. */
|
|
603
|
+
_startAnimating(node) {
|
|
604
|
+
this._animating.add(node);
|
|
605
|
+
// The transition has to schedule its own first frame: it starts at the
|
|
606
|
+
// *old* value, so to whoever caused it the displayed style hasn't changed
|
|
607
|
+
// and their damage test contributes nothing. `setStyleState` happens to
|
|
608
|
+
// invalidate anyway, but a React prop change does not — and a transition
|
|
609
|
+
// no one schedules only runs when something else dirties the window,
|
|
610
|
+
// by which time its start is stale and it snaps to the end.
|
|
611
|
+
this.invalidate(false, damageForAnimation(node), 'animation');
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Step every in-flight transition to `now`, then keep the frame clock
|
|
616
|
+
* running while any is unfinished — the animation *is* the repaint loop,
|
|
617
|
+
* and it stops on its own the frame the last one lands.
|
|
618
|
+
*/
|
|
619
|
+
_advanceAnimations(now) {
|
|
620
|
+
if (this._animating.size === 0) return;
|
|
621
|
+
const claims = [];
|
|
622
|
+
for (const node of [...this._animating]) {
|
|
623
|
+
if (node.destroyed) {
|
|
624
|
+
this._animating.delete(node);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
// Decided *before* the tick, deliberately: a tick that finishes deletes
|
|
628
|
+
// the property from `_anim`, and after that there is no way to tell a
|
|
629
|
+
// layout animation from a paint-only one — the node's own bounds would
|
|
630
|
+
// be claimed for something that just moved, leaving a trail behind it.
|
|
631
|
+
claims.push(damageForAnimation(node));
|
|
632
|
+
if (!node._tickAnimations(now)) this._animating.delete(node);
|
|
633
|
+
}
|
|
634
|
+
this.needsPaint = true;
|
|
635
|
+
// Claim a region rather than leaving the frame unbounded: an animation is
|
|
636
|
+
// a repaint every frame for its whole duration, so this is the difference
|
|
637
|
+
// between a 120ms transition costing eight full-window repaints and eight
|
|
638
|
+
// repaints of the thing that moved. Nodes that *finished* on this tick are
|
|
639
|
+
// claimed too — one just landed on its final value and that last frame
|
|
640
|
+
// still has to paint it, which is why every transition used to end with a
|
|
641
|
+
// full-window repaint.
|
|
642
|
+
for (const claim of claims) this.invalidate(false, claim, 'animation');
|
|
643
|
+
}
|
|
644
|
+
}
|
package/src/nodes/box.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// <box>: a node that scrolls.
|
|
2
|
+
|
|
3
|
+
import { Node } from './node.js';
|
|
4
|
+
import { Scrollable } from './scrollable.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The flex container — and, with `overflow: 'scroll'`, the scroll container
|
|
8
|
+
* too. There is no separate scrolling element: see `Scrollable`.
|
|
9
|
+
*/
|
|
10
|
+
export class BoxNode extends Scrollable(Node) {
|
|
11
|
+
constructor(props, app) {
|
|
12
|
+
super('box', props, app);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A box draws a fill and a border and no text at all, so a new ink or a
|
|
16
|
+
* new face costs it nothing — it is only ever the *source* of one. The
|
|
17
|
+
* nodes inside it claim their own damage as the walk reaches them, which
|
|
18
|
+
* keeps hovering a long list bounded to the labels rather than to the
|
|
19
|
+
* list. */
|
|
20
|
+
_textStyleMoved() {}
|
|
21
|
+
}
|