react-x11 2.6.0 → 2.7.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 (60) hide show
  1. package/README.md +5 -3
  2. package/package.json +10 -3
  3. package/src/activate.js +12 -0
  4. package/src/anchor.js +6 -0
  5. package/src/appearance.js +351 -28
  6. package/src/appearancehooks.js +5 -2
  7. package/src/application.js +41 -0
  8. package/src/cocoa/app.js +358 -20
  9. package/src/cocoa/bezels.js +51 -1
  10. package/src/cocoa/context2d.js +271 -25
  11. package/src/cocoa/dnd.js +347 -0
  12. package/src/cocoa/dock.js +39 -0
  13. package/src/cocoa/filepanels.js +155 -0
  14. package/src/cocoa/fonts.js +93 -2
  15. package/src/cocoa/globalmenu.js +41 -33
  16. package/src/cocoa/notifications.js +244 -0
  17. package/src/cocoa/permissions.js +74 -0
  18. package/src/cocoa/presenter.js +190 -2
  19. package/src/cocoa/statusitem.js +112 -0
  20. package/src/cocoa/window.js +85 -4
  21. package/src/components/Button.js +20 -1
  22. package/src/components/Checkbox.js +17 -2
  23. package/src/components/Menu.js +108 -38
  24. package/src/components/Radio.js +17 -2
  25. package/src/components/Select.js +159 -27
  26. package/src/components/Switch.js +8 -1
  27. package/src/components/native.js +99 -0
  28. package/src/components/theme.js +37 -20
  29. package/src/desktopsettings.js +34 -2
  30. package/src/dnd.js +92 -3
  31. package/src/errors.js +6 -3
  32. package/src/filedialog.js +81 -16
  33. package/src/index.d.ts +17 -1
  34. package/src/index.js +17 -0
  35. package/src/launcher.js +170 -0
  36. package/src/launcherhooks.js +81 -0
  37. package/src/nodes.js +553 -35
  38. package/src/notificationhooks.js +56 -0
  39. package/src/notifications.js +558 -0
  40. package/src/palette.js +144 -8
  41. package/src/permissionhooks.js +89 -0
  42. package/src/permissions.js +196 -0
  43. package/src/screens.js +39 -4
  44. package/src/style.d.ts +10 -4
  45. package/src/style.js +1 -0
  46. package/src/styles.js +161 -15
  47. package/src/textselection.js +1 -4
  48. package/src/trayhooks.js +90 -0
  49. package/src/types/appearance.d.ts +24 -0
  50. package/src/types/components.d.ts +10 -0
  51. package/src/types/elements.d.ts +14 -0
  52. package/src/types/events.d.ts +14 -0
  53. package/src/types/filedialog.d.ts +18 -7
  54. package/src/types/launcher.d.ts +43 -0
  55. package/src/types/notifications.d.ts +113 -0
  56. package/src/types/permissions.d.ts +100 -0
  57. package/src/types/style.d.ts +30 -2
  58. package/src/types/system.d.ts +5 -3
  59. package/src/types/tray.d.ts +54 -0
  60. package/src/windowid.js +23 -0
@@ -51,6 +51,41 @@ function keyEquivalent(shortcut) {
51
51
  return { key: key.toLowerCase(), modifiers: modifiers || FLAG_COMMAND };
52
52
  }
53
53
 
