react-x11 2.7.0 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.7.0",
3
+ "version": "2.8.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -98,7 +98,7 @@
98
98
  "yoga-layout": "^3.2.1"
99
99
  },
100
100
  "optionalDependencies": {
101
- "@windowkit/appkit": "^0.5.0",
101
+ "@windowkit/appkit": "^0.5.1",
102
102
  "dbus-native": "^0.15.1",
103
103
  "x11-dri": "^0.7.0"
104
104
  },
package/src/cocoa/app.js CHANGED
@@ -16,6 +16,7 @@ import { cssColorStraight } from 'ntk';
16
16
 
17
17
  import { deliverActivate, deliverOpen } from '../application.js';
18
18
  import { flushPendingFrames } from '../frames.js';
19
+ import { flushSyncWork } from '../priority.js';
19
20
  import { setCompositingForTests } from '../compositing.js';
20
21
  import { setScreensForTests } from '../screens.js';
21
22
  import { setScaleForTests } from '../scale.js';
@@ -118,6 +119,23 @@ export class CocoaApp {
118
119
  options.cocoa?.presenter ??
119
120
  process.env.REACT_X11_COCOA_PRESENTER ??
120
121
  'surface';
122
+ // With the surface presenter, whether the nodes that animate get a
123
+ // layer of their own above the bitmap (src/cocoa/promotion.js). On by
124
+ // default — an animation the render server runs survives a busy JS
125
+ // thread, and the bitmap keeps the frame for everything else — where
126
+ // the bridge draws a layer's colour and a rastered one the same, which
127
+ // it says with `colorSpace()` (@windowkit/appkit >= 0.5.1, the range
128
+ // package.json asks for): before it a layer's colour was Generic RGB
129
+ // against sRGB surfaces, and a node moving between its layer and the
130
+ // bitmap changed shade on the way.
131
+ // Off (`cocoa.promote: false`, REACT_X11_COCOA_PROMOTE=0) keeps every
132
+ // animation on the frame clock, which is what the presenter bench's
133
+ // `surface` column measures; on (`true`, `=1`) is on regardless.
134
+ const promoteEnv = process.env.REACT_X11_COCOA_PROMOTE;
135
+ this._promote =
136
+ options.cocoa?.promote ??
137
+ (promoteEnv === '1' ||
138
+ (promoteEnv !== '0' && native.colorSpace?.() === 'sRGB'));
121
139
 
122
140
  // the app's own bridge, so an app over a fake one (the tests) needs no
123
141
  // real bridge on the machine — the manager's default loads it only when
@@ -594,9 +612,15 @@ export class CocoaApp {
594
612
  );
595
613
  }
596
614
 
