react-x11 2.15.3 → 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 (57) hide show
  1. package/README.md +37 -0
  2. package/package.json +3 -3
  3. package/src/Reconciler.js +85 -22
  4. package/src/anchor.js +60 -18
  5. package/src/capabilities.js +29 -4
  6. package/src/cocoa/app.js +15 -9
  7. package/src/cocoa/context2d.js +23 -0
  8. package/src/cocoa/fonts.js +78 -0
  9. package/src/cocoa/presenter.js +17 -0
  10. package/src/cocoa/promotion.js +20 -0
  11. package/src/cocoa/relaunch.js +8 -3
  12. package/src/cocoa/symbols.js +64 -0
  13. package/src/cocoa/threaded.js +24 -4
  14. package/src/cocoa/window.js +362 -139
  15. package/src/components/ProgressBar.js +1 -1
  16. package/src/components/Slider.js +72 -39
  17. package/src/components/anchor.js +7 -2
  18. package/src/components/index.js +1 -0
  19. package/src/components/theme.js +32 -28
  20. package/src/desktopcapabilityhooks.js +29 -6
  21. package/src/filedialoghooks.js +3 -5
  22. package/src/frame/childmain.js +8 -20
  23. package/src/frame/env.js +2 -10
  24. package/src/icontheme.js +240 -0
  25. package/src/imagesource.js +83 -1
  26. package/src/index.js +3 -0
  27. package/src/node.d.ts +7 -0
  28. package/src/nodes/animation.js +17 -47
  29. package/src/nodes/cascade.js +17 -2
  30. package/src/nodes/image.js +63 -1
  31. package/src/nodes/kinds.js +12 -0
  32. package/src/nodes/layout.js +5 -1
  33. package/src/nodes/node.js +17 -3
  34. package/src/nodes/paint.js +117 -0
  35. package/src/nodes/scope.js +259 -0
  36. package/src/nodes/scrollable.js +53 -6
  37. package/src/nodes/text.js +2 -0
  38. package/src/nodes/textarea.js +1 -1
  39. package/src/nodes/textinput.js +1 -1
  40. package/src/nodes/window/anchoring.js +45 -18
  41. package/src/nodes/window/flush.js +6 -5
  42. package/src/nodes/window/popup.js +10 -0
  43. package/src/nodes/window/size.js +40 -2
  44. package/src/nodes/window/window.js +41 -14
  45. package/src/registry.js +2 -1
  46. package/src/settings.js +332 -0
  47. package/src/statusnotifier.js +164 -17
  48. package/src/styles.js +212 -8
  49. package/src/symbols.js +200 -0
  50. package/src/testing/mock-app.js +10 -0
  51. package/src/trayhooks.js +21 -5
  52. package/src/types/capabilities.d.ts +13 -1
  53. package/src/types/components.d.ts +33 -0
  54. package/src/types/elements.d.ts +57 -6
  55. package/src/types/style.d.ts +57 -0
  56. package/src/types/system.d.ts +104 -0
  57. package/src/types/tray.d.ts +14 -2
package/src/nodes/node.js CHANGED
@@ -34,7 +34,7 @@ import { NO_DAMAGE } from './damage.js';
34
34
  import { NodeHitTest } from './hittest.js';
35
35
  import { installMethods } from './install.js';
36
36
  import { NodeInvalidate } from './invalidate.js';
37
- import { CUSTOM_SELF_DAMAGED } from './kinds.js';
37
+ import { CUSTOM_SELF_DAMAGED, THEME_SCOPE } from './kinds.js';
38
38
  import { NodeLayout } from './layout.js';
39
39
  import { NodeLayoutHost } from './layouthost.js';
40
40
  import { NodePaint } from './paint.js';
