react-x11 2.6.1 → 2.8.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 (59) 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 +367 -3
  9. package/src/cocoa/bezels.js +51 -1
  10. package/src/cocoa/dnd.js +358 -0
  11. package/src/cocoa/dock.js +39 -0
  12. package/src/cocoa/filepanels.js +155 -0
  13. package/src/cocoa/fonts.js +93 -2
  14. package/src/cocoa/globalmenu.js +41 -33
  15. package/src/cocoa/notifications.js +244 -0
  16. package/src/cocoa/permissions.js +74 -0
  17. package/src/cocoa/presenter.js +274 -33
  18. package/src/cocoa/promotion.js +708 -0
  19. package/src/cocoa/statusitem.js +112 -0
  20. package/src/cocoa/window.js +113 -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 +137 -11
  31. package/src/errors.js +6 -3
  32. package/src/filedialog.js +81 -16
  33. package/src/index.d.ts +29 -2
  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 +604 -37
  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/style.d.ts +10 -4
  44. package/src/style.js +1 -0
  45. package/src/styles.js +161 -15
  46. package/src/textselection.js +1 -4
  47. package/src/trayhooks.js +90 -0
  48. package/src/types/appearance.d.ts +24 -0
  49. package/src/types/components.d.ts +10 -0
  50. package/src/types/elements.d.ts +14 -0
  51. package/src/types/events.d.ts +14 -0
  52. package/src/types/filedialog.d.ts +18 -7
  53. package/src/types/launcher.d.ts +43 -0
  54. package/src/types/notifications.d.ts +113 -0
  55. package/src/types/permissions.d.ts +100 -0
  56. package/src/types/style.d.ts +30 -2
  57. package/src/types/system.d.ts +5 -3
  58. package/src/types/tray.d.ts +54 -0
  59. package/src/windowid.js +23 -0
package/src/nodes.js CHANGED
@@ -32,14 +32,17 @@ import {
32
32
  isLayoutProp,
33
33
  styleUsesTokens,
34
34
  resolveTokens,
35
- styleHasSizeQueries,
36
- styleHasSupportsQueries,
35
+ queryKinds,
36
+ QUERY_SIZE,
37
+ QUERY_SUPPORTS,
38
+ QUERY_CONTAINER,
39
+ containerQueryNames,
40
+ containerAnswers,
37
41
  resolveQueries,
38
42
  DEFAULT_FOCUS_RING,
39
43
  resolveHitSlop,
40
44
  resolveBorderWidths,
41
45
  resolveBorderColors,
42
- tint,
43
46
  } from './styles.js';
