react-x11 2.11.0 → 2.12.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.
Files changed (96) hide show
  1. package/README.md +278 -129
  2. package/package.json +10 -3
  3. package/src/Reconciler.js +15 -17
  4. package/src/a11y.js +2 -2
  5. package/src/anchor.js +7 -5
  6. package/src/bootstrap.js +14 -0
  7. package/src/clientmessage.js +1 -1
  8. package/src/cocoa/app.js +292 -49
  9. package/src/cocoa/bezels.js +175 -30
  10. package/src/cocoa/dnd.js +27 -13
  11. package/src/cocoa/fonts.js +3 -3
  12. package/src/cocoa/glarea.js +20 -3
  13. package/src/cocoa/main.d.ts +8 -0
  14. package/src/cocoa/main.js +43 -0
  15. package/src/cocoa/panehost.js +15 -5
  16. package/src/cocoa/presenter.js +13 -9
  17. package/src/cocoa/promotion.js +4 -7
  18. package/src/cocoa/relaunch.js +207 -0
  19. package/src/cocoa/threaded.js +246 -0
  20. package/src/cocoa/window.js +256 -42
  21. package/src/components/Select.js +2 -2
  22. package/src/components/anchor.js +3 -3
  23. package/src/components/native.js +12 -7
  24. package/src/components/theme.js +2 -2
  25. package/src/debug.js +1 -1
  26. package/src/decorations.js +1 -1
  27. package/src/editmenu.js +2 -2
  28. package/src/errors.js +46 -0
  29. package/src/events.js +6 -6
  30. package/src/foreignnodes.js +3 -2
  31. package/src/frames.js +2 -2
  32. package/src/glnodes.js +1 -1
  33. package/src/grid.js +1653 -0
  34. package/src/host.d.ts +230 -0
  35. package/src/host.js +11 -3
  36. package/src/imagesource.js +1 -1
  37. package/src/index.d.ts +21 -4
  38. package/src/index.js +9 -1
  39. package/src/layouts.js +721 -0
  40. package/src/node.d.ts +4 -2
  41. package/src/node.js +19 -21
  42. package/src/nodes/animation.js +644 -0
  43. package/src/nodes/box.js +21 -0
  44. package/src/nodes/boxpaint.js +473 -0
  45. package/src/nodes/canvas.js +269 -0
  46. package/src/nodes/cascade.js +600 -0
  47. package/src/nodes/damage.js +183 -0
  48. package/src/nodes/edithistory.js +124 -0
  49. package/src/nodes/editmenupopup.js +260 -0
  50. package/src/nodes/hittest.js +185 -0
  51. package/src/nodes/image.js +266 -0
  52. package/src/nodes/install.js +75 -0
  53. package/src/nodes/invalidate.js +465 -0
  54. package/src/nodes/kinds.js +31 -0
  55. package/src/nodes/layout.js +439 -0
  56. package/src/nodes/layouthost.js +949 -0
  57. package/src/nodes/node.js +868 -0
  58. package/src/nodes/paint.js +466 -0
  59. package/src/nodes/position.js +366 -0
  60. package/src/nodes/preedit.js +127 -0
  61. package/src/nodes/queries.js +330 -0
  62. package/src/nodes/rects.js +102 -0
  63. package/src/nodes/scrollable.js +891 -0
  64. package/src/nodes/scrollbars.js +138 -0
  65. package/src/nodes/scrollblit.js +1034 -0
  66. package/src/nodes/selectable.js +142 -0
  67. package/src/nodes/styling.js +225 -0
  68. package/src/nodes/text.js +649 -0
  69. package/src/nodes/textarea.js +391 -0
  70. package/src/nodes/textinput.js +1146 -0
  71. package/src/nodes/util.js +17 -0
  72. package/src/nodes/window/anchoring.js +161 -0
  73. package/src/nodes/window/capabilities.js +190 -0
  74. package/src/nodes/window/debugpaint.js +83 -0
  75. package/src/nodes/window/droptarget.js +145 -0
  76. package/src/nodes/window/floors.js +577 -0
  77. package/src/nodes/window/flush.js +334 -0
  78. package/src/nodes/window/hints.js +482 -0
  79. package/src/nodes/window/listeners.js +222 -0
  80. package/src/nodes/window/popup.js +71 -0
  81. package/src/nodes/window/size.js +591 -0
  82. package/src/nodes/window/window.js +945 -0
  83. package/src/palette.js +1 -1
  84. package/src/registry.js +7 -3
  85. package/src/styles.js +137 -15
  86. package/src/svgnodes.js +2 -1
  87. package/src/testing/harness.js +2 -2
  88. package/src/textselection.js +5 -3
  89. package/src/trace-registry.js +1 -1
  90. package/src/types/components.d.ts +38 -6
  91. package/src/types/elements.d.ts +11 -1
  92. package/src/types/nodes.d.ts +17 -2
  93. package/src/types/style.d.ts +94 -3
  94. package/src/windowstate.js +1 -1
  95. package/src/yoga.js +1 -1
  96. 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
+ }
@@ -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
+ }