react-x11 2.15.2 → 2.16.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 (64) hide show
  1. package/README.md +37 -0
  2. package/package.json +4 -3
  3. package/src/Reconciler.js +85 -22
  4. package/src/anchor.js +60 -18
  5. package/src/application.js +25 -1
  6. package/src/capabilities.js +349 -0
  7. package/src/cocoa/app.js +28 -9
  8. package/src/cocoa/context2d.js +139 -6
  9. package/src/cocoa/fonts.js +78 -0
  10. package/src/cocoa/presenter.js +17 -0
  11. package/src/cocoa/promotion.js +20 -0
  12. package/src/cocoa/relaunch.js +8 -3
  13. package/src/cocoa/symbols.js +64 -0
  14. package/src/cocoa/threaded.js +24 -4
  15. package/src/cocoa/window.js +362 -139
  16. package/src/components/ProgressBar.js +1 -1
  17. package/src/components/Slider.js +72 -39
  18. package/src/components/anchor.js +7 -2
  19. package/src/components/index.js +1 -0
  20. package/src/components/theme.js +32 -28
  21. package/src/dbusmenuexport.js +243 -0
  22. package/src/desktopcapabilityhooks.js +160 -0
  23. package/src/filedialoghooks.js +3 -5
  24. package/src/frame/childmain.js +8 -20
  25. package/src/frame/env.js +2 -10
  26. package/src/globalmenu.js +3 -205
  27. package/src/icontheme.js +240 -0
  28. package/src/imagesource.js +98 -3
  29. package/src/index.d.ts +1 -0
  30. package/src/index.js +11 -2
  31. package/src/launcher.js +235 -32
  32. package/src/launcherhooks.js +47 -28
  33. package/src/node.d.ts +7 -0
  34. package/src/nodes/animation.js +17 -47
  35. package/src/nodes/cascade.js +17 -2
  36. package/src/nodes/image.js +65 -2
  37. package/src/nodes/kinds.js +12 -0
  38. package/src/nodes/layout.js +5 -1
  39. package/src/nodes/node.js +17 -3
  40. package/src/nodes/paint.js +117 -0
  41. package/src/nodes/scope.js +259 -0
  42. package/src/nodes/scrollable.js +53 -6
  43. package/src/nodes/text.js +2 -0
  44. package/src/nodes/textarea.js +1 -1
  45. package/src/nodes/textinput.js +1 -1
  46. package/src/nodes/window/anchoring.js +45 -18
  47. package/src/nodes/window/flush.js +6 -5
  48. package/src/nodes/window/popup.js +10 -0
  49. package/src/nodes/window/size.js +40 -2
  50. package/src/nodes/window/window.js +41 -14
  51. package/src/registry.js +2 -1
  52. package/src/settings.js +332 -0
  53. package/src/statusnotifier.js +752 -0
  54. package/src/styles.js +212 -8
  55. package/src/symbols.js +200 -0
  56. package/src/testing/mock-app.js +10 -0
  57. package/src/trayhooks.js +193 -29
  58. package/src/types/capabilities.d.ts +139 -0
  59. package/src/types/components.d.ts +33 -0
  60. package/src/types/elements.d.ts +57 -6
  61. package/src/types/launcher.d.ts +50 -4
  62. package/src/types/style.d.ts +57 -0
  63. package/src/types/system.d.ts +104 -0
  64. package/src/types/tray.d.ts +64 -6
package/src/launcher.js CHANGED
@@ -1,35 +1,55 @@
1
- // The launcher's view of the app: the badge on its icon.
1
+ // The launcher's view of the app: the badge on its icon, a progress bar
2
+ // across it, an attention flag, and the menu behind a right-click.
2
3
  //
3
4
  // A count on the Dock tile, a dot on the taskbar entry — the one thing an
4
5
  // app says to the desktop that the user reads without opening it. Two
5
- // desktops, two mechanisms, one call:
6
+ // desktops, two mechanisms, one call each:
6
7
  //