@@ -154,6 +154,9 @@ export class Node {
154
154
  // subtree's hit reach, invalidated through _clearHitBounds()
155
155
  this._paintOrderCache = null;
156
156
  this._hitBoundsCache = null;
157
+ // the surface an `opacity` below 1 draws this subtree through, kept from
158
+ // one frame to the next while it fits (`NodePaint._paintGroup`)
159
+ this._groupSurface = null;
157
160
  // a `$token` the theme does not define, held for `commitMount` to throw
158
161
  // on this node's own fiber — see `_tokenProblem`. Strict mode only.
159
162
  // `null` is "commitMount is still to come", `false` is "it has been and
@@ -327,9 +330,19 @@ export class Node {
327
330
  return;
328
331
  }
329
332
  if (child.isWindow) {
333
+ // A `<ThemeProvider>` inside a window is drawn as a box, which the
334
+ // developer never wrote — so the message names what they did write.
335
+ // Directly inside a window it passes a nested window on
336
+ // (nodes/scope.js, `ThemeBoxNode`); this is one further down.
330
337
  throw new Error(
331
- `react-x11: <window> cannot be nested inside <${this.kind}>; ` +
332
- 'windows may only appear at the root or inside another <window>.',
338
+ this._reactFiber?.type === THEME_SCOPE
339
+ ? 'react-x11: a <window> under this <ThemeProvider> cannot be ' +
340
+ 'nested — the provider is inside a <box>, where it is a box ' +
341
+ 'itself, and a window nests only in a window. Put the ' +
342
+ 'provider directly inside the outer <window>, or inside the ' +
343
+ 'nested one.'
344
+ : `react-x11: <window> cannot be nested inside <${this.kind}>; ` +
345
+ 'windows may only appear at the root or inside another <window>.',
333
346
  );
334
347
  }
335
348
  // A registered element that declared childrenAllowed: false says so
@@ -499,6 +512,7 @@ export class Node {
499
512
  * freed by the caller via freeRecursive on the subtree top. */
500
513
  destroySubtree() {
501
514
  this.destroyed = true;
515
+ if (this._groupSurface) this._releaseGroupSurface();
502
516
  // a loop outlives nothing: the window drops it from the set that keeps
503
517
  // its frame clock alive, and stops watching visibility with the last one
504
518
  this.root?._forgetLoopNode(this);
@@ -3,6 +3,7 @@
3
3
  // window, background first.
4
4
 
5
5
  import { isPlaced } from '../layouts.js';
6
+ import { Surface } from '../ntk.js';
6
7
  import { isPaintedColor } from './boxpaint.js';
7
8
  import { DAMAGE_SLOP } from './damage.js';
8
9
  import { DRAWN_KINDS } from './kinds.js';
@@ -97,6 +98,122 @@ export class NodePaint {
97
98
 
98
99
  paint(ctx) {
99
100
  if (this.hidden) return;
101
+ const opacity = this.style.opacity;
102
+ // A retained presenter replaying this node's own paint into a layer puts
103
+ // the opacity on the layer instead (src/cocoa/presenter.js), and a group
104
+ // drawn here as well would fade it twice.
105
+ if (opacity !== undefined && opacity < 1 && !this._ownPaintOnly) {
106
+ // Nothing to see, so nothing to draw — NaN included. The node is still
107
+ // laid out and still hit, as CSS's `opacity: 0` is.
108
+ if (!(opacity > 0)) return;
109
+ this._paintGroup(ctx, opacity);
110
+ return;
111
+ }
112
+ // back to opaque: the surface a fade drew through is not needed any more
113
+ if (this._groupSurface) this._releaseGroupSurface();
114
+ this._paintNode(ctx);
115
+ }
116
+
117
+ /**
118
+ * `opacity` below 1: this node and everything under it drawn **once**, into
119
+ * a surface, and composited at that alpha — so it fades as a group. An icon
120
+ * over its own card does not show the card through the icon, a border does
121
+ * not double up where it meets a background, and text keeps the colour it
122
+ * had. That is CSS's `opacity`, and the reason it is not a `globalAlpha` on
123
+ * each draw.
124
+ *
125
+ * The surface is the subtree's paint bounds inside the window — stable
126
+ * across frames, so a fade reuses it — and only this pass's damage is drawn
127
+ * into it. Where the backend makes no surfaces (the headless mock) the
128
+ * subtree is drawn with the alpha on each draw instead: the same pixels
129
+ * wherever nothing inside it overlaps.
130
+ */
131
+ _paintGroup(ctx, opacity) {
132
+ const rect = this._groupRect();
133
+ if (!rect) return;
134
+ const surface = this._groupSurfaceFor(rect.width, rect.height);
135
+ const alpha = ctx.globalAlpha ?? 1;
136
+ if (!surface) {
137
+ ctx.globalAlpha = alpha * opacity;
138
+ try {
139
+ this._paintNode(ctx);
140
+ } finally {
141
+ ctx.globalAlpha = alpha;
142
+ }
143
+ return;
144
+ }
145
+ surface.render((sctx) => {
146
+ sctx.clearRect(0, 0, rect.width, rect.height);
147
+ sctx.translate(-rect.x, -rect.y);
148
+ const damage = this.root?._paintDamage;
149
+ if (damage) {
150
+ sctx.beginPath();
151
+ sctx.rect(damage.x, damage.y, damage.width, damage.height);
152
+ sctx.clip();
153
+ }
154
+ this._paintNode(sctx);
155
+ });
156
+ ctx.globalAlpha = alpha * opacity;
157
+ try {
158
+ ctx.drawImage(surface, rect.x, rect.y);
159
+ } finally {
160
+ ctx.globalAlpha = alpha;
161
+ }
162
+ }
163
+
164
+ /** Where a group's surface goes: the subtree's paint bounds, on whole
165
+ * pixels, cut to the window — a faded pane scrolled far off the top has
166
+ * nothing of its own down there to keep. Null when none of it is on the
167
+ * window at all. */
168
+ _groupRect() {
169
+ const b = this._subtreeBounds();
170
+ const win = this.root?.abs;
171
+ let x0 = Math.floor(b.x);
172
+ let y0 = Math.floor(b.y);
173
+ let x1 = Math.ceil(b.x + b.width);
174
+ let y1 = Math.ceil(b.y + b.height);
175
+ if (win) {
176
+ x0 = Math.max(x0, 0);
177
+ y0 = Math.max(y0, 0);
178
+ x1 = Math.min(x1, Math.ceil(win.width));
179
+ y1 = Math.min(y1, Math.ceil(win.height));
180
+ }
181
+ if (x1 <= x0 || y1 <= y0) return null;
182
+ return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
183
+ }
184
+
185
+ /** The group surface at this size, kept from the last frame when it fits —
186
+ * a fade asks for the same one every frame. Null where the backend has
187
+ * none to give. */
188
+ _groupSurfaceFor(width, height) {
189
+ const kept = this._groupSurface;
190
+ if (kept && kept.width === width && kept.height === height) return kept;
191
+ if (kept) this._releaseGroupSurface();
192
+ const app = this.app;
193
+ if (!app?.display?.Render && typeof app?.createSurface !== 'function') {
194
+ return null;
195
+ }
196
+ try {
197
+ this._groupSurface = new Surface(app, { width, height });
198
+ } catch {
199
+ // no pixmap to be had: the fallback draws with the alpha instead
200
+ this._groupSurface = null;
201
+ }
202
+ return this._groupSurface;
203
+ }
204
+
205
+ _releaseGroupSurface() {
206
+ const surface = this._groupSurface;
207
+ this._groupSurface = null;
208
+ try {
209
+ surface?.destroy();
210
+ } catch {
211
+ // gone with its connection
212
+ }
213
+ }
214
+
215
+ /** Everything this node draws, and its children: `paint` minus the group. */
216
+ _paintNode(ctx) {
100
217
  // Outside the box and under everything, which is the whole of what makes
101
218
  // a shadow different from a colour: it is drawn before this node's own
102
219
  // background so a translucent background does not sit on top of it, and
@@ -0,0 +1,259 @@
1
+ // Where a `<ThemeProvider>` meets the windows. At the root of the tree: what
2
+ // the container holds — top-level windows and popups — and the node a
3
+ // provider written above them becomes. And directly inside a window: the box
4
+ // a provider becomes there, which passes a nested window on to that window.
5
+
6
+ import { hooks as a11yHooks } from '../a11y.js';
7
+ import { baseTheme } from '../palette.js';
8
+ import { BoxNode } from './box.js';
9
+ import { THEME_SCOPE } from './kinds.js';
10
+ import { Node } from './node.js';
11
+
12
+ /**
13
+ * Put `node` at the top of the tree: realize a window against the screen
14
+ * root, and record it among the container's top-level windows — the list
15
+ * every "which windows does this app have" question reads
16
+ * (`app._rootChildren`). A theme scope puts its windows there instead of
17
+ * itself, so the list stays one of windows.
18
+ *
19
+ * Idempotent, because React reorders a keyed list at the root by inserting
20
+ * a child that is already mounted: a second entry for the same window would
21
+ * be a window counted twice, and announced to assistive technology twice.
22
+ */
23
+ export function attachTopLevel(app, node) {
24
+ if (node.isThemeScope) {
25
+ node._attach();
26
+ return;
27
+ }
28
+ // realize the whole subtree top-down against the screen root
29
+ if (!node.window) node.realize(null);
30
+ // React's getPublicRootInstance answers from the root fiber's first
31
+ // child, and only when that child is a host component. `render()` wraps
32
+ // the tree in a context provider, which is not one, so it would answer
33
+ // null — the container keeps the list instead.
34
+ const roots = (app._rootChildren ??= []);
35
+ if (roots.includes(node)) return;
36
+ roots.push(node);
37
+ a11yHooks.rootMounted?.(node);
38
+ }
39
+
40
+ /** Take `node` back off the top of the tree, before its subtree is
41
+ * destroyed — the bridge reads the subtree to say what went. */
42
+ export function detachTopLevel(app, node) {
43
+ if (node.isThemeScope) {
44
+ node._detach();
45
+ return;
46
+ }
47
+ const roots = app._rootChildren;
48
+ const at = roots ? roots.indexOf(node) : -1;
49
+ if (at !== -1) roots.splice(at, 1);
50
+ a11yHooks.rootUnmounted?.(node);
51
+ }
52
+
53
+ /**
54
+ * `<ThemeProvider>` above the windows (#584).
55
+ *
56
+ * A provider has to put its palette on a node, because a `$token` resolves by
57
+ * walking the node tree and knows nothing about React context. Inside a window
58
+ * that node is a `<box>`. At the root it cannot be — nothing drawn can be
59
+ * there, and a window cannot be inside a box — so the provider used to put
60
+ * the palette on the windows it could see among its children instead. It
61
+ * could only see literal `<window>` elements: a component that renders one,
62
+ * a window that is closed, a fragment of two, all planted a box at the root.
63
+ *
64
+ * This is the node for that position. It draws nothing, lays nothing out and
65
+ * holds only windows, popups and other scopes, and the windows under it take
66
+ * their palette from it: a top-level window keeps **no parent** — a window
67
+ * with a parent is a nested one to everything that asks, the accessibility
68
+ * bridge first — and reads the scope through `_scope` instead
69
+ * (`Node.theme`).
70
+ *
71
+ * The scope itself is never in `app._rootChildren`; its windows are, from
72
+ * the moment it is attached to the container (`attachTopLevel`).
73
+ */
74
+ export class ThemeScopeNode extends Node {
75
+ constructor(props, app) {
76
+ super(THEME_SCOPE, props, app, { yoga: false });
77
+ this.isThemeScope = true;
78
+ // the scope this one is written inside, when providers nest at the root
79
+ this._scope = null;
80
+ // in the container: its windows are realized and on the root list
81
+ this._attached = false;
82
+ // React's hide (`<Suspense>`, `<Activity>`), which lands on the topmost
83
+ // host instance under the boundary — this, when the provider is there
84
+ this._reactHidden = false;
85
+ }
86
+
87
+ /**
88
+ * The palette the windows under this scope inherit: this scope's own
89
+ * `theme` over the scope around it, or over the desktop's palette.
90
+ *
91
+ * Not cached, unlike `Node.theme`. Nothing tells a scope that the
92
+ * desktop's palette moved — `appearanceChanged` walks the windows — and a
93
+ * cached merge would hand them the old base. Each window caches what it
94
+ * read, so this runs once per window per theme change.
95
+ */
96
+ get theme() {
97
+ const inherited = this._scope ? this._scope.theme : baseTheme();
98
+ const own = this.props.theme;
99
+ return own ? { ...inherited, ...own } : inherited;
100
+ }
101
+
102
+ insertBefore(child, beforeChild) {
103
+ const from = this.children.indexOf(child);
104
+ if (from !== -1) this.children.splice(from, 1);
105
+ const before =
106
+ beforeChild == null ? -1 : this.children.indexOf(beforeChild);
107
+ this.children.splice(
108
+ before === -1 ? this.children.length : before,
109
+ 0,
110
+ child,
111
+ );
112
+ // a keyed reorder: the child is already linked, themed and attached
113
+ if (from !== -1) return;
114
+ child._scope = this;
115
+ // Built while detached, the subtree resolved its tokens against the
116
+ // desktop's palette; this is the attach walk that re-resolves them, the
117
+ // same one `Node.insertBefore` runs — a mount, so it claims no damage.
118
+ child._themeChanged(true);
119
+ if (this._hiddenByReact()) child._applyHidden();
120
+ if (this._attached) attachTopLevel(this.app, child);
121
+ }
122
+
123
+ removeChild(child) {
124
+ const at = this.children.indexOf(child);
125
+ if (at === -1) return;
126
+ this.children.splice(at, 1);
127
+ if (this._attached) detachTopLevel(this.app, child);
128
+ child._scope = null;
129
+ child.destroySubtree();
130
+ }
131
+
132
+ applyProps(newProps, oldProps) {
133
+ const before = oldProps ?? this.props;
134
+ this.props = newProps;
135
+ // A swap walks the windows as a live change, which repaints them. The
136
+ // `style` a provider passes lays out a box inside a window and means
137
+ // nothing here: a direction it names is in the palette as well, which
138
+ // is what a window reads its direction from.
139
+ if (newProps.theme !== before.theme) this._themeChanged();
140
+ }
141
+
142
+ setHidden(hidden) {
143
+ this._reactHidden = hidden;
144
+ this._applyHidden();
145
+ }
146
+
147
+ /** The hidden state above the windows moved: each one re-derives its own. */
148
+ _applyHidden() {
149
+ for (const child of this.children) child._applyHidden();
150
+ }
151
+
152
+ /** Whether React hides this scope, here or at a scope around it. */
153
+ _hiddenByReact() {
154
+ return this._reactHidden || (this._scope?._hiddenByReact() ?? false);
155
+ }
156
+
157
+ _attach() {
158
+ this._attached = true;
159
+ for (const child of this.children) attachTopLevel(this.app, child);
160
+ }
161
+
162
+ _detach() {
163
+ this._attached = false;
164
+ for (const child of this.children) detachTopLevel(this.app, child);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * `<ThemeProvider>` written directly inside a `<window>` (or a `<popup>`).
170
+ *
171
+ * There it is a box that fills its parent, like a provider anywhere else in
172
+ * a window — but a window may nest windows, and a provider wrapped around one
173
+ * has to hand it on, since a window cannot live inside a box. So a nested
174
+ * window put under this box is **passed through** to the window the box is
175
+ * in: that window holds it, realizes and stacks it the way it does any
176
+ * nested window, and the window reads its palette from here through
177
+ * `_scope`, which a palette lookup asks before the parent (`Node.theme`).
178
+ *
179
+ * React still sees the window as this box's child, so the calls it makes on
180
+ * the box for that window — a move, a removal — are forwarded as well, and
181
+ * the box answers for the passed windows in the three things it owns over its
182
+ * subtree: a theme swap, a hide, and its own removal.
183
+ *
184
+ * Only here. Under a provider that is inside a `<box>` there is no window to
185
+ * pass a window to, and `Node.insertBefore` says so.
186
+ */
187
+ export class ThemeBoxNode extends BoxNode {
188
+ constructor(props, app) {
189
+ super(props, app);
190
+ // the nested windows React put under this box, in the order it did
191
+ this._windows = [];
192
+ }
193
+
194
+ insertBefore(child, beforeChild) {
195
+ if (!child.isWindow || child.isPopup) {
196
+ super.insertBefore(child, beforeChild);
197
+ return;
198
+ }
199
+ if (!this._windows.includes(child)) {
200
+ this._windows.push(child);
201
+ child._scope = this;
202
+ }
203
+ // Before the box is in its window the window waits here, and `_setRoot`
204
+ // passes it on at the attach. A move is re-inserted at the end: nested
205
+ // windows stack among themselves, not among the box's drawn children.
206
+ if (this.parent) this.parent.insertBefore(child, null);
207
+ }
208
+
209
+ removeChild(child) {
210
+ const at = this._windows.indexOf(child);
211
+ if (at === -1) {
212
+ super.removeChild(child);
213
+ return;
214
+ }
215
+ this._windows.splice(at, 1);
216
+ // the window destroys it, and takes it off its stacking list
217
+ if (child.parent) child.parent.removeChild(child);
218
+ else child.destroySubtree();
219
+ child._scope = null;
220
+ }
221
+
222
+ _setRoot(root) {
223
+ const attaching = this.root !== root;
224
+ super._setRoot(root);
225
+ if (!attaching || !this.parent) return;
226
+ for (const win of this._windows) {
227
+ if (win.parent !== this.parent) this.parent.insertBefore(win, null);
228
+ }
229
+ }
230
+
231
+ _themeChanged(mounting = false) {
232
+ super._themeChanged(mounting);
233
+ for (const win of this._windows) win._themeChanged(mounting);
234
+ }
235
+
236
+ setHidden(hidden) {
237
+ super.setHidden(hidden);
238
+ for (const win of this._windows) win._applyHidden();
239
+ }
240
+
241
+ /** React hid this box — the hide a `<Suspense>` around the provider
242
+ * lands on, which the windows passed on from here have to follow. */
243
+ _hiddenByReact() {
244
+ return this.hidden;
245
+ }
246
+
247
+ destroySubtree() {
248
+ super.destroySubtree();
249
+ // React removes the box alone, never the windows under it, so they go
250
+ // with it — out of the window that holds them, which is each one's
251
+ // parent (the box's own is already cleared by now), unless that window
252
+ // is being destroyed itself and is walking its children as it does.
253
+ for (const win of this._windows) {
254
+ const holder = win.parent;
255
+ if (holder && !holder.destroyed) holder.removeChild(win);
256
+ else if (!win.destroyed) win.destroySubtree();
257
+ }
258
+ }
259
+ }
@@ -59,6 +59,17 @@ function replayHeld({ base, steps }, max) {
59
59
  // walked per node per hit test.
60
60
  const EMPTY_SCROLLBARS = Object.freeze([]);
61
61
 
62
+ /** Do two answers from `_scrollbar` paint the same thumb? Neither painting
63
+ * one counts. */
64
+ const sameThumb = (a, b) =>
65
+ a === b ||
66
+ (a !== undefined &&
67
+ b !== undefined &&
68
+ a.x === b.x &&
69
+ a.y === b.y &&
70
+ a.width === b.width &&
71
+ a.height === b.height);
72
+
62
73
  /**
63
74
  * Scrolling, as a style rather than as a species of node.
64
75
  *
@@ -177,6 +188,9 @@ export const Scrollable = (Base) =>
177
188
  this._childOrigin != null &&
178
189
  !this._scrollMeasureDirty &&
179
190
  !this.yoga.hasNewLayout();
191
+ // The thumbs as they stand, for a bounded frame to hold the measure
192
+ // below against (`_claimThumbs`)
193
+ const thumbsWere = !clean && layoutDiff.sink ? this._scrollbars() : null;
180
194
  if (!clean) {
181
195
  const size = this.measureScrollContent();
182
196
  if (!Number.isFinite(size?.width) || !Number.isFinite(size?.height)) {
@@ -208,8 +222,13 @@ export const Scrollable = (Base) =>
208
222
  // once and scrollIntoView waits for the pass.
209
223
  this._resolveScrollTo();
210
224
  this._resolveScrollIntoView();
225
+ const askedX = this.scrollX;
226
+ const askedY = this.scrollY;
211
227
  this.scrollY = clampScroll(this.scrollY, this._maxScroll('y'));
212
228
  this.scrollX = clampScroll(this.scrollX, this._maxScroll('x'));
229
+ // Of those three moves the clamp's is the one no call claimed when it
230
+ // was asked for — the diff below claims it
231
+ const clamped = this.scrollX !== askedX || this.scrollY !== askedY;
213
232
  this._reportViewport();
214
233
  this._reportScrollTo(from);
215
234
  // `scrollX` is how far the content has moved **from its start**, which
@@ -271,14 +290,22 @@ export const Scrollable = (Base) =>
271
290
  }
272
291
  };
273
292
  layoutDiff.shift = { x: ox - wasOrigin.x, y: oy - wasOrigin.y };
274
- } else if (shifted) {
275
- layoutDiff.sink = null;
276
293
  } else {
277
294
  const vp = insetRect(this.abs, -DAMAGE_SLOP);
278
- layoutDiff.sink = (rect) => {
279
- const clipped = intersectRects(rect, vp);
280
- if (clipped) outer(clipped);
281
- };
295
+ if (shifted) {
296
+ layoutDiff.sink = null;
297
+ // …which holds for a scroll's shift and not for the clamp's: the
298
+ // content shrank, or the viewport grew, under the offset — a row
299
+ // collapsing at the end of a list scrolled to its end — and
300
+ // nothing claimed the viewport every child just moved in.
301
+ if (clamped) outer(vp);
302
+ } else {
303
+ layoutDiff.sink = (rect) => {
304
+ const clipped = intersectRects(rect, vp);
305
+ if (clipped) outer(clipped);
306
+ };
307
+ }
308
+ this._claimThumbs(thumbsWere, outer);
282
309
  }
283
310
  }
284
311
  try {
@@ -818,6 +845,26 @@ export const Scrollable = (Base) =>
818
845
  return [this._scrollbar('y'), this._scrollbar('x')].filter(Boolean);
819
846
  }
820
847
 
848
+ /**
849
+ * A bounded layout pass's claim for the thumbs it changed, where each
850
+ * was and where it is. The pane paints them, not a node with a rect of
851
+ * its own, so content growing or shrinking under a pane that stays put
852
+ * slides a thumb or resizes it and the layout diff sees nothing move.
853
+ * `was` is `_scrollbars()` from before the pass measured the content.
854
+ */
855
+ _claimThumbs(was, sink) {
856
+ const now = this._scrollbars();
857
+ for (const axis of ['y', 'x']) {
858
+ const before = was.find((bar) => bar.axis === axis);
859
+ const after = now.find((bar) => bar.axis === axis);
860
+ if (sameThumb(before, after)) continue;
861
+ // a pixel of slop for the rounded corners' antialiasing, as the
862
+ // blit's own thumb repair takes
863
+ if (before) sink(insetRect(before, -1));
864
+ if (after) sink(insetRect(after, -1));
865
+ }
866
+ }
867
+
821
868
  /**
822
869
  * The bar belongs to the scroller, not to the content under it — the same
823
870
  * rule a browser applies. Without this a press on the thumb would be
package/src/nodes/text.js CHANGED
@@ -338,6 +338,8 @@ export class TextNode extends Node {
338
338
  variations: style.variations,
339
339
  textRendering: style.textRendering,
340
340
  color: style.color,
341
+ letterSpacing: style.letterSpacing,
342
+ features: style.features,
341
343
  });
342
344
  } else if (child.kind === 'text') {
343
345
  child.collectSpans(out);
@@ -137,7 +137,7 @@ export class TextAreaNode extends TextInputNode {
137
137
  width === undefined || direction !== 'rtl'
138
138
  ? width
139
139
  : Math.max(0, width - CARET_RESERVE * this.scale);
140
- const key = `${width}|${color}|${shown}|${s.family}|${s.size}|${s.weight}|${s.style}|${direction}|${align}`;
140
+ const key = `${width}|${color}|${shown}|${s.family}|${s.size}|${s.weight}|${s.style}|${direction}|${align}|${s.letterSpacing}|${JSON.stringify(s.features ?? null)}`;
141
141
  if (this._valueLayoutKey !== key) {
142
142
  this._valueLayoutKey = key;
143
143
  this._valueLayoutCache = fonts.layout([{ text: shown, ...s, color }], s, {
@@ -302,7 +302,7 @@ export class TextInputNode extends Node {
302
302
  const text = this._displayValue();
303
303
  const s = this.resolvedTextStyle();
304
304
  const direction = this.direction;
305
- const key = `${text}|${s.family}|${s.size}|${s.weight}|${s.style}|${direction}`;
305
+ const key = `${text}|${s.family}|${s.size}|${s.weight}|${s.style}|${direction}|${s.letterSpacing}|${JSON.stringify(s.features ?? null)}`;
306
306
  if (this._valueLayoutKey !== key) {
307
307
  this._valueLayoutKey = key;
308
308
  this._valueLayoutCache = fonts.layout(text, s, { direction });
@@ -1,7 +1,7 @@
1
1
  // A window anchored to a rect in another (#255, #280): where it goes, and
2
2
  // following the anchor as it moves.
3
3
 
4
- import { anchorOffscreen, anchorRect } from '../../anchor.js';
4
+ import { anchorOffscreen, anchorRect, anchorScreenRect } from '../../anchor.js';
5
5
 
6
6
  /** Anchoring, installed onto `WindowNode.prototype` by window.js. */
7
7
  export class WindowAnchoring {
@@ -32,18 +32,33 @@ export class WindowAnchoring {
32
32
  * anchor to yet — a ref whose node has not been laid out. */
33
33
  _anchorPlacement(size) {
34
34
  const anchor = this.props.anchor;
35
- const node = this._anchorTarget(anchor?.to);
36
- if (!node) return null;
35
+ if (!anchor) return null;
37
36
  // `anchorRect` is public API and speaks logical pixels on both sides;
38
37
  // this caller's `size` came from `_measure` (device) and its result is
39
38
  // headed for CreateWindow (device), so both convert here.
40
39
  const s = this.scale;
41
- const rect = anchorRect(node, {
42
- ...anchor,
43
- alignTo: this._anchorTarget(anchor.alignTo) ?? undefined,
44
- width: size.width / s,
45
- height: size.height / s,
46
- });
40
+ let rect;
41
+ if (anchor.rect) {
42
+ // A rect on the screen with no node behind it — the tray item a click
43
+ // reported. The popup's own scale and direction stand in for the
44
+ // node's.
45
+ rect = anchorScreenRect(this.app, anchor.rect, {
46
+ ...anchor,
47
+ scale: s,
48
+ direction: anchor.direction ?? this.direction,
49
+ width: size.width / s,
50
+ height: size.height / s,
51
+ });
52
+ } else {
53
+ const node = this._anchorTarget(anchor.to);
54
+ if (!node) return null;
55
+ rect = anchorRect(node, {
56
+ ...anchor,
57
+ alignTo: this._anchorTarget(anchor.alignTo) ?? undefined,
58
+ width: size.width / s,
59
+ height: size.height / s,
60
+ });
61
+ }
47
62
  if (!rect || s === 1) return rect;
48
63
  return {
49
64
  ...rect,
@@ -76,13 +91,7 @@ export class WindowAnchoring {
76
91
  */
77
92
  _followAnchor(size = this._requestedSize) {
78
93
  if (this.destroyed || !this.window || !this.props.anchor) return;
79
- const node = this._anchorTarget(this.props.anchor.to);
80
- // A ref that has not attached yet counts as gone, and for the same
81
- // reason: there is nowhere for the popup to be. Refs attach in the
82
- // commit phase a popup realizes in, so one written *above* its own
83
- // trigger in the JSX gets a frame of this — and waiting it out is
84
- // better than a frame in the corner of the screen.
85
- const lost = !node || anchorOffscreen(node, this.props.anchor.at);
94
+ const lost = this._anchorGone();
86
95
  if (lost !== Boolean(this._anchorLost)) {
87
96
  this._anchorLost = lost;
88
97
  // The grab goes with it and comes back with it. X releases a pointer
@@ -93,6 +102,7 @@ export class WindowAnchoring {
93
102
  // keeps it beside the map on every route back to the screen.
94
103
  if (lost) {
95
104
  if (this.props.grab) this.window.ungrabPointer?.();
105
+ if (this.props.grabKeyboard) this.window.ungrabKeyboard?.();
96
106
  this.window.unmap?.();
97
107
  } else {
98
108
  this._mapNow();
@@ -110,14 +120,31 @@ export class WindowAnchoring {
110
120
  }
111
121
  }
112
122
 
123
+ /**
124
+ * Whether there is nothing for this window to point at: the anchor's node
125
+ * has scrolled out of view, or is a ref that has not attached yet — which
126
+ * counts as gone for the same reason, since there is nowhere for the popup
127
+ * to be. Refs attach in the commit phase a popup realizes in, so one
128
+ * written *above* its own trigger in the JSX gets a frame of this, and
129
+ * waiting it out is better than a frame in the corner of the screen. A
130
+ * rect on the screen is never gone.
131
+ */
132
+ _anchorGone() {
133
+ const anchor = this.props.anchor;
134
+ if (anchor.rect) return false;
135
+ const node = this._anchorTarget(anchor.to);
136
+ return !node || anchorOffscreen(node, anchor.at);
137
+ }
138
+
113
139
  /**
114
140
  * Subscribe to whatever can move the anchor. On the **owner's** window,
115
141
  * not on this one: what moves is the trigger, and this window's own
116
- * layout passes say nothing about where it sits on screen.
142
+ * layout passes say nothing about where it sits on screen. A rect on the
143
+ * screen moves only when the app hands a new one, which is a commit.
117
144
  */
118
145
  _watchAnchor() {
119
146
  this._unwatchAnchor();
120
- if (!this.props.anchor) return;
147
+ if (!this.props.anchor || this.props.anchor.rect) return;
121
148
  // The anchor's own window where the ref has attached, and the window
122
149
  // this popup was *written into* otherwise — which is the same one in
123
150
  // every case that matters, and is what makes an unattached ref a