44
47
  import {
45
48
  blurKernel,
@@ -83,6 +86,7 @@ import {
83
86
  registerTopLevel,
84
87
  XDND_VERSION,
85
88
  } from './dnd.js';
89
+ import { TYPE_GROUPS } from './transfer.js';
86
90
  import { addPendingFrame, clearPendingFrame } from './frames.js';
87
91
  import { createClientMessages } from './clientmessage.js';
88
92
  import {
@@ -671,6 +675,11 @@ export function appearanceChanged(app) {
671
675
  // started here would reach the connection a tick after it closed and throw
672
676
  // out of the frame clock, where nothing is waiting to catch it.
673
677
  if (!app || app.X?._closing) return;
678
+ // A backend that renders native control bezels caches them by every
679
+ // parameter that changes the pixels — except the desktop's accent, which
680
+ // the toolkit reads for itself. The repaint below would blit the old
681
+ // colour back out of that cache, so it is forgotten first.
682
+ app.nativeBezels?.clear?.();
674
683
  for (const node of app._rootChildren ?? []) {
675
684
  if (node.destroyed) continue;
676
685
  node._themeChanged();
@@ -718,6 +727,22 @@ function shadowExtentOf(style, scale = 1) {
718
727
 
719
728
  const DEV = process.env.NODE_ENV !== 'production';
720
729
 
730
+ /** How many extra layout passes a flush spends settling `@container` blocks
731
+ * before it takes the layout it has — see `_settleContainerQueries`. */
732
+ const CONTAINER_QUERY_PASSES = 3;
733
+
734
+ /** The sizes a node's container blocks were resolved against, as one
735
+ * comparable string — what a pinned node is held at. */
736
+ function containersKey(containers) {
737
+ if (!containers) return '';
738
+ let key = '';
739
+ for (const name of Object.keys(containers)) {
740
+ const c = containers[name];
741
+ key += `${name}:${c.width}x${c.height};`;
742
+ }
743
+ return key;
744
+ }
745
+
721
746
  // Connections already told they have no 32-bit visual (`_argbAttributes`).
722
747
  const warnedNoArgb = new WeakSet();
723
748
 
@@ -1888,6 +1913,10 @@ export class Node {
1888
1913
  this._floorMeasureMode = null;
1889
1914
  this.root = null; // owning WindowNode once attached
1890
1915
  this.hidden = false;
1916
+ // Composited on a layer of its own above the window's bitmap, by a
1917
+ // presenter that can (src/cocoa/promotion.js): the paint walk leaves a
1918
+ // hole where it is, and the presenter draws it — the `<glarea>` idiom.
1919
+ this._promoted = false;
1891
1920
  this.destroyed = false;
1892
1921
  // absolute rect within the owning window, filled by absolutize()
1893
1922
  this.abs = { x: 0, y: 0, width: 0, height: 0 };
@@ -1937,6 +1966,13 @@ export class Node {
1937
1966
  // `null` is "commitMount is still to come", `false` is "it has been and
1938
1967
  // gone", and an Error is one waiting for it
1939
1968
  this._tokenError = null;
1969
+ // `'@container …'` blocks (styles.js), on the nodes that carry them and
1970
+ // null on every other node: the style the record was built for, the
1971
+ // container names it asks about (`''` for the unnamed ones), the sizes
1972
+ // and answers the blocks last resolved to, and the pin an oscillating
1973
+ // design is held at — see WindowNode._resolveContainerQueries. Before
1974
+ // `_syncStyle`, which reads and writes it.
1975
+ this._cq = null;
1940
1976
  this._syncStyle(props);
1941
1977
  this.yoga = yoga ? createLayoutNode() : null;
1942
1978
  if (this.yoga) {
@@ -1993,7 +2029,8 @@ export class Node {
1993
2029
  this.states[':disabled'] = Boolean(props.disabled);
1994
2030
  // window size queries fold into the base before state blocks, so a
1995
2031
  // `:hover` inside the wide layout still wins over the wide layout
1996
- const queried = styleHasSizeQueries(this._baseStyle);
2032
+ const kinds = queryKinds(this._baseStyle);
2033
+ const queried = (kinds & QUERY_SIZE) !== 0;
1997
2034
  if (queried !== this._queried) {
1998
2035
  this._queried = queried;
1999
2036
  const root = this.root;
@@ -2009,7 +2046,7 @@ export class Node {
2009
2046
  // assigned, so the first pass has nowhere to register and a "did it
2010
2047
  // change" guard would keep it unregistered forever. Set.add is
2011
2048
  // idempotent and these blocks are rare.
2012
- const asks = styleHasSupportsQueries(this._baseStyle);
2049
+ const asks = (kinds & QUERY_SUPPORTS) !== 0;
2013
2050
  this._supportsQueried = asks;
2014
2051
  if (this.root?._supportsQueryNodes) {
2015
2052
  if (asks) this.root._supportsQueryNodes.add(this);
@@ -2029,13 +2066,53 @@ export class Node {
2029
2066
  if (wantsAttention) this.root._attentionNodes.add(this);
2030
2067
  else this.root._attentionNodes.delete(this);
2031
2068
  }
2032
- if (queried || asks) {
2069
+ // `@container` blocks keep a third registry: what re-resolves them is a
2070
+ // layout pass moving the container they ask about — neither a resize
2071
+ // nor the server's answer. The record exists only on the nodes that
2072
+ // ask, so every other node pays one bit test here and nothing below.
2073
+ // Insertion registers through `_registerSizeQueries`; this is for a
2074
+ // style that starts or stops asking on a node already in a window.
2075
+ const asksContainers = (kinds & QUERY_CONTAINER) !== 0;
2076
+ let containers = null;
2077
+ if (asksContainers) {
2078
+ const base = this._baseStyle;
2079
+ let cq = this._cq;
2080
+ if (cq === null || cq.style !== base) {
2081
+ if (cq === null) {
2082
+ cq = this._cq = {
2083
+ style: base,
2084
+ names: null,
2085
+ containers: null,
2086
+ answers: '',
2087
+ pin: null,
2088
+ warned: false,
2089
+ };
2090
+ } else {
2091
+ // a different style asks different questions, and a pin held for
2092
+ // the old one is not an answer to the new one
2093
+ cq.style = base;
2094
+ cq.pin = null;
2095
+ }
2096
+ cq.names = containerQueryNames(base);
2097
+ }
2098
+ this.root?._containerQueryNodes?.add(this);
2099
+ containers = this._pinnedContainerSizes();
2100
+ } else if (this._cq !== null) {
2101
+ this._cq = null;
2102
+ this.root?._containerQueryNodes?.delete(this);
2103
+ }
2104
+ if (queried || asks || asksContainers) {
2033
2105
  this._baseStyle = resolveQueries(this._baseStyle, {
2034
2106
  size: this.root?.querySize ?? null,
2035
2107
  // null before the window is realized, which reads as "not
2036
2108
  // supported" — the fallback design is the one that works everywhere
2037
2109
  supports: this.root?.capabilities ?? null,
2110
+ containers,
2038
2111
  });
2112
+ if (asksContainers) {
2113
+ this._cq.containers = containers;
2114
+ this._cq.answers = containerAnswers(this._baseStyle, containers);
2115
+ }
2039
2116
  }
2040
2117
  this._stateful = hasStateStyles(this._baseStyle);
2041
2118
  // The scale multiplies *after* every merge — state blocks, queries,
@@ -2085,7 +2162,7 @@ export class Node {
2085
2162
  const duration = transitionFor(target, prop);
2086
2163
  if (duration <= 0) continue;
2087
2164
  if (interpolate(from, to, 0.5) === null) continue; // no midpoint: snap
2088
- (this._anim ??= new Map()).set(prop, {
2165
+ const entry = {
2089
2166
  from,
2090
2167
  to,
2091
2168
  duration,
@@ -2094,8 +2171,23 @@ export class Node {
2094
2171
  // be seconds old — and the first tick would then find the
2095
2172
  // transition already over and jump straight to the end
2096
2173
  start: now(),
2097
- });
2098
- this.root?._startAnimating(this);
2174
+ };
2175
+ const previous = this._anim?.get(prop);
2176
+ (this._anim ??= new Map()).set(prop, entry);
2177
+ // A presenter that can run it in the render server takes it here:
2178
+ // the node's style then goes straight to the target — the layer's
2179
+ // model value — and the one frame that sends it carries the
2180
+ // animation with it (src/cocoa/presenter.js). Declined, or with no
2181
+ // such presenter, the window's frame clock runs it as it always has.
2182
+ if (this._offload(prop, entry)) {
2183
+ entry.offloaded = true;
2184
+ this.root?.invalidate(false, damageForAnimation(this), 'animation');
2185
+ } else {
2186
+ // …and one the presenter had must not keep running underneath the
2187
+ // values the clock is about to write
2188
+ if (previous?.offloaded) this._cancelOffload(prop, previous);
2189
+ this.root?._startAnimating(this);
2190
+ }
2099
2191
  }
2100
2192
  }
2101
2193
  // After the transitions, before the style is assembled: a loop that just
@@ -2187,10 +2279,78 @@ export class Node {
2187
2279
 
2188
2280
  _animatedValues() {
2189
2281
  const values = {};
2190
- for (const [prop, a] of this._anim) values[prop] = a.value ?? a.from;
2282
+ for (const [prop, a] of this._anim) {
2283
+ // an offloaded property shows its target: the render server draws the
2284
+ // motion over the model value, and the model is the style
2285
+ if (!a.offloaded) values[prop] = a.value ?? a.from;
2286
+ }
2191
2287
  return values;
2192
2288
  }
2193
2289
 
2290
+ // --- the presenter's half of an animation ---------------------------------
2291
+ //
2292
+ // Two feature-detected hooks on the window (src/cocoa/window.js: the
2293
+ // layer presenter, and the surface presenter's layer promotion —
2294
+ // src/cocoa/promotion.js): `animateNode(node, prop, entry)` answers true
2295
+ // when the presenter will run the entry itself, `cancelNodeAnimation(node,
2296
+ // prop)` stops what it runs for the property, and the presenter calls back
2297
+ // through `_offloadEnded` / `_offloadDeclined` below. An entry the
2298
+ // presenter took is `offloaded`: it stays in `_anim` — so a retarget, a
2299
+ // loop-stop rule and `sameAnimation` all see it — but it contributes no
2300
+ // value to the style, is skipped by the tick, and keeps the node out of the
2301
+ // window's animating set. The X11 path has none of these hooks and is
2302
+ // byte-identical (docs/architecture/animation.md §4).
2303
+
2304
+ _offload(prop, entry) {
2305
+ const wnd = this.root?.window;
2306
+ if (typeof wnd?.animateNode !== 'function') return false;
2307
+ return wnd.animateNode(this, prop, entry) === true;
2308
+ }
2309
+
2310
+ _cancelOffload(prop, entry) {
2311
+ if (!entry?.offloaded) return;
2312
+ this.root?.window?.cancelNodeAnimation?.(this, prop);
2313
+ }
2314
+
2315
+ /** The presenter is done with `entry` — it ran out, or its layer went.
2316
+ * A transition is over either way (the model is the target). A loop
2317
+ * never ends on its own, so a loop that comes back this way lost its
2318
+ * layer, and the frame clock takes it over rather than letting it stop. */
2319
+ _offloadEnded(prop, entry) {
2320
+ if (this._anim?.get(prop) !== entry) return;
2321
+ if (entry.loop && !this.destroyed) {
2322
+ this._offloadDeclined(prop, entry);
2323
+ return;
2324
+ }
2325
+ this._anim.delete(prop);
2326
+ if (!this._anim.size) this.root?._animating.delete(this);
2327
+ }
2328
+
2329
+ /** The presenter could not run `entry` after all — the node turned into a
2330
+ * raster between the swap and the frame. The frame clock takes it from
2331
+ * the top; the property's declared start is where the pixels still are. */
2332
+ _offloadDeclined(prop, entry) {
2333
+ if (this._anim?.get(prop) !== entry || this.destroyed) return;
2334
+ entry.offloaded = false;
2335
+ entry.start = now();
2336
+ this.style = { ...this._targetStyle, ...this._animatedValues() };
2337
+ this.root?._startAnimating(this);
2338
+ }
2339
+
2340
+ /** Keep the frame clock running only for what the clock itself animates;
2341
+ * an offloaded-only node needs one frame — the one that sends the model
2342
+ * and the animation — and not a loop of them. */
2343
+ _scheduleAnimationFrames() {
2344
+ for (const a of this._anim?.values() ?? []) {
2345
+ if (!a.offloaded) {
2346
+ this.root?._startAnimating(this);
2347
+ return;
2348
+ }
2349
+ }
2350
+ this.root?._animating.delete(this);
2351
+ this.root?.invalidate(false, damageForAnimation(this), 'animation');
2352
+ }
2353
+
2194
2354
  /**
2195
2355
  * The style declared a set of loops (`animation`, styles.js): remember
2196
2356
  * them and reconcile what is running against them.
@@ -2237,6 +2397,7 @@ export class Node {
2237
2397
  if (!a.loop) continue;
2238
2398
  if (running && specs.some((spec) => spec.prop === prop)) continue;
2239
2399
  anim.delete(prop);
2400
+ this._cancelOffload(prop, a);
2240
2401
  changed = true;
2241
2402
  if (isLayoutProp(prop)) layoutTouched = true;
2242
2403
  }
@@ -2249,13 +2410,28 @@ export class Node {
2249
2410
  // spinner that jumps back to the start whenever anything above it
2250
2411
  // re-rendered — which is the frame after every state change in the
2251
2412
  // app.
2252
- if (current?.loop && sameAnimation(current, spec)) continue;
2253
- (this._anim ??= new Map()).set(spec.prop, {
2413
+ if (current?.loop && sameAnimation(current, spec)) {
2414
+ // A loop the clock started before the window had a presenter —
2415
+ // one declared at mount runs from `_setRoot`, before `realize` —
2416
+ // moves over the first time a presenter can take it. Its phase is
2417
+ // the render server's from here, which is what a restart costs.
2418
+ if (!current.offloaded && this._offload(spec.prop, current)) {
2419
+ current.offloaded = true;
2420
+ changed = true;
2421
+ }
2422
+ continue;
2423
+ }
2424
+ // a changed declaration, or a transition the loop takes over from:
2425
+ // whatever the presenter ran for the property stops first
2426
+ if (current?.offloaded) this._cancelOffload(spec.prop, current);
2427
+ const entry = {
2254
2428
  ...spec,
2255
2429
  loop: true,
2256
2430
  start: now(),
2257
2431
  value: animationValueAt(spec, 0),
2258
- });
2432
+ };
2433
+ (this._anim ??= new Map()).set(spec.prop, entry);
2434
+ if (this._offload(spec.prop, entry)) entry.offloaded = true;
2259
2435
  changed = true;
2260
2436
  if (isLayoutProp(spec.prop)) layoutTouched = true;
2261
2437
  }
@@ -2269,7 +2445,7 @@ export class Node {
2269
2445
  // a stop has to leave the frame clock idle, and a tick is exactly what
2270
2446
  // there may never be another of.
2271
2447
  if (!this._anim?.size) this.root?._animating.delete(this);
2272
- if (running) this.root?._startAnimating(this);
2448
+ if (running) this._scheduleAnimationFrames();
2273
2449
  if (!write) return true;
2274
2450
  if (layoutTouched && this.yoga) {
2275
2451
  applyLayoutStyle(this.yoga, this.style, before);
@@ -2316,19 +2492,23 @@ export class Node {
2316
2492
  _tickAnimations(now) {
2317
2493
  if (!this._anim?.size) return false;
2318
2494
  let layoutChanged = false;
2495
+ let ticking = 0; // entries the clock runs, as against the presenter's
2319
2496
  const before = this.style;
2320
2497
  for (const [prop, a] of this._anim) {
2498
+ if (a.offloaded) continue;
2321
2499
  if (a.loop) {
2322
2500
  // No end to test for and no rounding to accumulate: the phase is a
2323
2501
  // modulo of the elapsed time, so a bar that has been going for an
2324
2502
  // hour is exactly where the clock says.
2325
2503
  a.value = animationValueAt(a, now - a.start);
2326
2504
  if (isLayoutProp(prop)) layoutChanged = true;
2505
+ ticking++;
2327
2506
  continue;
2328
2507
  }
2329
2508
  const t = a.duration > 0 ? Math.min(1, (now - a.start) / a.duration) : 1;
2330
2509
  a.value = t >= 1 ? a.to : (interpolate(a.from, a.to, ease(t)) ?? a.to);
2331
2510
  if (t >= 1) this._anim.delete(prop);
2511
+ else ticking++;
2332
2512
  if (isLayoutProp(prop)) layoutChanged = true;
2333
2513
  }
2334
2514
  this.style = this._anim.size
@@ -2351,7 +2531,7 @@ export class Node {
2351
2531
  // it *per frame*: a transitioned `color` is a new ink every frame, for
2352
2532
  // this node and for everything inheriting from it.
2353
2533
  if (inheritedTextChanged(this.style, before)) this._retextSubtree();
2354
- return this._anim.size > 0;
2534
+ return ticking > 0;
2355
2535
  }
2356
2536
 
2357
2537
  /**
@@ -2398,11 +2578,129 @@ export class Node {
2398
2578
  if (this._wantsAttention && this.root?._attentionNodes) {
2399
2579
  this.root._attentionNodes.add(this);
2400
2580
  }
2581
+ if (this._cq !== null && this.root?._containerQueryNodes) {
2582
+ this.root._containerQueryNodes.add(this);
2583
+ // it can see the containers above it now — the constructor's
2584
+ // resolution had no ancestors to find one in
2585
+ this._sizeQueriesChanged();
2586
+ }
2401
2587
  for (const child of this.children) {
2402
2588
  if (!child.isWindow) child._registerSizeQueries();
2403
2589
  }
2404
2590
  }
2405
2591
 
2592
+ /**
2593
+ * The sizes this node's `@container` blocks resolve against: for each name
2594
+ * the style asks about, the nearest ancestor declaring it, in this node's
2595
+ * **logical** pixels — the unit the threshold beside `width: 400` was
2596
+ * written in, so the two numbers mean the same thing. Yoga's computed
2597
+ * size rather than `abs`: inside a flush, `abs` is still the previous
2598
+ * frame's.
2599
+ *
2600
+ * A container that has not been laid out yet contributes nothing, so its
2601
+ * blocks do not apply — the way a capability block does not before the
2602
+ * window exists: the fallback design is the one that works everywhere.
2603
+ * "Laid out" is `_placed` between frames and every attached node while
2604
+ * the window is settling a pass it just ran (`_cqFresh`); a fresh yoga
2605
+ * node answers NaN. Null when no container is known, which is the
2606
+ * identity `resolveQueries` keeps.
2607
+ */
2608
+ _containerSizes() {
2609
+ const names = this._cq?.names;
2610
+ if (!names) return null;
2611
+ let sizes = null;
2612
+ const s = this.scale || 1;
2613
+ const fresh = Boolean(this.root?._cqFresh);
2614
+ for (const name of names) {
2615
+ const c = this._containerFor(name);
2616
+ if (!c) {
2617
+ // In a window and nothing above declares one: a forgotten
2618
+ // declaration, not a component rendered outside its context — that
2619
+ // is what the *named* form is for, and a missing name applies
2620
+ // nothing quietly. `root` rather than `parent`: React builds a
2621
+ // subtree bottom-up, so a node can have a parent and no window yet,
2622
+ // and the container it will find is further up.
2623
+ if (DEV && name === '' && this.root) this._noContainer();
2624
+ continue;
2625
+ }
2626
+ if (!c.yoga || !(fresh || c._placed)) continue;
2627
+ const width = c.yoga.getComputedWidth() / s;
2628
+ const height = c.yoga.getComputedHeight() / s;
2629
+ if (!Number.isFinite(width) || !Number.isFinite(height)) continue;
2630
+ (sizes ??= {})[name] = { width, height };
2631
+ }
2632
+ return sizes;
2633
+ }
2634
+
2635
+ /** `_containerSizes()`, unless this node is pinned at the sizes it is
2636
+ * looking at — then the sizes its held answer came from, so a restyle
2637
+ * arriving from React does not undo what the layout pass decided. */
2638
+ _pinnedContainerSizes() {
2639
+ const live = this._containerSizes();
2640
+ const cq = this._cq;
2641
+ const pin = cq.pin;
2642
+ if (!pin) return live;
2643
+ if (pin.key === containersKey(live)) return pin.containers;
2644
+ cq.pin = null;
2645
+ return live;
2646
+ }
2647
+
2648
+ /**
2649
+ * The nearest ancestor whose style declares `container` — any container
2650
+ * for the unnamed query (`''`), the one carrying `name` otherwise, however
2651
+ * many nearer containers that reaches past. A window ends the walk after
2652
+ * offering itself, and a window asks nothing: a `<popup>` inside a
2653
+ * container is a root of its own and asks its own window with `@width`.
2654
+ *
2655
+ * Walked rather than cached: it is a dozen property reads per dependent
2656
+ * per layout pass, and a cache would have to follow every insert, every
2657
+ * reorder and every `container` value that changes above.
2658
+ */
2659
+ _containerFor(name) {
2660
+ if (this.isWindow) return null;
2661
+ for (let n = this.parent; n; n = n.parent) {
2662
+ const c = n.style?.container;
2663
+ if (name === '' ? c === true || typeof c === 'string' : c === name) {
2664
+ return n;
2665
+ }
2666
+ if (n.isWindow) break;
2667
+ }
2668
+ return null;
2669
+ }
2670
+
2671
+ _noContainer() {
2672
+ this._tokenProblem(
2673
+ [
2674
+ `react-x11: <${this.kind}> has an "@container" block and no ` +
2675
+ 'container above it — declare one with `container: true` in an ' +
2676
+ "ancestor's style (or name it and ask for it by name), or ask " +
2677
+ 'the window with "@width"',
2678
+ ],
2679
+ true,
2680
+ 'The block does not apply and the app carries on',
2681
+ );
2682
+ }
2683
+
2684
+ /** Said once per node, in development: a design that cannot settle looks
2685
+ * like a layout bug, and the frame it is pinned at is the only clue. */
2686
+ _warnContainerOscillation() {
2687
+ const cq = this._cq;
2688
+ if (cq.warned) return;
2689
+ cq.warned = true;
2690
+ const asked = [...(cq.names ?? [])]
2691
+ .map((n) => (n === '' ? 'its container' : `"${n}"`))
2692
+ .join(', ');
2693
+ console.warn(
2694
+ `react-x11: the "@container" blocks on <${this.kind}> cannot settle: ` +
2695
+ `a block that matches at one size of ${asked} changes that size to ` +
2696
+ 'one where it no longer matches, and back. A container query must ' +
2697
+ 'not move the size it asks about — give the container a size of its ' +
2698
+ 'own, or minWidth: 0 and a flexBasis so its content cannot grow it. ' +
2699
+ 'The current answer is held until the container moves for another ' +
2700
+ 'reason (docs/styling.md#container-queries).',
2701
+ );
2702
+ }
2703
+
2406
2704
  /** Style names this element claims as its own semantics (see WindowNode).
2407
2705
  * Registered elements declare theirs to `registerElement`, so the common
2408
2706
  * case needs no subclass. */
@@ -2686,12 +2984,14 @@ export class Node {
2686
2984
  * swallow the error instead of raising it late. Those throw at once, like
2687
2985
  * the keyed reorder they resemble.
2688
2986
  */
2689
- _tokenProblem(problems, mounting) {
2987
+ _tokenProblem(problems, mounting, consequence = undefined) {
2690
2988
  if (!STRICT_TOKENS) {
2691
2989
  // every one of them: two misspellings in a style are two things to
2692
2990
  // fix, and a report that named only the first would send someone back
2693
2991
  // for a second run to find the second
2694
- for (const message of problems) reportStyleError(this, message);
2992
+ for (const message of problems) {
2993
+ reportStyleError(this, message, consequence);
2994
+ }
2695
2995
  return;
2696
2996
  }
2697
2997
  const error = new Error(problems[0]);
@@ -2699,10 +2999,16 @@ export class Node {
2699
2999
  else throw error;
2700
3000
  }
2701
3001
 
2702
- /** The owning window resized: re-resolve, since a query block may now
2703
- * match that did not, or the other way round. */
3002
+ /** The owning window resized, the server's answer moved, or a layout pass
3003
+ * moved a container this node asks about: re-resolve, since a query block
3004
+ * may now match that did not, or the other way round. */
2704
3005
  _sizeQueriesChanged() {
2705
- if (!(this._queried || this._supportsQueried) || this.destroyed) return;
3006
+ if (
3007
+ !(this._queried || this._supportsQueried || this._cq !== null) ||
3008
+ this.destroyed
3009
+ ) {
3010
+ return;
3011
+ }
2706
3012
  const before = this.style;
2707
3013
  // a query block may name `fontSize`, and `_syncStyle` → `_retarget` is
2708
3014
  // what pushes that into the subtree; only the node-local text props are
@@ -2710,7 +3016,19 @@ export class Node {
2710
3016
  this._syncStyle(this.props);
2711
3017
  if (localTextStyleChanged(this.style, before)) this._textContentChanged();
2712
3018
  if (this.yoga && this.style !== before) {
2713
- applyLayoutStyle(this.yoga, this.style, before);
3019
+ // A block that moved a layout property changed the tree the content
3020
+ // floors were measured from — the debt a style change from React
3021
+ // leaves too (`invalidate`, reason 'props'). It has to be marked as a
3022
+ // *content* change: the live-resize deferral takes a plain
3023
+ // `_floorsDirty` for the drag itself and lays out against the floors
3024
+ // in hand, which are the old arrangement's, and by the time the
3025
+ // catch-up looks the dirty flags are spent and no leaf's height moved
3026
+ // — so the floor of a card that turned from a row into a column would
3027
+ // stay the row's, and yoga would squeeze the column down to it.
3028
+ if (applyLayoutStyle(this.yoga, this.style, before) && this.root) {
3029
+ this.root._floorsDirty = true;
3030
+ this.root._floorsContentDirty = true;
3031
+ }
2714
3032
  }
2715
3033
  }
2716
3034
 
@@ -3699,11 +4017,58 @@ export class Node {
3699
4017
  this.yoga.getComputedWidth(),
3700
4018
  this.yoga.getComputedHeight(),
3701
4019
  );
4020
+ if (this.props.onLayout) this._reportLayout();
3702
4021
  for (const child of this.children) {
3703
4022
  if (!child.isWindow) child.absolutize(this.abs.x, this.abs.y);
3704
4023
  }
3705
4024
  }
3706
4025
 
4026
+ /**
4027
+ * `onLayout`: the rect a layout pass gave this node, reported when it
4028
+ * changed — React Native's contract, and the seam for a decision that is
4029
+ * not a style (docs/react-features.md): how many columns to build, which
4030
+ * component to render. Where the decision *is* a style, a container
4031
+ * query answers it in the same frame instead (docs/styling.md).
4032
+ *
4033
+ * `x`/`y` are the position **within the parent as laid out** — yoga's
4034
+ * answer, which a scroll does not move — rather than the window
4035
+ * coordinates `abs` holds: a list scrolling under the pointer must not
4036
+ * re-render every row on every notch. Logical pixels, this node's own,
4037
+ * the same division `measure()` makes.
4038
+ *
4039
+ * Deferred, like `onViewport`: this runs inside the layout pass, and a
4040
+ * `setState` from the handler would re-enter it. One report per frame,
4041
+ * because `absolutize` runs once, after the container blocks have
4042
+ * settled — a card whose block changed its height reports the height it
4043
+ * ended the frame at, not the one it had between passes.
4044
+ */
4045
+ _reportLayout() {
4046
+ const s = this.scale;
4047
+ const next = {
4048
+ x: this.yoga.getComputedLeft() / s,
4049
+ y: this.yoga.getComputedTop() / s,
4050
+ width: this.abs.width / s,
4051
+ height: this.abs.height / s,
4052
+ };
4053
+ const last = this._lastLayout;
4054
+ if (
4055
+ last &&
4056
+ last.x === next.x &&
4057
+ last.y === next.y &&
4058
+ last.width === next.width &&
4059
+ last.height === next.height
4060
+ ) {
4061
+ return;
4062
+ }
4063
+ this._lastLayout = next;
4064
+ setImmediate(() => {
4065
+ // the handler as it is *now*: React may have re-rendered in between
4066
+ const notify = this.props.onLayout;
4067
+ if (this.destroyed || !notify) return;
4068
+ callHandler(this, 'onLayout', notify, next);
4069
+ });
4070
+ }
4071
+
3707
4072
  /**
3708
4073
  * absolutize's write to `abs`, funneled through one place so a bounded
3709
4074
  * frame's layout diff sees every node the pass actually moved or resized.
@@ -4955,6 +5320,7 @@ export class Node {
4955
5320
  ctx.clip();
4956
5321
  }
4957
5322
  for (const child of order) {
5323
+ if (child._promoted) continue; // on a layer of its own: a hole here
4958
5324
  if (child._offscreen()) continue;
4959
5325
  if (child._outsideDamage()) continue;
4960
5326
  child.paint(ctx);
@@ -5458,8 +5824,14 @@ export class TextNode extends Node {
5458
5824
  weight: base.weight,
5459
5825
  style: base.style,
5460
5826
  });
5461
- const capHeight = font?.metrics?.(base.size)?.capHeight;
5462
- if (!capHeight) return null; // no metrics: leave the box alone
5827
+ const measured = font?.metrics?.(base.size)?.capHeight;
5828
+ if (!measured) return null; // no metrics: leave the box alone
5829
+ // Whole pixels: the trimmed box's top is the baseline less this, so a
5830
+ // fractional cap height — 9.15px for a 13px face — puts the baseline
5831
+ // between two rows, and the rasteriser lands the letters a row low on
5832
+ // one backend and half-covers two rows on the other. Rounded, the
5833
+ // baseline sits on a pixel wherever the box does.
5834
+ const capHeight = Math.round(measured);
5463
5835
  const shift = halfLeading(layout);
5464
5836
  const firstBaseline = shift + lines[0].baseline;
5465
5837
  const lastBaseline = shift + lines[lines.length - 1].baseline;
@@ -6045,6 +6417,7 @@ export const Scrollable = (Base) =>
6045
6417
  this.yoga.getComputedWidth(),
6046
6418
  this.yoga.getComputedHeight(),
6047
6419
  );
6420
+ if (this.props.onLayout) this._reportLayout();
6048
6421
  this._absolutizeChildren(this.abs.x, this.abs.y);
6049
6422
  }
6050
6423
 
@@ -8496,8 +8869,7 @@ export class TextInputNode extends Node {
8496
8869
  // colour does that on both a light and a dark palette. `#b3d4fc`
8497
8870
  // under the dark palette's near-white ink is 1.3:1, which is nothing.
8498
8871
  // Tinting the surface instead leaves the ink's own contrast intact.
8499
- ctx.fillStyle =
8500
- this.props.selectionColor ?? tint(this.theme.accent, 0.35);
8872
+ ctx.fillStyle = this.props.selectionColor ?? this.theme.selection;
8501
8873
  // One band per direction run, not one rectangle between the two caret
8502
8874
  // positions: a range is contiguous in logical order and a line is laid
8503
8875
  // out in visual order, so a selection that crosses into an Arabic word
@@ -8520,7 +8892,7 @@ export class TextInputNode extends Node {
8520
8892
  this._paintPreedit(ctx, valueX, textY, style);
8521
8893
 
8522
8894
  if (this._focused && this._caretOn && a === b) {
8523
- ctx.fillStyle = this.props.caretColor ?? style.color;
8895
+ ctx.fillStyle = this.props.caretColor ?? this.theme.caret ?? style.color;
8524
8896
  ctx.fillRect(
8525
8897
  valueX + caretX,
8526
8898
  markY,
@@ -8887,8 +9259,7 @@ export class TextAreaNode extends TextInputNode {
8887
9259
  // colour does that on both a light and a dark palette. `#b3d4fc`
8888
9260
  // under the dark palette's near-white ink is 1.3:1, which is nothing.
8889
9261
  // Tinting the surface instead leaves the ink's own contrast intact.
8890
- ctx.fillStyle =
8891
- this.props.selectionColor ?? tint(this.theme.accent, 0.35);
9262
+ ctx.fillStyle = this.props.selectionColor ?? this.theme.selection;
8892
9263
  // The bands `textRangeRects` reports — one per line and one per
8893
9264
  // direction run inside a line, which is what a selection crossing into
8894
9265
  // an Arabic word actually covers. Two caret positions and a rectangle
@@ -8997,6 +9368,12 @@ export class WindowNode extends Scrollable(Node) {
8997
9368
  // nodes with `@supports` blocks, re-resolved when the server's answer
8998
9369
  // changes rather than on every layout
8999
9370
  this._supportsQueryNodes = new Set();
9371
+ // nodes with `@container` blocks, re-resolved after every layout pass
9372
+ // against the containers they ask about — see _resolveContainerQueries.
9373
+ // `_cqFresh` is true while a pass this window just ran is being settled,
9374
+ // when every attached node has a computed size to offer
9375
+ this._containerQueryNodes = new Set();
9376
+ this._cqFresh = false;
9000
9377
  // nodes whose child list changed and whose own size is pinned: their new
9001
9378
  // arrangement is only measurable once layout has run (see
9002
9379
  // Node._childListChanged)
@@ -9493,6 +9870,13 @@ export class WindowNode extends Scrollable(Node) {
9493
9870
 
9494
9871
  let size = measure();
9495
9872
  if (this._resolveSizeQueries(size.width, size.height)) size = measure();
9873
+ // …and the container blocks against the arrangement that produced it,
9874
+ // so the window is created at the size its content will actually take
9875
+ if (this._containerQueryNodes.size !== 0) {
9876
+ this._settleContainerQueries(() => {
9877
+ size = measure();
9878
+ });
9879
+ }
9496
9880
 
9497
9881
  // A cap the content decides is its natural size, never below a floor
9498
9882
  // that was named as a number: `WM_NORMAL_HINTS` with a min above its own
@@ -10093,6 +10477,16 @@ export class WindowNode extends Scrollable(Node) {
10093
10477
  _initDnd() {
10094
10478
  const wnd = this.window;
10095
10479
  const X = this.app?.X;
10480
+ // A backend with drop machinery of its own (the cocoa backend's
10481
+ // NSDraggingDestination, src/cocoa/dnd.js): the same DropSession, driven
10482
+ // through its local entry points by the window's transport instead of
10483
+ // by XDND ClientMessages. No property to write, nothing to intern.
10484
+ if (typeof wnd?.attachDropTransport === 'function') {
10485
+ this._dnd = new DropSession(this);
10486
+ registerTopLevel(this);
10487
+ wnd.attachDropTransport(this._dnd, this);
10488
+ return;
10489
+ }
10096
10490
  if (
10097
10491
  !X ||
10098
10492
  typeof X.InternAtom !== 'function' ||
@@ -10137,11 +10531,43 @@ export class WindowNode extends Scrollable(Node) {
10137
10531
  * their top-level's count, since that is where the messages arrive. */
10138
10532
  _registerDropTarget(node) {
10139
10533
  (this._dropTargets ??= new Set()).add(node);
10534
+ this._dndTopLevel()?.window?.dropTargetsChanged?.();
10140
10535
  }
10141
10536
 
10142
10537
  _forgetDropTarget(node) {
10143
10538
  this._dropTargets?.delete(node);
10144
10539
  this._dndOwner()?.forget(node);
10540
+ this._dndTopLevel()?.window?.dropTargetsChanged?.();
10541
+ }
10542
+
10543
+ /** The top-level whose drop session — and transport — this window's
10544
+ * targets roll up into. */
10545
+ _dndTopLevel() {
10546
+ let node = this;
10547
+ while (node && !node._dnd) node = node.parent?.root;
10548
+ return node ?? null;
10549
+ }
10550
+
10551
+ /** The concrete type names every `dropAccept` under this top-level asks
10552
+ * for — what a backend that registers its accepted types up front (the
10553
+ * cocoa backend) adds to its base set. Groups and predicates name none. */
10554
+ _dndConcreteTypes() {
10555
+ const out = new Set();
10556
+ const walk = (wn) => {
10557
+ for (const node of wn._dropTargets ?? []) {
10558
+ const accept = node.props.dropAccept;
10559
+ for (const entry of Array.isArray(accept) ? accept : []) {
10560
+ if (typeof entry === 'string' && !(entry in TYPE_GROUPS)) {
10561
+ out.add(entry);
10562
+ }
10563
+ }
10564
+ }
10565
+ for (const child of wn.children) {
10566
+ if (child.isWindow && !child.isPopup) walk(child);
10567
+ }
10568
+ };
10569
+ walk(this);
10570
+ return [...out];
10145
10571
  }
10146
10572
 
10147
10573
  _dndTargetCount() {
@@ -11001,6 +11427,105 @@ export class WindowNode extends Scrollable(Node) {
11001
11427
  return true;
11002
11428
  }
11003
11429
 
11430
+ /** One layout pass at the window's size, with the content floors it
11431
+ * needs: fresh ones when something changed them, the ones in hand during
11432
+ * a live resize, none when nothing moved them. */
11433
+ _layoutStep(width, height) {
11434
+ if (!this._floorsDirty && this._floorsWidth === width) {
11435
+ this._layoutRoot(width, height);
11436
+ } else if (this._deferContentFloors(width)) {
11437
+ this._scheduleFloorsCatchUp();
11438
+ this._layoutRoot(width, height);
11439
+ } else {
11440
+ this._applyContentFloors(width, height);
11441
+ }
11442
+ }
11443
+
11444
+ /**
11445
+ * Resolve the `@container` blocks against the layout just produced, and
11446
+ * lay out again while an answer moves — a container's size is what a pass
11447
+ * *produces*, so it can only be asked about afterwards, and a block that
11448
+ * changed may carry layout properties. `relayout` is whatever pass the
11449
+ * caller runs: `_layoutStep` in a flush, `measure()` while an auto-sized
11450
+ * window is working out how big to be.
11451
+ *
11452
+ * Bounded. Two passes settle the common case (a block that matches at the
11453
+ * width the content took), and nested containers can honestly need a
11454
+ * third — an outer answer moving an inner container past one of its own
11455
+ * thresholds — so the cap is a small fixed number rather than "once".
11456
+ * What it must not do is chase a design no size satisfies; that is
11457
+ * detected per node inside `_resolveContainerQueries`, and pinned.
11458
+ */
11459
+ _settleContainerQueries(relayout) {
11460
+ if (this._containerQueryNodes.size === 0) return;
11461
+ const held = new Map();
11462
+ this._cqFresh = true;
11463
+ try {
11464
+ for (let pass = 0; pass < CONTAINER_QUERY_PASSES; pass++) {
11465
+ if (!this._resolveContainerQueries(held)) return;
11466
+ relayout();
11467
+ }
11468
+ if (DEV && this._resolveContainerQueries(held, false)) {
11469
+ console.warn(
11470
+ 'react-x11: "@container" blocks did not settle in ' +
11471
+ `${CONTAINER_QUERY_PASSES} layout passes; the last one stands. ` +
11472
+ 'More than two nested containers, each changing the next, is ' +
11473
+ 'the shape that gets here (docs/styling.md#container-queries).',
11474
+ );
11475
+ }
11476
+ } finally {
11477
+ this._cqFresh = false;
11478
+ }
11479
+ }
11480
+
11481
+ /**
11482
+ * One round of the above: every dependent whose blocks answer differently
11483
+ * against the containers as they are now is re-resolved, and the caller
11484
+ * hears whether any was. With `apply` false it only answers.
11485
+ *
11486
+ * `held` is what each node has answered so far this frame. A node that
11487
+ * comes back to an answer it already held is a design that oscillates —
11488
+ * the block moves the size it asks about, which CSS forbids by
11489
+ * construction (size containment) and yoga cannot — so it is **pinned**:
11490
+ * the answer it has stands, and stays until the container's size moves
11491
+ * for some other reason. Without the pin the next frame would find the
11492
+ * other answer, apply it, get the other size, and strobe on every layout.
11493
+ */
11494
+ _resolveContainerQueries(held, apply = true) {
11495
+ let changed = false;
11496
+ for (const node of [...this._containerQueryNodes]) {
11497
+ if (node.destroyed) {
11498
+ this._containerQueryNodes.delete(node);
11499
+ continue;
11500
+ }
11501
+ const cq = node._cq;
11502
+ const containers = node._containerSizes();
11503
+ const key = containersKey(containers);
11504
+ if (cq.pin) {
11505
+ if (cq.pin.key === key) continue;
11506
+ cq.pin = null;
11507
+ }
11508
+ const answers = containerAnswers(node._baseStyle, containers);
11509
+ if (answers === cq.answers) continue;
11510
+ if (!apply) return true;
11511
+ let seen = held.get(node);
11512
+ if (seen?.includes(answers)) {
11513
+ cq.pin = { key, containers: cq.containers };
11514
+ if (DEV) node._warnContainerOscillation();
11515
+ continue;
11516
+ }
11517
+ if (!seen) held.set(node, (seen = [cq.answers]));
11518
+ seen.push(answers);
11519
+ node._sizeQueriesChanged();
11520
+ changed = true;
11521
+ }
11522
+ // a block may carry layout properties, so the floors measured from the
11523
+ // styles it is replacing are not the answer any more — the same debt a
11524
+ // window query leaves (`_resolveSizeQueries`)
11525
+ if (changed) this._floorsDirty = true;
11526
+ return changed;
11527
+ }
11528
+
11004
11529
  /**
11005
11530
  * A node in this window has a loop declared on it. Registration is what
11006
11531
  * makes the window watch its own visibility — and only then: a
@@ -11033,6 +11558,13 @@ export class WindowNode extends Scrollable(Node) {
11033
11558
  watchDesktopSettings(this.app, () => this._refreshLoops()),
11034
11559
  ];
11035
11560
  this._loopVisibilityChanged();
11561
+ // The window has a presenter now, which it did not when a loop declared
11562
+ // at mount started on the clock (`_setRoot` runs before `realize`):
11563
+ // every loop is asked again here, so one a presenter can take moves
11564
+ // over on the window's first frame rather than at the next swap that
11565
+ // happens to re-resolve its style. Where nothing can take it, the
11566
+ // second look at an unchanged declaration is a no-op.
11567
+ this._refreshLoops();
11036
11568
  }
11037
11569
 
11038
11570
  _unwatchLoops() {
@@ -11206,8 +11738,19 @@ export class WindowNode extends Scrollable(Node) {
11206
11738
  // A retained presenter keeps a per-node diff instead of damage rects,
11207
11739
  // and this is the one channel every change already announces itself on
11208
11740
  // (docs/macos.md §"One renderer, two presenters"). Feature-detected: an
11209
- // ntk window has no ear here and the X11 path is byte-identical.
11210
- this.window?.noteInvalidate?.(damage, layoutChanged, reason);
11741
+ // ntk window has no ear here and the X11 path is byte-identical. A
11742
+ // presenter that answers `true` has taken the claim onto a layer of its
11743
+ // own (src/cocoa/promotion.js): the bitmap owes nothing for it, and the
11744
+ // frame that is still owed — for the presenter's half, `prepareFrame` —
11745
+ // paints nothing unless something else claims.
11746
+ const taken =
11747
+ this.window?.noteInvalidate?.(damage, layoutChanged, reason) === true;
11748
+ if (taken && !layoutChanged) {
11749
+ this._damage ??= [];
11750
+ this.needsPaint = true;
11751
+ this._scheduleFrame();
11752
+ return;
11753
+ }
11211
11754
  if (layoutChanged) {
11212
11755
  this.needsLayout = true;
11213
11756
  // The content floors are measured from the tree, so anything that
@@ -11313,6 +11856,11 @@ export class WindowNode extends Scrollable(Node) {
11313
11856
  this._damage = addDamageRect(this._damage, bounds, this._damageRectCap());
11314
11857
  }
11315
11858
  this.needsPaint = true;
11859
+ this._scheduleFrame();
11860
+ }
11861
+
11862
+ /** A frame, on the window's clock. */
11863
+ _scheduleFrame() {
11316
11864
  // Recorded before the `_scheduled` gate, not inside it: the debt is
11317
11865
  // "this window has damage", which a discrete event may pay off early
11318
11866
  // (see frames.js). Tying it to whether a callback is outstanding would
@@ -11375,13 +11923,12 @@ export class WindowNode extends Scrollable(Node) {
11375
11923
  // ledger's rect moves with the shift (issue #398).
11376
11924
  this._laidOut = true;
11377
11925
  this._resolveSizeQueries(width, height);
11378
- if (!this._floorsDirty && this._floorsWidth === width) {
11379
- this._layoutRoot(width, height);
11380
- } else if (this._deferContentFloors(width)) {
11381
- this._scheduleFloorsCatchUp();
11382
- this._layoutRoot(width, height);
11383
- } else {
11384
- this._applyContentFloors(width, height);
11926
+ this._layoutStep(width, height);
11927
+ // `@container` blocks are answered by the pass, not before it, and a
11928
+ // changed answer is one more pass — before `absolutize`, so the layout
11929
+ // diff below sees one arrangement against the last frame's
11930
+ if (this._containerQueryNodes.size !== 0) {
11931
+ this._settleContainerQueries(() => this._layoutStep(width, height));
11385
11932
  }
11386
11933
  this.abs = { x: 0, y: 0, width, height };
11387
11934
  this._placed = true;
@@ -11441,6 +11988,13 @@ export class WindowNode extends Scrollable(Node) {
11441
11988
  for (const node of this._reflowed) node._reflowBefore = null;
11442
11989
  this._reflowed.clear();
11443
11990
  }
11991
+ // A presenter compositing part of the tree on layers of its own — the
11992
+ // surface presenter's promoted nodes (src/cocoa/promotion.js) — gets
11993
+ // its word in here: after layout, so it sees where everything landed,
11994
+ // and before the damage is taken, so a node it moves onto or off a
11995
+ // layer claims the bitmap under it in this very frame. Feature-detected
11996
+ // like `presentFrame`; an ntk window has no such half.
11997
+ this.window.prepareFrame?.(this, layoutRan);
11444
11998
  // any node this pass laid out may be what an open popup is anchored to
11445
11999
  if (layoutRan) this._notifyAnchorChange();
11446
12000
  // after layout (the claims above included), before the damage is taken:
@@ -11986,6 +12540,11 @@ export class WindowNode extends Scrollable(Node) {
11986
12540
  if (!check(child)) return false;
11987
12541
  continue;
11988
12542
  }
12543
+ // A node on a layer of its own (src/cocoa/promotion.js) has no
12544
+ // pixels in the bitmap the band is cut from, so nothing of it can
12545
+ // be dragged along — a pulsing toast over a list is what promotion
12546
+ // is for, and this is the half of it that keeps the pan a blit.
12547
+ if (child._promoted) continue;
11989
12548
  if (rectsOverlap(child._subtreeBounds(), vp)) return false;
11990
12549
  }
11991
12550
  return true;
@@ -12188,6 +12747,14 @@ export class WindowNode extends Scrollable(Node) {
12188
12747
  this._lastReasons = EMPTY_REASONS;
12189
12748
  }
12190
12749
  if (damage === FULL_DAMAGE || !damage) return null;
12750
+ // an empty list: every claim this frame made was answered on a layer of
12751
+ // its own (`invalidate`, the presenter's `true`), and the bitmap paints
12752
+ // nothing — which is not the same as nothing having been claimed
12753
+ if (damage.length === 0) {
12754
+ this._lastDamageRects = [];
12755
+ this._lastDamage = { x: 0, y: 0, width: 0, height: 0 };
12756
+ return [];
12757
+ }
12191
12758
  const rects = [];
12192
12759
  for (const claimed of damage) {
12193
12760
  const clamped = this._clampDamage(claimed, width, height);