54
+ /**
55
+ * A dbusmenu snapshot (`snapshot()`'s `Map<id, node>`) as the bridge's menu
56
+ * item spec — the vocabulary `setMainMenu`, `setDockMenu` and a status
57
+ * item's menu all take, so the menu bar, the Dock menu and the tray are one
58
+ * builder. `ids` defaults to the root's children.
59
+ */
60
+ export function menuItemsSpec(nodes, ids = nodes.get(ROOT_ID).childIds) {
61
+ return ids.map((id) => {
62
+ const node = nodes.get(id);
63
+ const props = node.props;
64
+ const out = { id };
65
+ if (props.type === 'separator') {
66
+ out.separator = true;
67
+ if (props.visible === false) out.hidden = true;
68
+ return out;
69
+ }
70
+ out.title = String(props.label ?? '');
71
+ if (props.enabled === false) out.enabled = false;
72
+ if (props.visible === false) out.hidden = true;
73
+ if (props['toggle-state'] === 1) out.checked = true;
74
+ // the serialisable icon pair (menuitem.js): the name is read in the
75
+ // platform's icon theme — SF Symbols here, freedesktop on a Linux panel
76
+ // — and the bytes are the literal-pixel fallback
77
+ if (props['icon-name']) out.iconName = props['icon-name'];
78
+ if (props['icon-data']) out.iconData = props['icon-data'];
79
+ const key = keyEquivalent(props.shortcut);
80
+ if (key) {
81
+ out.key = key.key;
82
+ out.modifiers = key.modifiers;
83
+ }
84
+ if (node.childIds.length) out.items = menuItemsSpec(nodes, node.childIds);
85
+ return out;
86
+ });
87
+ }
88
+
54
89
  export class CocoaGlobalMenuExport {
55
90
  constructor(app, { getMenus, onSelect, onAboutToShow, target, onChange }) {
56
91
  this.app = app;
@@ -93,35 +128,7 @@ export class CocoaGlobalMenuExport {
93
128
  * `activate` like every real item.
94
129
  */
95
130
  _spec() {
96
- const itemsOf = (ids) =>
97
- ids.map((id) => {
98
- const node = this.nodes.get(id);
99
- const props = node.props;
100
- const out = { id };
101
- if (props.type === 'separator') {
102
- out.separator = true;
103
- if (props.visible === false) out.hidden = true;
104
- return out;
105
- }
106
- out.title = String(props.label ?? '');
107
- if (props.enabled === false) out.enabled = false;
108
- if (props.visible === false) out.hidden = true;
109
- if (props['toggle-state'] === 1) out.checked = true;
110
- // the serialisable icon pair (menuitem.js): the name is read in
111
- // the platform's icon theme — SF Symbols here, freedesktop on a
112
- // Linux panel — and the bytes are the literal-pixel fallback
113
- if (props['icon-name']) out.iconName = props['icon-name'];
114
- if (props['icon-data']) out.iconData = props['icon-data'];
115
- const key = keyEquivalent(props.shortcut);
116
- if (key) {
117
- out.key = key.key;
118
- out.modifiers = key.modifiers;
119
- }
120
- if (node.childIds.length) out.items = itemsOf(node.childIds);
121
- return out;
122
- });
123
- const root = this.nodes.get(ROOT_ID);
124
- const menus = itemsOf(root.childIds).map((menu) => ({
131
+ const menus = menuItemsSpec(this.nodes).map((menu) => ({
125
132
  title: menu.title ?? '',
126
133
  items: menu.items ?? [],
127
134
  }));
@@ -137,10 +144,11 @@ export class CocoaGlobalMenuExport {
137
144
  /** A backend menu-activate event landed on this export. */
138
145
  activate(id) {
139
146
  if (id === QUIT_ID) {
140
- // the same route the close button takes: the app decides what
141
- // closing means, exactly as it would for the red light
142
- const wnd = this.target?.window ?? [...this.app._windows.values()][0];
143
- wnd?.emit('close', { preventDefault() {} });
147
+ // ⌘Q is a quit request like the Dock's and a logout's: one route for
148
+ // all of them (`CocoaApp.requestQuit`), which is the primary window's
149
+ // close request the app decides what closing means, exactly as it
150
+ // would for the red light
151
+ this.app.requestQuit();
144
152
  return;
145
153
  }
146
154
  const item = this.nodes?.get(id)?.item;
@@ -0,0 +1,244 @@
1
+ // Desktop notifications on the cocoa backend — `UNUserNotificationCenter`
2
+ // through @windowkit/appkit (>= 0.5), the top rung of src/notifications.js's
3
+ // ladder. `CocoaApp.notifications` is this object; its presence is the
4
+ // capability, and its `available()` is the second gate: the centre only
5
+ // delivers for a code-signed app bundle with a bundle id, and a bare `node`
6
+ // process reports itself unavailable so the ladder can move on to
7
+ // `osascript` rather than post into the void.
8
+ //
9
+ // Three things about the system shape the code:
10
+ //
11
+ // - **Authorization is the system's prompt, once per bundle.** The first
12
+ // post from an app the user has not answered for asks first, and a
13
+ // refusal is a refusal — reported, not fallen through.
14
+ // - **Actions are categories.** The centre knows an action set only as a
15
+ // registered category, and `setNotificationCategories` *replaces* the set,
16
+ // so every distinct action list this process has used stays registered
17
+ // under a name derived from its keys. A click on the banner itself is the
18
+ // action `default`, the freedesktop word the ladder speaks.
19
+ // - **The user's answer comes back as an event** on the app's callback,
20
+ // naming the notification by identifier — `notification-action` and
21
+ // `notification-dismissed`, which the app routes here.
22
+
23
+ function categoryId(actions) {
24
+ return `react-x11:${actions.map((a) => a.key).join(',')}`;
25
+ }
26
+
27
+ export class CocoaNotifications {
28
+ constructor(app) {
29
+ this.app = app;
30
+ this._native = app._native;
31
+ this._handles = new Map(); // identifier -> handle
32
+ this._categories = new Map(); // id -> { id, actions }
33
+ this._settings = null;
34
+ this._authorized = null;
35
+ }
36
+
37
+ /** The bridge's settings, read once — `available` is the bundle question. */
38
+ settings() {
39
+ if (this._settings) return this._settings;
40
+ this._settings = new Promise((resolve) => {
41
+ try {
42
+ this._native.notificationSettings((s) =>
43
+ resolve(s ?? { available: false }),
44
+ );
45
+ } catch {
46
+ resolve({ available: false });
47
+ }
48
+ });
49
+ return this._settings;
50
+ }
51
+
52
+ async available() {
53
+ const s = await this.settings();
54
+ return s?.available === true;
55
+ }
56
+
57
+ /**
58
+ * Ask once; the system remembers, and so does this.
59
+ *
60
+ * Three answers, not two. `granted` and `denied` are the obvious pair, and
61
+ * `unasked` is the one that matters: a falsy `granted` does **not** mean
62
+ * the user said no. A bundle Launch Services has never registered — an
63
+ * ad-hoc signature in a temp directory, say — gets no prompt at all, and
64
+ * the request comes back refused with the status still `notDetermined`.
65
+ * Calling that a refusal would tell the user they declined something they
66
+ * were never shown, and would stop a ladder that has every right to carry
67
+ * on: nobody has turned anything off. So the status is read again
68
+ * afterwards, and it is the system, not the boolean, that says which
69
+ * happened.
70
+ *
71
+ * @returns {Promise<'granted'|'denied'|'unasked'>}
72
+ */
73
+ async _authorize() {
74
+ if (this._authorized != null) return this._authorized;
75
+ const s = await this.settings();
76
+ if (
77
+ s.authorizationStatus === 'authorized' ||
78
+ s.authorizationStatus === 'provisional'
79
+ ) {
80
+ this._authorized = 'granted';
81
+ return this._authorized;
82
+ }
83
+ if (s.authorizationStatus === 'denied') {
84
+ this._authorized = 'denied';
85
+ return this._authorized;
86
+ }
87
+ const granted = await new Promise((resolve) => {
88
+ try {
89
+ this._native.requestNotificationAuthorization(
90
+ ['alert', 'sound', 'badge'],
91
+ (ok) => resolve(Boolean(ok)),
92
+ );
93
+ } catch {
94
+ resolve(false);
95
+ }
96
+ });
97
+ if (granted) {
98
+ this._authorized = 'granted';
99
+ return this._authorized;
100
+ }
101
+ // Refused — by the user, or by a system that never asked one. The prompt
102
+ // moves the status off `notDetermined`; nothing else does.
103
+ this._settings = null;
104
+ const after = await this.settings();
105
+ if (after.authorizationStatus === 'notDetermined') {
106
+ // Not cached: a later post, from a bundle the system has since
107
+ // registered, deserves the prompt this one never got.
108
+ return 'unasked';
109
+ }
110
+ this._authorized = 'denied';
111
+ return this._authorized;
112
+ }
113
+
114
+ _ensureCategory(actions) {
115
+ if (!actions?.length) return undefined;
116
+ const id = categoryId(actions);
117
+ if (!this._categories.has(id)) {
118
+ this._categories.set(id, {
119
+ id,
120
+ actions: actions.map((a) => ({
121
+ id: a.key,
122
+ title: a.label ?? a.key,
123
+ foreground: true,
124
+ })),
125
+ });
126
+ this._native.setNotificationCategories([...this._categories.values()]);
127
+ }
128
+ return id;
129
+ }
130
+
131
+ _props(options, identifier) {
132
+ const props = {
133
+ title: options.summary,
134
+ body: options.body ?? '',
135
+ sound: options.silent ? null : 'default',
136
+ };
137
+ if (identifier) props.identifier = identifier;
138
+ if (options.subtitle) props.subtitle = options.subtitle;
139
+ const categoryId = this._ensureCategory(options.actions);
140
+ if (categoryId) props.categoryId = categoryId;
141
+ if (options.userInfo) props.userInfo = options.userInfo;
142
+ return props;
143
+ }
144
+
145
+ /**
146
+ * Post, if the user has allowed it.
147
+ *
148
+ * `null` rather than a throw when the centre could not ask (see
149
+ * `_authorize`): the ladder reads that as "this rung cannot serve" and
150
+ * moves on. A real refusal still throws, because it is an answer.
151
+ */
152
+ async post(options) {
153
+ const auth = await this._authorize();
154
+ if (auth === 'denied') {
155
+ const err = new Error(
156
+ 'react-x11: notifications are not allowed for this app — the user ' +
157
+ 'declined them in System Settings › Notifications.',
158
+ );
159
+ err.name = 'NotificationsDeniedError';
160
+ throw err;
161
+ }
162
+ if (auth !== 'granted') return null;
163
+ const handle = new CocoaHandle(this, options);
164
+ await handle._post({});
165
+ return handle;
166
+ }
167
+
168
+ /** `notification-action` / `notification-dismissed` from the app. */
169
+ route(ev) {
170
+ const handle = this._handles.get(ev.identifier);
171
+ if (!handle) return;
172
+ if (ev.type === 'notification-action') {
173
+ handle._action(
174
+ ev.actionId === 'default' ? 'default' : String(ev.actionId),
175
+ );
176
+ } else if (ev.type === 'notification-dismissed') {
177
+ this._handles.delete(ev.identifier);
178
+ handle._closed('dismissed');
179
+ }
180
+ }
181
+ }
182
+
183
+ class CocoaHandle {
184
+ constructor(centre, options) {
185
+ this.backend = 'cocoa';
186
+ this.id = null;
187
+ this._centre = centre;
188
+ this._options = options;
189
+ this._done = false;
190
+ }
191
+
192
+ _post(patch) {
193
+ const centre = this._centre;
194
+ const native = centre._native;
195
+ this._options = { ...this._options, ...patch };
196
+ const props = centre._props(this._options, this.id);
197
+ return new Promise((resolve, reject) => {
198
+ const done = (error) => (error ? reject(error) : resolve(this));
199
+ try {
200
+ if (this.id) {
201
+ native.updateNotification(this.id, props, done);
202
+ } else {
203
+ this.id = native.postNotification(props, done);
204
+ centre._handles.set(this.id, this);
205
+ }
206
+ } catch (err) {
207
+ reject(err);
208
+ }
209
+ });
210
+ }
211
+
212
+ update(patch = {}) {
213
+ if (this._done) return Promise.resolve(this);
214
+ return this._post(patch);
215
+ }
216
+
217
+ async close() {
218
+ if (this._done || !this.id) return;
219
+ this._centre._handles.delete(this.id);
220
+ try {
221
+ this._centre._native.removeNotification(this.id);
222
+ } finally {
223
+ this._closed('closed');
224
+ }
225
+ }
226
+
227
+ _action(key) {
228
+ try {
229
+ this._options.onAction?.(key);
230
+ } catch (err) {
231
+ console.error('react-x11: a notification action handler threw', err);
232
+ }
233
+ }
234
+
235
+ _closed(reason) {
236
+ if (this._done) return;
237
+ this._done = true;
238
+ try {
239
+ this._options.onClose?.(reason);
240
+ } catch (err) {
241
+ console.error('react-x11: a notification close handler threw', err);
242
+ }
243
+ }
244
+ }
@@ -0,0 +1,74 @@
1
+ // macOS privacy authorizations (TCC) on the cocoa backend, through
2
+ // @windowkit/appkit (>= 0.5): the rung that answers in src/permissions.js's
3
+ // ladder. `CocoaApp.permissions` is this object and its presence is the
4
+ // capability, the rule `filePanels` and `nativeBezels` follow.
5
+ //
6
+ // The bridge is mechanism — `authorizationStatus(kind, opts?)` never
7
+ // prompts, `requestAuthorization(kind, opts?, cb)` raises the system's
8
+ // prompt where a framework offers one and answers once, asynchronously,
9
+ // `openPrivacySettings(kind?)` deep-links — and its vocabulary is Apple's.
10
+ // What is decided here is the translation into the ladder's five words,
11
+ // and one thing worth knowing: a bare `node` process is attributed to its
12
+ // responsible process (the terminal, an IDE) or to `node` itself, and a
13
+ // bundled app must carry the usage-description keys
14
+ // (`NSCameraUsageDescription` and friends) or a request never prompts.
15
+
16
+ /** Apple's four words as the ladder's. */
17
+ const STATUS = Object.freeze({
18
+ authorized: 'granted',
19
+ denied: 'denied',
20
+ restricted: 'restricted',
21
+ notDetermined: 'prompt',
22
+ });
23
+
24
+ export function statusFromBridge(status) {
25
+ return STATUS[status] ?? 'unknown';
26
+ }
27
+
28
+ /** The bridge's options for a kind: `automation` carries its target. */
29
+ function bridgeOptions(kind, options = {}) {
30
+ return kind === 'automation' && options.target != null
31
+ ? { target: String(options.target) }
32
+ : undefined;
33
+ }
34
+
35
+ export class CocoaPermissions {
36
+ constructor(native) {
37
+ this._native = native;
38
+ }
39
+
40
+ status(kind, options) {
41
+ const opts = bridgeOptions(kind, options);
42
+ return statusFromBridge(
43
+ opts
44
+ ? this._native.authorizationStatus(kind, opts)
45
+ : this._native.authorizationStatus(kind),
46
+ );
47
+ }
48
+
49
+ request(kind, options) {
50
+ const opts = bridgeOptions(kind, options);
51
+ return new Promise((resolve, reject) => {
52
+ const done = (granted, status) =>
53
+ resolve(
54
+ status != null
55
+ ? statusFromBridge(status)
56
+ : granted
57
+ ? 'granted'
58
+ : 'denied',
59
+ );
60
+ try {
61
+ if (opts) this._native.requestAuthorization(kind, opts, done);
62
+ else this._native.requestAuthorization(kind, done);
63
+ } catch (err) {
64
+ reject(err);
65
+ }
66
+ });
67
+ }
68
+
69
+ openSettings(kind) {
70
+ if (kind == null) this._native.openPrivacySettings();
71
+ else this._native.openPrivacySettings(kind);
72
+ return true;
73
+ }
74
+ }
@@ -32,6 +32,7 @@ import {
32
32
  damageToPaint,
33
33
  intersectRects,
34
34
  } from '../nodes.js';
35
+ import { EASING_CONTROL_POINTS, TRANSITION_CONTROL_POINTS } from '../styles.js';
35
36
  import { CocoaContext2D } from './context2d.js';
36
37
 
37
38
  const RASTER_PAD = 2; // antialiasing/italic overhang outside the ink bounds
@@ -324,9 +325,8 @@ const EDGE_PROPS = [
324
325
  'borderEndWidth',
325
326
  ];
326
327
 
327
- function stylePaintsPlain(node) {
328
+ function stylePaintsPlain(node, style = node.style ?? {}) {
328
329
  if (node.kind !== 'box') return false;
329
- const style = node.style ?? {};
330
330
  if (style.backgroundImage || style.boxShadow || style.outlineWidth) {
331
331
  return false;
332
332
  }
@@ -339,6 +339,26 @@ function stylePaintsPlain(node) {
339
339
  return uniformRadius(style.borderRadius) !== null;
340
340
  }
341
341
 
342
+ /**
343
+ * Style property → the key path on a PropBox's own layer, for the
344
+ * animations the presenter takes off the frame clock and hands to the
345
+ * render server (docs/architecture/animation.md §4.2). Only what the layer
346
+ * expresses as a property of itself qualifies: a colour on `<text>` is a
347
+ * re-raster per frame, a layout property moves the siblings, and both stay
348
+ * on the JS loop. `scaled` values go out in points, as `_syncPropBox`
349
+ * sends them.
350
+ */
351
+ const ANIMATED_KEY_PATHS = Object.freeze({
352
+ backgroundColor: { keyPath: 'backgroundColor', colour: true },
353
+ borderColor: { keyPath: 'borderColor', colour: true },
354
+ borderWidth: { keyPath: 'borderWidth', scaled: true },
355
+ borderRadius: { keyPath: 'cornerRadius', scaled: true },
356
+ });
357
+
358
+ // the ids the bridge reports an animation's end under: unique per process,
359
+ // looked up per app (`_animationEnds`)
360
+ let animationSeq = 0;
361
+
342
362
  function uniformRadius(radius) {
343
363
  if (radius === undefined) return 0;
344
364
  if (typeof radius === 'number') return radius;
@@ -439,6 +459,10 @@ export class CocoaLayerPresenter {
439
459
  this.visuals = new Map(); // node -> Visual
440
460
  this.rasters = new Map(); // node -> RasterState
441
461
  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 })
442
466
  // Claims since the last frame — taken at the top of `frame()`, the way
443
467
  // the X11 path takes its damage before painting, so a claim made from
444
468
  // inside a paint lands in the next frame instead of being cleared with
@@ -548,6 +572,7 @@ export class CocoaLayerPresenter {
548
572
  visual.destroy();
549
573
  this.visuals.delete(node);
550
574
  this._dropRaster(node);
575
+ this._dropAnimations(node, false);
551
576
  const bars = this.bars.get(node);
552
577
  if (bars) {
553
578
  for (const entry of bars.values()) {
@@ -612,8 +637,14 @@ export class CocoaLayerPresenter {
612
637
  if (visual && visual.isRaster !== wantsRaster) {
613
638
  visual.destroy();
614
639
  this._dropRaster(node);
640
+ // a layer that turns into a raster takes its animations with it; the
641
+ // frame clock can still run them over the bitmap
642
+ if (wantsRaster) this._dropAnimations(node, true);
615
643
  visual = null;
616
644
  }
645
+ if (wantsRaster && this.pendingAnimations.has(node)) {
646
+ this._dropAnimations(node, true);
647
+ }
617
648
  if (!visual) {
618
649
  visual = new Visual(this, node);
619
650
  visual.isRaster = wantsRaster;
@@ -728,6 +759,163 @@ export class CocoaLayerPresenter {
728
759
  ])
729
760
  : [0, 0, 0, 0],
730
761
  });
762
+ // after the model value went out, inside the same transaction
763
+ this._applyAnimations(node, visual);
764
+ }
765
+
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
+ */
783
+ 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;
794
+ }
795
+
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. */
798
+ 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
+ }
731
919
  }
732
920
 
733
921
  /**