7
- // 1. **the app's own tile** — the cocoa backend's `NSDockTile.badgeLabel`,
8
- // reached through the app object (`setDockBadge`, src/cocoa/app.js).
9
- // Any string shows, and it is the whole of the API on a Mac.
8
+ // 1. **the app's own tile** — the cocoa backend's `NSDockTile`, reached
9
+ // through the app object (`setDockBadge`, `setDockMenu`, src/cocoa/
10
+ // app.js).
10
11
  // 2. **`com.canonical.Unity.LauncherEntry`** over the session bus — the
11
- // protocol Unity defined and the KDE, elementary and Cairo-Dock
12
- // launchers still listen for. One signal, `Update(app_uri, a{sv})`,
13
- // carrying a `count` and whether it is visible, attributed to the app
14
- // by `application://<id>.desktop` — which is why it needs the identity
12
+ // protocol Unity defined and the KDE, elementary, Cairo-Dock and
13
+ // Dash-to-Dock launchers still listen for. One signal,
14
+ // `Update(app_uri, a{sv})`, attributed to the app by
15
+ // `application://<id>.desktop` — which is why it needs the identity
15
16
  // `registerApplication({ appId })` established: with no app id there is
16
- // nothing for a launcher to pin the count to, and the call resolves
17
+ // nothing for a launcher to pin anything to, and the calls resolve
17
18
  // false rather than guessing.
18
19
  //
19
- // The two disagree on what a badge *is*, and the API takes the stricter
20
- // shape: a **count**. A number shows on both; a string shows on macOS and is
21
- // a visible count of nothing on Linux (the protocol has no text field), so
22
- // pass a string only where the Mac is the audience. `0`, `null` and `''`
23
- // all clear it, because a badge of zero is the badge nobody wanted.
20
+ // The badge takes the stricter of the two shapes: a **count**. A number shows
21
+ // on both; a string shows on macOS and is a visible count of nothing on Linux
22
+ // (the protocol has no text field), so pass a string only where the Mac is
23
+ // the audience. `0`, `null` and `''` all clear it, because a badge of zero is
24
+ // the badge nobody wanted.
24
25
  //
25
- // Nothing here imports react: `useBadge` (launcherhooks.js) is the hook, and
26
- // this is the function under it, callable from host-side code with no tree.
27
- // The entry on the bus is held for as long as the badge is shown and
28
- // released when it is cleareda held bus ref is a ref()'d socket, and an
29
- // app that cleared its badge on the way out should be free to exit.
26
+ // ## The quicklist is not "desktop actions"
27
+ //
28
+ // This module used to say the freedesktop counterpart of the Dock menu was
29
+ // `Actions=` in the `.desktop` filean install step, not runtime code and
30
+ // that the hook was therefore cocoa-only. That was half the story and the
31
+ // wrong half. `LauncherEntry` carries a **`quicklist`** property: an object
32
+ // path to a `com.canonical.dbusmenu` tree, which is the same menu protocol
33
+ // the global menu and the tray already speak, built from the same items. It
34
+ // is runtime, it follows state, and Dash-to-Dock/ubuntu-dock, Plank and the
35
+ // Unity heritage launchers all render it.
36
+ //
37
+ // So `useDockMenu()` has a Linux rung after all, and the three menus an app
38
+ // puts on the desktop — panel, tray, launcher — are now one authoring model
39
+ // on both backends. `.desktop` actions are still the right place for entries
40
+ // that must work *while the app is not running*; they are a different feature
41
+ // wearing a similar hat.
42
+ //
43
+ // Nothing here imports react: `useBadge`/`useDockMenu` (launcherhooks.js) are
44
+ // the hooks, and these are the functions under them, callable from host-side
45
+ // code with no tree. The entry on the bus is held for as long as anything is
46
+ // shown and released when the last of it is cleared — a held bus ref is a
47
+ // ref()'d socket, and an app that cleared its badge on the way out should be
48
+ // free to exit.
30
49
 
31
50
  import { currentRegistration } from './application.js';
32
51
  import { loadTransport, sessionBus } from './bus.js';
52
+ import { DbusMenuExport } from './dbusmenuexport.js';
33
53
  import { liveApps } from './trace-registry.js';
34
54
 