597
- _tickFrames() {
615
+ /**
616
+ * Run every frame that is due at `now`. The pump tick and the frame
617
+ * timer read the clock; a test names the moment, so that what a tick
618
+ * decides is a fact about that moment and not about how long the test
619
+ * took to ask — a shared runner stalls for milliseconds between two
620
+ * lines.
621
+ */
622
+ _tickFrames(now = performance.now()) {
598
623
  if (!this._rafQueue.length) return;
599
- const now = performance.now();
600
624
  // Each window keeps its own clock, so a window on a 120Hz panel paints
601
625
  // every refresh while one on a 60Hz monitor paints every other pump
602
626
  // tick. Decided once per clock per tick: every frame a window queued
@@ -638,9 +662,21 @@ export class CocoaApp {
638
662
  for (const wnd of this._windows.values()) wnd.present();
639
663
  }
640
664
 
641
- /** After any synchronously dispatched input: paint the response now (the
642
- * same early flush a discrete event gets on X11) and put it on glass. */
665
+ /**
666
+ * After any synchronously dispatched input: land React's half of the
667
+ * response, paint it (the same early flush a discrete event gets on X11 —
668
+ * `discrete` in events.js) and put it on glass.
669
+ *
670
+ * `flushSyncWork` is not belt and braces. A discrete-priority update is
671
+ * committed on a **microtask**, and a microtask does not run until the
672
+ * call that dispatched the event returns to Node — which, inside one of
673
+ * AppKit's modal loops (a live resize, menu tracking, a dragging
674
+ * session), is after the whole gesture. Without it a handler's own
675
+ * `setState` reaches the screen when the gesture ends, and the gesture is
676
+ * exactly when it was worth showing.
677
+ */
643
678
  _afterInput() {
679
+ flushSyncWork();
644
680
  flushPendingFrames();
645
681
  this._presentAll();
646
682
  }
@@ -694,10 +730,14 @@ export class CocoaApp {
694
730
  case 'drag-perform':
695
731
  return this._routeDrop(ev);
696
732
  case 'drag-session-began':
733
+ // `draggingSession:willBeginAtPoint:`, dispatched from inside
734
+ // `beginDrag` — before the `onDragStart` handler's own render has
735
+ // been asked for, so there is nothing to flush yet. The frame that
736
+ // shows the drag beginning goes out from `CocoaWindow.beginDrag`,
737
+ // on the way back past it.
697
738
  return undefined;
698
739
  case 'drag-session-moved':
699
- this._activeDrag?.nativeMoved(ev);
700
- return undefined;
740
+ return this._routeDragMoved(ev);
701
741
  case 'drag-session-ended':
702
742
  return this._routeDragEnded(ev);
703
743
  case 'app-open-urls':
@@ -913,6 +953,20 @@ export class CocoaApp {
913
953
  this._afterInput();
914
954
  }
915
955
 
956
+ /**
957
+ * `draggingSession:movedToPoint:` — the source's own view of the gesture,
958
+ * and the only thing that arrives while AppKit tracks it: the pointer's
959
+ * `mousemove` stops, `pump2` does not return until the drop, and no timer
960
+ * or microtask of ours runs in between. So the frame belongs to the
961
+ * callback, like the destination's (`_routeDrop`) — that is what lets a
962
+ * `<popup dragPreview>` follow the pointer, and an `onDrag` that moves an
963
+ * insertion marker move it during the drag rather than on the release.
964
+ */
965
+ _routeDragMoved(ev) {
966
+ this._activeDrag?.nativeMoved(ev);
967
+ this._afterInput();
968
+ }
969
+
916
970
  /** The release of a drag AppKit was tracking for us. */
917
971
  _routeDragEnded(ev) {
918
972
  const drag = this._activeDrag;
package/src/cocoa/dnd.js CHANGED
@@ -44,6 +44,7 @@
44
44
  // identifier for a MIME type no declared type claims is computed alike by
45
45
  // every process — how `application/x-myapp-…` travels between two react-x11
46
46
  // apps) and back (`pasteboardTypeInfo`).
47
+ import { runWithPriority, DiscreteEventPriority } from '../priority.js';
47
48
  import {
48
49
  TEXT_TARGETS,
49
50
  TYPE_GROUPS,
@@ -254,6 +255,15 @@ export class CocoaDropTransport {
254
255
  this.session = session;
255
256
  this.node = node;
256
257
  this._registered = null;
258
+ // AppKit tracks the whole drag on this thread: the pump does not return
259
+ // until the drop, so nothing an enter/over dispatch schedules — a
260
+ // `useDropTarget` lighting its "drop here" label, any handler's
261
+ // `setState` — has a frame tick or a microtask to land on. Discrete is
262
+ // the one lane the app can land by hand from inside the callback
263
+ // (src/cocoa/app.js `_afterInput`), which is where the answer to
264
+ // AppKit's question is painted. The renderer's own `:drag-over` needs
265
+ // none of this and never did.
266
+ session.hoverPriority = DiscreteEventPriority;
257
267
  this.refreshTypes();
258
268
  }
259
269
 
@@ -326,7 +336,8 @@ export class CocoaDropTransport {
326
336
 
327
337
  _leave(ev) {
328
338
  const drag = this._local(ev);
329
- this.session.localLeave();
339
+ // the leaving half of the same stream, in the same lane as the enter
340
+ runWithPriority(DiscreteEventPriority, () => this.session.localLeave());
330
341
  if (drag) drag.accepted = false;
331
342
  }
332
343
 
@@ -35,7 +35,7 @@ import {
35
35
  import { EASING_CONTROL_POINTS, TRANSITION_CONTROL_POINTS } from '../styles.js';
36
36
  import { CocoaContext2D } from './context2d.js';
37
37
 
38
- const RASTER_PAD = 2; // antialiasing/italic overhang outside the ink bounds
38
+ export const RASTER_PAD = 2; // antialiasing/italic overhang outside the ink bounds
39
39
 
40
40
  /**
41
41
  * A recording "context" for ntk's SvgView.draw: instead of rasterizing, it
@@ -325,7 +325,13 @@ const EDGE_PROPS = [
325
325
  'borderEndWidth',
326
326
  ];
327
327
 
328
- function stylePaintsPlain(node, style = node.style ?? {}) {
328
+ /**
329
+ * Does `style` draw as a plain box — the property vocabulary of a layer
330
+ * (background, a uniform solid border, one radius), nothing that needs a
331
+ * raster? What decides whether a node is a PropBox on the layer presenter,
332
+ * and whether the surface presenter can promote it (src/cocoa/promotion.js).
333
+ */
334
+ export function stylePaintsPlain(node, style = node.style ?? {}) {
329
335
  if (node.kind !== 'box') return false;
330
336
  if (style.backgroundImage || style.boxShadow || style.outlineWidth) {
331
337
  return false;
@@ -359,13 +365,13 @@ const ANIMATED_KEY_PATHS = Object.freeze({
359
365
  // looked up per app (`_animationEnds`)
360
366
  let animationSeq = 0;
361
367
 
362
- function uniformRadius(radius) {
368
+ export function uniformRadius(radius) {
363
369
  if (radius === undefined) return 0;
364
370
  if (typeof radius === 'number') return radius;
365
371
  return null; // per-corner shapes go to raster
366
372
  }
367
373
 
368
- class Visual {
374
+ export class Visual {
369
375
  constructor(presenter, node) {
370
376
  this.presenter = presenter;
371
377
  this.node = node;
@@ -409,7 +415,7 @@ class Visual {
409
415
  }
410
416
  }
411
417
 
412
- class RasterState {
418
+ export class RasterState {
413
419
  constructor() {
414
420
  this.surface = null;
415
421
  this.gen = 0;
@@ -450,6 +456,218 @@ class RasterState {
450
456
  }
451
457
  }
452
458
 
459
+ /**
460
+ * A plain box as the properties of its layer — what a PropBox visual sends
461
+ * on the layer presenter and what a promoted node's layer is set from on
462
+ * the surface presenter (src/cocoa/promotion.js): one function, so a box
463
+ * reads the same on either. `parentOrigin` is what `frame` is relative to,
464
+ * in window coordinates; everything goes out in points.
465
+ */
466
+ export function propBoxProps(node, app, scale, parentOrigin, order) {
467
+ const abs = node.abs;
468
+ const style = node.style ?? {};
469
+ const border = typeof style.borderWidth === 'number' ? style.borderWidth : 0;
470
+ const colour = (value) =>
471
+ value ? (app._parseColor(String(value)) ?? [0, 0, 0, 0]) : [0, 0, 0, 0];
472
+ return {
473
+ frame: [
474
+ (abs.x - parentOrigin.x) / scale,
475
+ (abs.y - parentOrigin.y) / scale,
476
+ Math.max(0, abs.width) / scale,
477
+ Math.max(0, abs.height) / scale,
478
+ ],
479
+ zPosition: order,
480
+ hidden: Boolean(node.hidden),
481
+ masksToBounds: Boolean(node.clipsChildren?.()) && !node.isScroller?.(),
482
+ cornerRadius: (uniformRadius(style.borderRadius) ?? 0) / scale,
483
+ backgroundColor: colour(style.backgroundColor),
484
+ borderWidth: border / scale,
485
+ borderColor: colour(style.borderColor),
486
+ };
487
+ }
488
+
489
+ // --- animations the render server runs ---------------------------------------
490
+ //
491
+ // The node model keeps deciding what is animating and when it ends
492
+ // (nodes.js `_retarget` / `_updateLoops`); what moves here is who
493
+ // interpolates. Taken means the node's style goes to its target — the
494
+ // layer's model value, sent by the next frame's property diff — and that
495
+ // frame attaches an explicit animation carrying the pixels there; no frame
496
+ // after it is scheduled for the property, and a loop costs no JS frames at
497
+ // all. Declined means the frame clock runs it exactly as before, so
498
+ // nothing here is load-bearing for correctness.
499
+ //
500
+ // Kept apart from the presenter because two of them hand animations over:
501
+ // the layer presenter, where every plain box has a layer, and the surface
502
+ // presenter's promotion (src/cocoa/promotion.js), where only the nodes
503
+ // that animate do. They differ in which node has a layer and agree on
504
+ // everything from there — `layerOf(node)` is the whole of the difference.
505
+
506
+ /**
507
+ * The animations one presenter has handed to the render server: what
508
+ * `take` accepted and is waiting for the frame that attaches it, and what
509
+ * is running on a layer. `onIdle(node)`, when given, hears that the last
510
+ * animation running for a node ended on the bridge's word — the one end
511
+ * no frame follows on its own.
512
+ */
513
+ export class LayerAnimations {
514
+ constructor({ native, app, scale, layerOf, onIdle = null }) {
515
+ this.native = native;
516
+ this.app = app;
517
+ this.scale = scale;
518
+ this.layerOf = layerOf;
519
+ this.onIdle = onIdle;
520
+ this.pending = new Map(); // node -> Map(prop -> entry)
521
+ this.live = new Map(); // node -> Map(id -> { prop, key, entry })
522
+ }
523
+
524
+ /**
525
+ * Take `prop`'s animation for `node`, or decline. Decided against the
526
+ * *target* style: a `:hover` that adds a shadow turns the node into a
527
+ * raster in the same swap that starts a fade, and a raster's background
528
+ * is in its bitmap, not on its layer.
529
+ */
530
+ take(node, prop, entry) {
531
+ const map = ANIMATED_KEY_PATHS[prop];
532
+ if (!map || !stylePaintsPlain(node, node._targetStyle ?? node.style)) {
533
+ return false;
534
+ }
535
+ if (this._value(map, entry.from) == null) return false;
536
+ if (this._value(map, entry.to) == null) return false;
537
+ let pending = this.pending.get(node);
538
+ if (!pending) this.pending.set(node, (pending = new Map()));
539
+ pending.set(prop, entry);
540
+ return true;
541
+ }
542
+
543
+ /** Stop what runs for `prop` on `node`: a loop the window lost sight of,
544
+ * a declaration that changed, a transition the clock takes back. */
545
+ cancel(node, prop) {
546
+ this.pending.get(node)?.delete(prop);
547
+ this._removeLive(node, prop);
548
+ }
549
+
550
+ /** Is anything taken for `node` — waiting for a frame, or running? */
551
+ has(node) {
552
+ return this.pending.has(node) || this.live.has(node);
553
+ }
554
+
555
+ _ends() {
556
+ return (this.app._animationEnds ??= new Map());
557
+ }
558
+
559
+ /** A style value as the layer takes it — a colour as components, a
560
+ * length in points — or null for one the layer cannot animate. */
561
+ _value(map, value) {
562
+ if (map.colour) {
563
+ return typeof value === 'string'
564
+ ? (this.app._parseColor(value) ?? null)
565
+ : null;
566
+ }
567
+ return typeof value === 'number'
568
+ ? value / (map.scaled ? this.scale : 1)
569
+ : null;
570
+ }
571
+
572
+ /** The frame's half: attach what `take` accepted to `layer`, after the
573
+ * model value went out, inside the same transaction. */
574
+ apply(node, layer) {
575
+ const pending = this.pending.get(node);
576
+ if (!pending) return;
577
+ this.pending.delete(node);
578
+ for (const [prop, entry] of pending) {
579
+ const map = ANIMATED_KEY_PATHS[prop];
580
+ const id = `rx${++animationSeq}`;
581
+ const key = `${prop}:${id}`;
582
+ const opts = { duration: entry.duration / 1000, id };
583
+ if (entry.loop) {
584
+ // a loop replaces whatever ran for the property: its declaration
585
+ // changed, and a loop restarts from the top when it does
586
+ this._removeLive(node, prop);
587
+ opts.from = this._value(map, entry.from);
588
+ opts.to = this._value(map, entry.to);
589
+ opts.timing = EASING_CONTROL_POINTS[entry.easing];
590
+ opts.repeat = Infinity;
591
+ opts.autoreverse = entry.alternate;
592
+ } else if (map.colour) {
593
+ // From where the pixels are — which is what "an interrupted
594
+ // transition reverses from where it got to" means here. A colour
595
+ // cannot be additive, so the one before it is replaced.
596
+ this._removeLive(node, prop);
597
+ const shown = this.native.presentationValue?.(layer, map.keyPath);
598
+ opts.from = Array.isArray(shown) ? shown : this._value(map, entry.from);
599
+ opts.to = this._value(map, entry.to);
600
+ opts.timing = TRANSITION_CONTROL_POINTS;
601
+ } else {
602
+ // Additive: a delta over the model value, (old − new) → 0, and the
603
+ // ones before it keep running and sum. Continuity on a retarget with
604
+ // nothing read back, however many are in flight.
605
+ opts.from = this._value(map, entry.from) - this._value(map, entry.to);
606
+ opts.to = 0;
607
+ opts.additive = true;
608
+ opts.timing = TRANSITION_CONTROL_POINTS;
609
+ }
610
+ this.native.addAnimation(layer, map.keyPath, opts, key);
611
+ // looked up after the removals above, which may have pruned the map
612
+ let live = this.live.get(node);
613
+ if (!live) this.live.set(node, (live = new Map()));
614
+ live.set(id, { prop, key, entry });
615
+ this._ends().set(id, (ev) => this._animationEnded(node, id, ev));
616
+ }
617
+ }
618
+
619
+ /** Every animation running for `prop` on `node`, off the layer and out
620
+ * of the books. */
621
+ _removeLive(node, prop) {
622
+ const live = this.live.get(node);
623
+ if (!live) return;
624
+ const layer = this.layerOf(node);
625
+ for (const [id, run] of live) {
626
+ if (run.prop !== prop) continue;
627
+ live.delete(id);
628
+ this._ends().delete(id);
629
+ if (layer) this.native.removeAnimation(layer, run.key);
630
+ }
631
+ if (live.size === 0) this.live.delete(node);
632
+ }
633
+
634
+ /** The bridge's `animation-end` for one of ours — it ran out, or CA
635
+ * dropped it. An older additive one ending changes nothing: the node
636
+ * checks the entry is still the one it holds. */
637
+ _animationEnded(node, id) {
638
+ this._ends().delete(id);
639
+ const live = this.live.get(node);
640
+ const run = live?.get(id);
641
+ if (!run) return;
642
+ live.delete(id);
643
+ if (live.size === 0) this.live.delete(node);
644
+ node._offloadEnded(run.prop, run.entry);
645
+ if (!this.has(node)) this.onIdle?.(node);
646
+ }
647
+
648
+ /** The layer is going — the visual is destroyed, or turns into a raster —
649
+ * and every animation on it goes with it. What was still waiting for a
650
+ * frame goes back to the frame clock when the node stays (`reclaim`);
651
+ * what was running is over, and the model shows. */
652
+ drop(node, reclaim) {
653
+ const pending = this.pending.get(node);
654
+ if (pending) {
655
+ this.pending.delete(node);
656
+ for (const [prop, entry] of pending) {
657
+ if (reclaim) node._offloadDeclined(prop, entry);
658
+ else node._offloadEnded(prop, entry);
659
+ }
660
+ }
661
+ const live = this.live.get(node);
662
+ if (!live) return;
663
+ this.live.delete(node);
664
+ for (const [id, run] of live) {
665
+ this._ends().delete(id);
666
+ node._offloadEnded(run.prop, run.entry);
667
+ }
668
+ }
669
+ }
670
+
453
671
  export class CocoaLayerPresenter {
454
672
  constructor(window) {
455
673
  this.window = window;
@@ -459,10 +677,13 @@ export class CocoaLayerPresenter {
459
677
  this.visuals = new Map(); // node -> Visual
460
678
  this.rasters = new Map(); // node -> RasterState
461
679
  this.bars = new Map(); // scroller node -> Map(axis -> { layer, raster })
462
- // animations taken off the frame clock: accepted and waiting for the
463
- // frame that attaches them, and running in the render server
464
- this.pendingAnimations = new Map(); // node -> Map(prop -> entry)
465
- this.liveAnimations = new Map(); // node -> Map(id -> { prop, key, entry })
680
+ // animations taken off the frame clock, by the node's own layer
681
+ this.animations = new LayerAnimations({
682
+ native: this.native,
683
+ app: window.app,
684
+ scale: this.scale,
685
+ layerOf: (node) => this.visuals.get(node)?.layer ?? null,
686
+ });
466
687
  // Claims since the last frame — taken at the top of `frame()`, the way
467
688
  // the X11 path takes its damage before painting, so a claim made from
468
689
  // inside a paint lands in the next frame instead of being cleared with
@@ -572,7 +793,7 @@ export class CocoaLayerPresenter {
572
793
  visual.destroy();
573
794
  this.visuals.delete(node);
574
795
  this._dropRaster(node);
575
- this._dropAnimations(node, false);
796
+ this.animations.drop(node, false);
576
797
  const bars = this.bars.get(node);
577
798
  if (bars) {
578
799
  for (const entry of bars.values()) {
@@ -639,11 +860,11 @@ export class CocoaLayerPresenter {
639
860
  this._dropRaster(node);
640
861
  // a layer that turns into a raster takes its animations with it; the
641
862
  // frame clock can still run them over the bitmap
642
- if (wantsRaster) this._dropAnimations(node, true);
863
+ if (wantsRaster) this.animations.drop(node, true);
643
864
  visual = null;
644
865
  }
645
- if (wantsRaster && this.pendingAnimations.has(node)) {
646
- this._dropAnimations(node, true);
866
+ if (wantsRaster && this.animations.pending.has(node)) {
867
+ this.animations.drop(node, true);
647
868
  }
648
869
  if (!visual) {
649
870
  visual = new Visual(this, node);
@@ -731,191 +952,23 @@ export class CocoaLayerPresenter {
731
952
 
732
953
  _syncPropBox(node, visual, parentOrigin, order) {
733
954
  const abs = node.abs;
734
- const style = node.style ?? {};
735
- const s = this.scale;
736
955
  visual.origin = { x: abs.x, y: abs.y };
737
- const border =
738
- typeof style.borderWidth === 'number' ? style.borderWidth : 0;
739
- visual.set({
740
- frame: [
741
- (abs.x - parentOrigin.x) / s,
742
- (abs.y - parentOrigin.y) / s,
743
- Math.max(0, abs.width) / s,
744
- Math.max(0, abs.height) / s,
745
- ],
746
- zPosition: order,
747
- hidden: Boolean(node.hidden),
748
- masksToBounds: Boolean(node.clipsChildren?.()) && !node.isScroller?.(),
749
- cornerRadius: (uniformRadius(style.borderRadius) ?? 0) / s,
750
- backgroundColor: style.backgroundColor
751
- ? (this.window.app._parseColor(String(style.backgroundColor)) ?? [
752
- 0, 0, 0, 0,
753
- ])
754
- : [0, 0, 0, 0],
755
- borderWidth: border / s,
756
- borderColor: style.borderColor
757
- ? (this.window.app._parseColor(String(style.borderColor)) ?? [
758
- 0, 0, 0, 0,
759
- ])
760
- : [0, 0, 0, 0],
761
- });
956
+ visual.set(
957
+ propBoxProps(node, this.window.app, this.scale, parentOrigin, order),
958
+ );
762
959
  // after the model value went out, inside the same transaction
763
- this._applyAnimations(node, visual);
960
+ this.animations.apply(node, visual.layer);
764
961
  }
765
962
 
766
- // --- animations the render server runs -----------------------------------
767
- //
768
- // The node model keeps deciding what is animating and when it ends
769
- // (nodes.js `_retarget` / `_updateLoops`); what moves here is who
770
- // interpolates. Taken means the node's style goes to its target — the
771
- // layer's model value, sent by the next frame's property diff — and that
772
- // frame attaches an explicit animation carrying the pixels there; no frame
773
- // after it is scheduled for the property, and a loop costs no JS frames at
774
- // all. Declined means the frame clock runs it exactly as before, so
775
- // nothing here is load-bearing for correctness.
776
-
777
- /**
778
- * Take `prop`'s animation for `node`, or decline. Decided against the
779
- * *target* style: a `:hover` that adds a shadow turns the node into a
780
- * raster in the same swap that starts a fade, and a raster's background
781
- * is in its bitmap, not on its layer.
782
- */
963
+ /** The window's animation seam (src/cocoa/window.js): take `prop`'s
964
+ * animation for `node` off the frame clock, or decline. */
783
965
  animate(node, prop, entry) {
784
- const map = ANIMATED_KEY_PATHS[prop];
785
- if (!map || !stylePaintsPlain(node, node._targetStyle ?? node.style)) {
786
- return false;
787
- }
788
- if (this._value(map, entry.from) == null) return false;
789
- if (this._value(map, entry.to) == null) return false;
790
- let pending = this.pendingAnimations.get(node);
791
- if (!pending) this.pendingAnimations.set(node, (pending = new Map()));
792
- pending.set(prop, entry);
793
- return true;
966
+ return this.animations.take(node, prop, entry);
794
967
  }
795
968
 
796
- /** Stop what runs for `prop` on `node`: a loop the window lost sight of,
797
- * a declaration that changed, a transition the clock takes back. */
969
+ /** …and stop what runs for `prop` on `node`. */
798
970
  cancel(node, prop) {
799
- this.pendingAnimations.get(node)?.delete(prop);
800
- this._removeLive(node, prop);
801
- }
802
-
803
- _ends() {
804
- const app = this.window.app;
805
- return (app._animationEnds ??= new Map());
806
- }
807
-
808
- /** A style value as the layer takes it — a colour as components, a
809
- * length in points — or null for one the layer cannot animate. */
810
- _value(map, value) {
811
- if (map.colour) {
812
- return typeof value === 'string'
813
- ? (this.window.app._parseColor(value) ?? null)
814
- : null;
815
- }
816
- return typeof value === 'number'
817
- ? value / (map.scaled ? this.scale : 1)
818
- : null;
819
- }
820
-
821
- /** The frame's half: attach what `animate` accepted, after the model
822
- * value went out, inside the same transaction. */
823
- _applyAnimations(node, visual) {
824
- const pending = this.pendingAnimations.get(node);
825
- if (!pending) return;
826
- this.pendingAnimations.delete(node);
827
- for (const [prop, entry] of pending) {
828
- const map = ANIMATED_KEY_PATHS[prop];
829
- const id = `rx${++animationSeq}`;
830
- const key = `${prop}:${id}`;
831
- const opts = { duration: entry.duration / 1000, id };
832
- if (entry.loop) {
833
- // a loop replaces whatever ran for the property: its declaration
834
- // changed, and a loop restarts from the top when it does
835
- this._removeLive(node, prop);
836
- opts.from = this._value(map, entry.from);
837
- opts.to = this._value(map, entry.to);
838
- opts.timing = EASING_CONTROL_POINTS[entry.easing];
839
- opts.repeat = Infinity;
840
- opts.autoreverse = entry.alternate;
841
- } else if (map.colour) {
842
- // From where the pixels are — which is what "an interrupted
843
- // transition reverses from where it got to" means here. A colour
844
- // cannot be additive, so the one before it is replaced.
845
- this._removeLive(node, prop);
846
- const shown = this.native.presentationValue?.(
847
- visual.layer,
848
- map.keyPath,
849
- );
850
- opts.from = Array.isArray(shown) ? shown : this._value(map, entry.from);
851
- opts.to = this._value(map, entry.to);
852
- opts.timing = TRANSITION_CONTROL_POINTS;
853
- } else {
854
- // Additive: a delta over the model value, (old − new) → 0, and the
855
- // ones before it keep running and sum. Continuity on a retarget with
856
- // nothing read back, however many are in flight.
857
- opts.from = this._value(map, entry.from) - this._value(map, entry.to);
858
- opts.to = 0;
859
- opts.additive = true;
860
- opts.timing = TRANSITION_CONTROL_POINTS;
861
- }
862
- this.native.addAnimation(visual.layer, map.keyPath, opts, key);
863
- // looked up after the removals above, which may have pruned the map
864
- let live = this.liveAnimations.get(node);
865
- if (!live) this.liveAnimations.set(node, (live = new Map()));
866
- live.set(id, { prop, key, entry });
867
- this._ends().set(id, (ev) => this._animationEnded(node, id, ev));
868
- }
869
- }
870
-
871
- /** Every animation running for `prop` on `node`, off the layer and out
872
- * of the books. */
873
- _removeLive(node, prop) {
874
- const live = this.liveAnimations.get(node);
875
- if (!live) return;
876
- const visual = this.visuals.get(node);
877
- for (const [id, run] of live) {
878
- if (run.prop !== prop) continue;
879
- live.delete(id);
880
- this._ends().delete(id);
881
- if (visual) this.native.removeAnimation(visual.layer, run.key);
882
- }
883
- if (live.size === 0) this.liveAnimations.delete(node);
884
- }
885
-
886
- /** The bridge's `animation-end` for one of ours — it ran out, or CA
887
- * dropped it. An older additive one ending changes nothing: the node
888
- * checks the entry is still the one it holds. */
889
- _animationEnded(node, id) {
890
- this._ends().delete(id);
891
- const live = this.liveAnimations.get(node);
892
- const run = live?.get(id);
893
- if (!run) return;
894
- live.delete(id);
895
- if (live.size === 0) this.liveAnimations.delete(node);
896
- node._offloadEnded(run.prop, run.entry);
897
- }
898
-
899
- /** The layer is going — the visual is destroyed, or turns into a raster —
900
- * and every animation on it goes with it. What was still waiting for a
901
- * frame goes back to the frame clock when the node stays (`reclaim`);
902
- * what was running is over, and the model shows. */
903
- _dropAnimations(node, reclaim) {
904
- const pending = this.pendingAnimations.get(node);
905
- if (pending) {
906
- this.pendingAnimations.delete(node);
907
- for (const [prop, entry] of pending) {
908
- if (reclaim) node._offloadDeclined(prop, entry);
909
- else node._offloadEnded(prop, entry);
910
- }
911
- }
912
- const live = this.liveAnimations.get(node);
913
- if (!live) return;
914
- this.liveAnimations.delete(node);
915
- for (const [id, run] of live) {
916
- this._ends().delete(id);
917
- node._offloadEnded(run.prop, run.entry);
918
- }
971
+ this.animations.cancel(node, prop);
919
972
  }
920
973
 
921
974
  /**