35
55
  export const LAUNCHER_ENTRY_IFACE = 'com.canonical.Unity.LauncherEntry';
@@ -78,6 +98,29 @@ function soleApp() {
78
98
  /** The one exported entry per process — an app has one icon. */
79
99
  let entry = null;
80
100
 
101
+ /**
102
+ * What the launcher currently believes, so an `Update` can carry the whole of
103
+ * it. The protocol's dict is a *patch* and every launcher merges it, but
104
+ * sending the full state makes the call idempotent and means a launcher that
105
+ * restarted and missed a patch is corrected by the next one of any kind.
106
+ */
107
+ function blankState() {
108
+ return {
109
+ count: 0,
110
+ countVisible: false,
111
+ progress: 0,
112
+ progressVisible: false,
113
+ urgent: false,
114
+ };
115
+ }
116
+
117
+ /** Is anything still being shown? When not, the entry can go. */
118
+ function stateIsEmpty(state, hasMenu) {
119
+ return (
120
+ !hasMenu && !state.countVisible && !state.progressVisible && !state.urgent
121
+ );
122
+ }
123
+
81
124
  async function launcherEntry(appUri) {
82
125
  if (entry && entry.appUri === appUri) return entry;
83
126
  if (entry) await releaseEntry();
@@ -104,7 +147,17 @@ async function launcherEntry(appUri) {
104
147
  await ref.release();
105
148
  return null;
106
149
  }
107
- entry = { appUri, ref, dbus, iface, registration };
150
+ entry = {
151
+ appUri,
152
+ ref,
153
+ dbus,
154
+ iface,
155
+ registration,
156
+ state: blankState(),
157
+ menu: null,
158
+ menuRegistration: null,
159
+ menuPath: `${launcherEntryPath(appUri)}/Menu`,
160
+ };
108
161
  return entry;
109
162
  }
110
163
 
@@ -112,10 +165,53 @@ async function releaseEntry() {
112
165
  const held = entry;
113
166
  entry = null;
114
167
  if (!held) return;
168
+ await held.menuRegistration?.remove?.().catch(() => {});
115
169
  await held.registration?.remove?.().catch(() => {});
116
170
  await held.ref.release();
117
171
  }
118
172
 
173
+ /** Emit the whole of the current state under the app's URI. */
174
+ function emit(held) {
175
+ const V = held.dbus.Variant;
176
+ const props = {
177
+ count: new V('x', held.state.count),
178
+ 'count-visible': new V('b', held.state.countVisible),
179
+ progress: new V('d', held.state.progress),
180
+ 'progress-visible': new V('b', held.state.progressVisible),
181
+ urgent: new V('b', held.state.urgent),
182
+ };
183
+ // Only advertised when there is one: a path to an object that is not
184
+ // exported makes a launcher build a menu client against nothing, and
185
+ // Dash-to-Dock keeps the stale client until the path *changes*.
186
+ if (held.menuRegistration) props.quicklist = new V('o', held.menuPath);
187
+ held.iface.emit.Update(held.appUri, props);
188
+ }
189
+
190
+ /**
191
+ * The shared half of every setter: find the identity, take or make the entry,
192
+ * let `mutate` move the state, emit, and drop the entry if nothing is left.
193
+ *
194
+ * `mutate` returns false to mean "nothing changed and nothing was being
195
+ * shown" — the case where clearing something that was already clear must not
196
+ * dial the bus.
197
+ */
198
+ async function withEntry(mutate, { clearing }) {
199
+ const appId = currentRegistration()?.appId;
200
+ if (!appId) {
201
+ if (clearing && entry) await releaseEntry();
202
+ return false;
203
+ }
204
+ if (clearing && !entry) return false; // nothing shown, nothing to clear
205
+ const held = await launcherEntry(launcherAppUri(appId));
206
+ if (!held) return false;
207
+ mutate(held.state);
208
+ emit(held);
209
+ if (stateIsEmpty(held.state, Boolean(held.menuRegistration))) {
210
+ await releaseEntry();
211
+ }
212
+ return true;
213
+ }
214
+
119
215
  /**
120
216
  * Show `value` on the app's icon, or clear it.
121
217
  *
@@ -144,23 +240,130 @@ export async function setBadge(value, { app } = {}) {
144
240
  }
145
241
 
146
242
  // Rung 2: the launcher protocol, which needs an identity to badge.
243
+ const count =
244
+ typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : 0;
245
+ return withEntry(
246
+ (state) => {
247
+ state.count = label === null ? 0 : count;
248
+ state.countVisible = label !== null;
249
+ },
250
+ { clearing: label === null },
251
+ );
252
+ }
253
+
254
+ /**
255
+ * A progress bar across the app's icon: `0`…`1`, or `null` to clear it.
256
+ *
257
+ * ```js
258
+ * await setProgress(downloaded / total);
259
+ * await setProgress(null); // done
260
+ * ```
261
+ *
262
+ * The launcher protocol's `progress`, which ubuntu-dock, Plank and the
263
+ * Unity-heritage launchers draw across the tile. **Inert on the cocoa
264
+ * backend**: `NSDockTile` has no progress of its own — an app that wants one
265
+ * on a Mac draws it into a custom tile view, which is app-side art rather
266
+ * than a call. Resolves to whether a launcher was told.
267
+ */
268
+ export async function setProgress(value, { app } = {}) {
269
+ const target = app ?? soleApp();
270
+ // No cocoa rung to try: the Dock has no progress. Left deliberately without
271
+ // a `setDockProgress` probe so that adding one to the bridge later is the
272
+ // only change needed here.
273
+ void target;
274
+ const clearing =
275
+ value == null || value === false || !Number.isFinite(Number(value));
276
+ const clamped = clearing ? 0 : Math.min(1, Math.max(0, Number(value)));
277
+ return withEntry(
278
+ (state) => {
279
+ state.progress = clamped;
280
+ state.progressVisible = !clearing;
281
+ },
282
+ { clearing },
283
+ );
284
+ }
285
+
286
+ /**
287
+ * Ask the launcher for the user's attention, or stop asking.
288
+ *
289
+ * The launcher protocol's `urgent`, which is the tile bouncing or glowing.
290
+ * Distinct from `<window states={['demands_attention']}>`, which is the
291
+ * *window's* urgency hint and what a taskbar blinks: this one marks the app's
292
+ * icon in the launcher whether or not any window is open. On the cocoa
293
+ * backend the window state is the mechanism and this is inert.
294
+ */
295
+ export async function setUrgent(urgent, { app } = {}) {
296
+ void (app ?? soleApp());
297
+ return withEntry((state) => void (state.urgent = Boolean(urgent)), {
298
+ clearing: !urgent,
299
+ });
300
+ }
301
+
302
+ /**
303
+ * The menu behind a right-click on the app's launcher icon — the quicklist.
304
+ *
305
+ * Takes `MenuBar`'s item vocabulary and exports it as a `com.canonical.dbusmenu`
306
+ * tree, exactly as the tray and the global menu do. `null` takes it down.
307
+ * Resolves to whether a launcher was told.
308
+ *
309
+ * The menu is exported **before** the property naming it is emitted, so a
310
+ * launcher that builds its client the instant it sees `quicklist` finds an
311
+ * object there.
312
+ */
313
+ export async function setQuicklist(items, { app } = {}) {
314
+ const target = app ?? soleApp();
315
+
316
+ // Rung 1: the app's own tile menu.
317
+ if (typeof target?.setDockMenu === 'function') {
318
+ target.setDockMenu(items ?? null);
319
+ return true;
320
+ }
321
+
147
322
  const appId = currentRegistration()?.appId;
148
323
  if (!appId) {
149
- if (label === null) await releaseEntry();
324
+ if (!items && entry) await releaseEntry();
150
325
  return false;
151
326
  }
152
- const appUri = launcherAppUri(appId);
153
- if (label === null && !entry) return false; // nothing shown, nothing to clear
154
- const held = await launcherEntry(appUri);
327
+ if (!items) {
328
+ // Taking the menu down: drop the export, then say so.
329
+ if (!entry?.menuRegistration) return false;
330
+ const held = entry;
331
+ const registration = held.menuRegistration;
332
+ held.menuRegistration = null;
333
+ held.menu = null;
334
+ await registration?.remove?.().catch(() => {});
335
+ // `quicklist` is omitted rather than set empty — there is no "no menu"
336
+ // value in the protocol, and an object path to nothing is worse than a
337
+ // property a launcher never saw.
338
+ emit(held);
339
+ if (stateIsEmpty(held.state, false)) await releaseEntry();
340
+ return true;
341
+ }
342
+
343
+ const held = await launcherEntry(launcherAppUri(appId));
155
344
  if (!held) return false;
156
- const count =
157
- typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : 0;
158
- const V = held.dbus.Variant;
159
- held.iface.emit.Update(appUri, {
160
- count: new V('x', label === null ? 0 : count),
161
- 'count-visible': new V('b', label !== null),
345
+
346
+ if (held.menu) {
347
+ // Already exported: this is a re-render, and the tree diffs itself.
348
+ held.menu.update(items);
349
+ return true;
350
+ }
351
+
352
+ const menu = new DbusMenuExport({
353
+ getMenus: () => items,
354
+ onSelect: (item) => item.onSelect?.(),
355
+ onAboutToShow: (item) => item.onAboutToShow?.(),
162
356
  });
163
- if (label === null) await releaseEntry();
357
+ const iface = menu.defineMenu(held.dbus);
358
+ try {
359
+ held.menuRegistration = await held.ref.bus.export(held.menuPath, iface);
360
+ } catch {
361
+ return false;
362
+ }
363
+ menu.iface = iface;
364
+ menu.exported = true;
365
+ held.menu = menu;
366
+ emit(held);
164
367
  return true;
165
368
  }
166
369
 
@@ -1,17 +1,21 @@
1
- // `useBadge()` and `useDockMenu()` — the launcher's view of the app, as
2
- // things a component declares rather than manages.
1
+ // `useBadge()`, `useProgress()` and `useDockMenu()` — the launcher's view of
2
+ // the app, as things a component declares rather than manages.
3
3
  //
4
- // The badge lives in `launcher.js` and is cross-backend; the Dock menu is
5
- // the cocoa backend's alone, because its freedesktop counterpart desktop
6
- // actions in the `.desktop` file is an install step and not runtime code.
7
- // Where a hook has no mechanism it is inert, with a one-time development
8
- // note naming the backend: the inert-props policy of docs/macos.md, so
9
- // shared app code stays branch-free.
4
+ // All three now have a rung on both backends except progress, which has one
5
+ // only on Linux (`NSDockTile` has no progress bar; see `setProgress`). Where
6
+ // a hook has no mechanism it is inert and silent: a mark on an icon is not a
7
+ // feature an app should branch on, and a development warning for every
8
+ // desktop that lacks one is a warning nobody can act on.
9
+ //
10
+ // The one that changed shape is `useDockMenu`. It used to be cocoa-only on
11
+ // the grounds that the freedesktop counterpart was an install step — see
12
+ // `launcher.js`, where that reasoning is corrected: the launcher protocol
13
+ // carries a `quicklist` dbusmenu, so the Dock menu is runtime code on both.
10
14
 
11
15
  import { useEffect, useRef } from 'react';
12
16
 
13
17
  import { useAppOrNull } from './appcontext.js';
14
- import { setBadge } from './launcher.js';
18
+ import { setBadge, setProgress, setQuicklist } from './launcher.js';
15
19
 
16
20
  /**
17
21
  * Show `value` on the app's icon while this component is mounted, and clear
@@ -37,12 +41,31 @@ export function useBadge(value) {
37
41
  }, [app]);
38
42
  }
39
43
 
40
- let warnedInert = false;
44
+ /**
45
+ * A progress bar across the app's icon, `0`…`1`, cleared on unmount.
46
+ *
47
+ * ```jsx
48
+ * useProgress(done / total); // null or undefined clears it
49
+ * ```
50
+ *
51
+ * Linux launchers only — the Dock has no progress bar of its own.
52
+ */
53
+ export function useProgress(value) {
54
+ const app = useAppOrNull();
55
+ useEffect(() => {
56
+ setProgress(value, { app }).catch(() => {});
57
+ }, [value, app]);
58
+ useEffect(() => {
59
+ return () => {
60
+ setProgress(null, { app }).catch(() => {});
61
+ };
62
+ }, [app]);
63
+ }
41
64
 
42
65
  /**
43
- * The menu behind a right-click on the Dock icon, from the same `items`
44
- * vocabulary `MenuBar` and `ContextMenu` take — an item's `onSelect` fires
45
- * when the user picks it.
66
+ * The menu behind a right-click on the app's icon in the Dock or launcher,
67
+ * from the same `items` vocabulary `MenuBar` and `ContextMenu` take — an
68
+ * item's `onSelect` fires when the user picks it.
46
69
  *
47
70
  * ```jsx
48
71
  * useDockMenu([
@@ -53,8 +76,10 @@ let warnedInert = false;
53
76
  * ```
54
77
  *
55
78
  * Installed for as long as the component is mounted, replaced when `items`
56
- * changes, and taken down on unmount. Inert off the cocoa backend, with a
57
- * development note the first time.
79
+ * changes, and taken down on unmount. `NSDockTile`'s menu on the cocoa
80
+ * backend; the launcher protocol's `quicklist` on Linux, which needs the
81
+ * identity `registerApplication({ appId })` establishes and a `.desktop` file
82
+ * of that name for a launcher to attach it to.
58
83
  */
59
84
  export function useDockMenu(items) {
60
85
  const app = useAppOrNull();
@@ -64,18 +89,12 @@ export function useDockMenu(items) {
64
89
  live.current = items;
65
90
 
66
91
  useEffect(() => {
67
- if (typeof app?.setDockMenu !== 'function') {
68
- if (process.env.NODE_ENV !== 'production' && !warnedInert && app) {
69
- warnedInert = true;
70
- console.warn(
71
- 'react-x11: useDockMenu() is inert on this backend — only the ' +
72
- 'cocoa backend has a Dock. On a Linux desktop the counterpart ' +
73
- 'is desktop actions in the .desktop file (docs/desktop.md).',
74
- );
75
- }
76
- return undefined;
77
- }
78
- app.setDockMenu(items ?? null);
79
- return () => app.setDockMenu(null);
92
+ setQuicklist(items ?? null, { app }).catch(() => {});
80
93
  }, [items, app]);
94
+
95
+ useEffect(() => {
96
+ return () => {
97
+ setQuicklist(null, { app }).catch(() => {});
98
+ };
99
+ }, [app]);
81
100
  }
package/src/node.d.ts CHANGED
@@ -236,6 +236,13 @@ export interface TextStyle {
236
236
  variations: Record<string, number> | undefined;
237
237
  textRendering: TextRendering | undefined;
238
238
  color: string;
239
+ /** `letterSpacing`, in device pixels like `size` — undefined unless a
240
+ * style or the cascade above it named one. */
241
+ letterSpacing: number | undefined;
242
+ /** The OpenType features `fontVariantNumeric` and `fontFeatureSettings`
243
+ * resolve to, tag → 1 on, 0 off or an alternate — undefined unless either
244
+ * was named. The same object for the same pair of values. */
245
+ features: Readonly<Record<string, number>> | undefined;
239
246
  }
240
247
 
241
248
  /**
@@ -40,36 +40,6 @@ const gridContainerMoved = (was, now) =>
40
40
  const gridItemMoved = (was, now) =>
41
41
  GRID_ITEM_PROPS.some((prop) => was[prop] !== now[prop]);
42
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
43
  // Frame timestamps for transitions. Indirected so tests can drive the clock
74
44
  // instead of sleeping through real animations.
75
45
  export let now = () => Date.now();
@@ -129,7 +99,7 @@ export class NodeAnimation {
129
99
  // such presenter, the window's frame clock runs it as it always has.
130
100
  if (this._offload(prop, entry)) {
131
101
  entry.offloaded = true;
132
- this.root?.invalidate(false, damageForAnimation(this), 'animation');
102
+ this.root?.invalidate(false, this, 'animation');
133
103
  } else {
134
104
  // …and one the presenter had must not keep running underneath the
135
105
  // values the clock is about to write
@@ -342,7 +312,7 @@ export class NodeAnimation {
342
312
  }
343
313
  }
344
314
  this.root?._animating.delete(this);
345
- this.root?.invalidate(false, damageForAnimation(this), 'animation');
315
+ this.root?.invalidate(false, this, 'animation');
346
316
  }
347
317
 
348
318
  /**
@@ -608,7 +578,7 @@ export class WindowAnimation {
608
578
  // invalidate anyway, but a React prop change does not — and a transition
609
579
  // no one schedules only runs when something else dirties the window,
610
580
  // by which time its start is stale and it snaps to the end.
611
- this.invalidate(false, damageForAnimation(node), 'animation');
581
+ this.invalidate(false, node, 'animation');
612
582
  }
613
583
 
614
584
  /**
@@ -618,27 +588,27 @@ export class WindowAnimation {
618
588
  */
619
589
  _advanceAnimations(now) {
620
590
  if (this._animating.size === 0) return;
621
- const claims = [];
622
591
  for (const node of [...this._animating]) {
623
592
  if (node.destroyed) {
624
593
  this._animating.delete(node);
625
594
  continue;
626
595
  }
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));
596
+ // The node where it stands, which is where it *was*: nothing has been
597
+ // laid out yet, so its rect is the one the last pass gave it. Where it
598
+ // goes is the layout pass's to claim. A tick on a layout property asks
599
+ // for one, and that pass claims the old and new rect of every node it
600
+ // moves (`_assignAbs`) — this one, and whatever this one pushed, in
601
+ // flow or out of it. A paint-only property moves nothing, and this
602
+ // claim is the whole frame.
603
+ //
604
+ // Claimed rather than left unbounded because an animation repaints
605
+ // every frame for as long as it runs: eight height loops in a menu-bar
606
+ // popover were a full-window repaint per frame at 120Hz (#603). A node
607
+ // that *finishes* on this tick is claimed too — it just landed on its
608
+ // final value, and this last frame still has to paint it there.
609
+ this.invalidate(false, node, 'animation');
632
610
  if (!node._tickAnimations(now)) this._animating.delete(node);
633
611
  }
634
612
  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
613
  }
644
614
  }
@@ -190,10 +190,20 @@ export class NodeCascade {
190
190
  * A detached node has no ancestors yet and so cannot see a provider two
191
191
  * levels up; it still resolves, against the base, and `_themeChanged()` on
192
192
  * attach re-resolves it against the real one.
193
+ *
194
+ * A window can take its palette from somewhere other than its parent: a
195
+ * `<ThemeProvider>` above it at the root, which is not its parent because a
196
+ * top-level window has none, or one directly inside the window it is
197
+ * nested in, which passed it on to that window. Either is `_scope`, and it
198
+ * comes first (nodes/scope.js).
193
199
  */
194
200
  get theme() {
195
201
  if (this._theme !== undefined) return this._theme;
196
- const inherited = this.parent ? this.parent.theme : baseTheme();
202
+ const inherited = this._scope
203
+ ? this._scope.theme
204
+ : this.parent
205
+ ? this.parent.theme
206
+ : baseTheme();
197
207
  const own = this.props.theme;
198
208
  this._theme = own ? { ...inherited, ...own } : inherited;
199
209
  return this._theme;
@@ -312,7 +322,12 @@ export class NodeCascade {
312
322
  const size = (inherited.size * to) / from;
313
323
  const cached = this._textScaled;
314
324
  if (cached?.from !== inherited || cached.style.size !== size) {
315
- this._textScaled = { from: inherited, style: { ...inherited, size } };
325
+ const style = { ...inherited, size };
326
+ // the other length that travels, re-expressed the same way
327
+ if (inherited.letterSpacing) {
328
+ style.letterSpacing = (inherited.letterSpacing * to) / from;
329
+ }
330
+ this._textScaled = { from: inherited, style };
316
331
  }
317
332
  return this._textScaled.style;
318
333
  }