react-x11 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +5 -3
  2. package/package.json +10 -3
  3. package/src/activate.js +12 -0
  4. package/src/anchor.js +6 -0
  5. package/src/appearance.js +351 -28
  6. package/src/appearancehooks.js +5 -2
  7. package/src/application.js +41 -0
  8. package/src/cocoa/app.js +358 -20
  9. package/src/cocoa/bezels.js +51 -1
  10. package/src/cocoa/context2d.js +271 -25
  11. package/src/cocoa/dnd.js +347 -0
  12. package/src/cocoa/dock.js +39 -0
  13. package/src/cocoa/filepanels.js +155 -0
  14. package/src/cocoa/fonts.js +93 -2
  15. package/src/cocoa/globalmenu.js +41 -33
  16. package/src/cocoa/notifications.js +244 -0
  17. package/src/cocoa/permissions.js +74 -0
  18. package/src/cocoa/presenter.js +190 -2
  19. package/src/cocoa/statusitem.js +112 -0
  20. package/src/cocoa/window.js +85 -4
  21. package/src/components/Button.js +20 -1
  22. package/src/components/Checkbox.js +17 -2
  23. package/src/components/Menu.js +108 -38
  24. package/src/components/Radio.js +17 -2
  25. package/src/components/Select.js +159 -27
  26. package/src/components/Switch.js +8 -1
  27. package/src/components/native.js +99 -0
  28. package/src/components/theme.js +37 -20
  29. package/src/desktopsettings.js +34 -2
  30. package/src/dnd.js +92 -3
  31. package/src/errors.js +6 -3
  32. package/src/filedialog.js +81 -16
  33. package/src/index.d.ts +17 -1
  34. package/src/index.js +17 -0
  35. package/src/launcher.js +170 -0
  36. package/src/launcherhooks.js +81 -0
  37. package/src/nodes.js +553 -35
  38. package/src/notificationhooks.js +56 -0
  39. package/src/notifications.js +558 -0
  40. package/src/palette.js +144 -8
  41. package/src/permissionhooks.js +89 -0
  42. package/src/permissions.js +196 -0
  43. package/src/screens.js +39 -4
  44. package/src/style.d.ts +10 -4
  45. package/src/style.js +1 -0
  46. package/src/styles.js +161 -15
  47. package/src/textselection.js +1 -4
  48. package/src/trayhooks.js +90 -0
  49. package/src/types/appearance.d.ts +24 -0
  50. package/src/types/components.d.ts +10 -0
  51. package/src/types/elements.d.ts +14 -0
  52. package/src/types/events.d.ts +14 -0
  53. package/src/types/filedialog.d.ts +18 -7
  54. package/src/types/launcher.d.ts +43 -0
  55. package/src/types/notifications.d.ts +113 -0
  56. package/src/types/permissions.d.ts +100 -0
  57. package/src/types/style.d.ts +30 -2
  58. package/src/types/system.d.ts +5 -3
  59. package/src/types/tray.d.ts +54 -0
  60. package/src/windowid.js +23 -0
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
 
@@ -1937,6 +1962,13 @@ export class Node {
1937
1962
  // `null` is "commitMount is still to come", `false` is "it has been and
1938
1963
  // gone", and an Error is one waiting for it
1939
1964
  this._tokenError = null;
1965
+ // `'@container …'` blocks (styles.js), on the nodes that carry them and
1966
+ // null on every other node: the style the record was built for, the
1967
+ // container names it asks about (`''` for the unnamed ones), the sizes
1968
+ // and answers the blocks last resolved to, and the pin an oscillating
1969
+ // design is held at — see WindowNode._resolveContainerQueries. Before
1970
+ // `_syncStyle`, which reads and writes it.
1971
+ this._cq = null;
1940
1972
  this._syncStyle(props);
1941
1973
  this.yoga = yoga ? createLayoutNode() : null;
1942
1974
  if (this.yoga) {
@@ -1993,7 +2025,8 @@ export class Node {
1993
2025
  this.states[':disabled'] = Boolean(props.disabled);
1994
2026
  // window size queries fold into the base before state blocks, so a
1995
2027
  // `:hover` inside the wide layout still wins over the wide layout
1996
- const queried = styleHasSizeQueries(this._baseStyle);
2028
+ const kinds = queryKinds(this._baseStyle);
2029
+ const queried = (kinds & QUERY_SIZE) !== 0;
1997
2030
  if (queried !== this._queried) {
1998
2031
  this._queried = queried;
1999
2032
  const root = this.root;
@@ -2009,7 +2042,7 @@ export class Node {
2009
2042
  // assigned, so the first pass has nowhere to register and a "did it
2010
2043
  // change" guard would keep it unregistered forever. Set.add is
2011
2044
  // idempotent and these blocks are rare.
2012
- const asks = styleHasSupportsQueries(this._baseStyle);
2045
+ const asks = (kinds & QUERY_SUPPORTS) !== 0;
2013
2046
  this._supportsQueried = asks;
2014
2047
  if (this.root?._supportsQueryNodes) {
2015
2048
  if (asks) this.root._supportsQueryNodes.add(this);
@@ -2029,13 +2062,53 @@ export class Node {
2029
2062
  if (wantsAttention) this.root._attentionNodes.add(this);
2030
2063
  else this.root._attentionNodes.delete(this);
2031
2064
  }
2032
- if (queried || asks) {
2065
+ // `@container` blocks keep a third registry: what re-resolves them is a
2066
+ // layout pass moving the container they ask about — neither a resize
2067
+ // nor the server's answer. The record exists only on the nodes that
2068
+ // ask, so every other node pays one bit test here and nothing below.
2069
+ // Insertion registers through `_registerSizeQueries`; this is for a
2070
+ // style that starts or stops asking on a node already in a window.
2071
+ const asksContainers = (kinds & QUERY_CONTAINER) !== 0;
2072
+ let containers = null;
2073
+ if (asksContainers) {
2074
+ const base = this._baseStyle;
2075
+ let cq = this._cq;
2076
+ if (cq === null || cq.style !== base) {
2077
+ if (cq === null) {
2078
+ cq = this._cq = {
2079
+ style: base,
2080
+ names: null,
2081
+ containers: null,
2082
+ answers: '',
2083
+ pin: null,
2084
+ warned: false,
2085
+ };
2086
+ } else {
2087
+ // a different style asks different questions, and a pin held for
2088
+ // the old one is not an answer to the new one
2089
+ cq.style = base;
2090
+ cq.pin = null;
2091
+ }
2092
+ cq.names = containerQueryNames(base);
2093
+ }
2094
+ this.root?._containerQueryNodes?.add(this);
2095
+ containers = this._pinnedContainerSizes();
2096
+ } else if (this._cq !== null) {
2097
+ this._cq = null;
2098
+ this.root?._containerQueryNodes?.delete(this);
2099
+ }
2100
+ if (queried || asks || asksContainers) {
2033
2101
  this._baseStyle = resolveQueries(this._baseStyle, {
2034
2102
  size: this.root?.querySize ?? null,
2035
2103
  // null before the window is realized, which reads as "not
2036
2104
  // supported" — the fallback design is the one that works everywhere
2037
2105
  supports: this.root?.capabilities ?? null,
2106
+ containers,
2038
2107
  });
2108
+ if (asksContainers) {
2109
+ this._cq.containers = containers;
2110
+ this._cq.answers = containerAnswers(this._baseStyle, containers);
2111
+ }
2039
2112
  }
2040
2113
  this._stateful = hasStateStyles(this._baseStyle);
2041
2114
  // The scale multiplies *after* every merge — state blocks, queries,
@@ -2085,7 +2158,7 @@ export class Node {
2085
2158
  const duration = transitionFor(target, prop);
2086
2159
  if (duration <= 0) continue;
2087
2160
  if (interpolate(from, to, 0.5) === null) continue; // no midpoint: snap
2088
- (this._anim ??= new Map()).set(prop, {
2161
+ const entry = {
2089
2162
  from,
2090
2163
  to,
2091
2164
  duration,
@@ -2094,8 +2167,23 @@ export class Node {
2094
2167
  // be seconds old — and the first tick would then find the
2095
2168
  // transition already over and jump straight to the end
2096
2169
  start: now(),
2097
- });
2098
- this.root?._startAnimating(this);
2170
+ };
2171
+ const previous = this._anim?.get(prop);
2172
+ (this._anim ??= new Map()).set(prop, entry);
2173
+ // A presenter that can run it in the render server takes it here:
2174
+ // the node's style then goes straight to the target — the layer's
2175
+ // model value — and the one frame that sends it carries the
2176
+ // animation with it (src/cocoa/presenter.js). Declined, or with no
2177
+ // such presenter, the window's frame clock runs it as it always has.
2178
+ if (this._offload(prop, entry)) {
2179
+ entry.offloaded = true;
2180
+ this.root?.invalidate(false, damageForAnimation(this), 'animation');
2181
+ } else {
2182
+ // …and one the presenter had must not keep running underneath the
2183
+ // values the clock is about to write
2184
+ if (previous?.offloaded) this._cancelOffload(prop, previous);
2185
+ this.root?._startAnimating(this);
2186
+ }
2099
2187
  }
2100
2188
  }
2101
2189
  // After the transitions, before the style is assembled: a loop that just
@@ -2187,10 +2275,77 @@ export class Node {
2187
2275
 
2188
2276
  _animatedValues() {
2189
2277
  const values = {};
2190
- for (const [prop, a] of this._anim) values[prop] = a.value ?? a.from;
2278
+ for (const [prop, a] of this._anim) {
2279
+ // an offloaded property shows its target: the render server draws the
2280
+ // motion over the model value, and the model is the style
2281
+ if (!a.offloaded) values[prop] = a.value ?? a.from;
2282
+ }
2191
2283
  return values;
2192
2284
  }
2193
2285
 
2286
+ // --- the presenter's half of an animation ---------------------------------
2287
+ //
2288
+ // Three feature-detected hooks on the window (src/cocoa/window.js, layers
2289
+ // mode only): `animateNode(node, prop, entry)` answers true when the
2290
+ // presenter will run the entry itself, `cancelNodeAnimation(node, prop)`
2291
+ // stops what it runs for the property, and the presenter calls back
2292
+ // through `_offloadEnded` / `_offloadDeclined` below. An entry the
2293
+ // presenter took is `offloaded`: it stays in `_anim` — so a retarget, a
2294
+ // loop-stop rule and `sameAnimation` all see it — but it contributes no
2295
+ // value to the style, is skipped by the tick, and keeps the node out of the
2296
+ // window's animating set. The X11 path has none of these hooks and is
2297
+ // byte-identical (docs/architecture/animation.md §4).
2298
+
2299
+ _offload(prop, entry) {
2300
+ const wnd = this.root?.window;
2301
+ if (typeof wnd?.animateNode !== 'function') return false;
2302
+ return wnd.animateNode(this, prop, entry) === true;
2303
+ }
2304
+
2305
+ _cancelOffload(prop, entry) {
2306
+ if (!entry?.offloaded) return;
2307
+ this.root?.window?.cancelNodeAnimation?.(this, prop);
2308
+ }
2309
+
2310
+ /** The presenter is done with `entry` — it ran out, or its layer went.
2311
+ * A transition is over either way (the model is the target). A loop
2312
+ * never ends on its own, so a loop that comes back this way lost its
2313
+ * layer, and the frame clock takes it over rather than letting it stop. */
2314
+ _offloadEnded(prop, entry) {
2315
+ if (this._anim?.get(prop) !== entry) return;
2316
+ if (entry.loop && !this.destroyed) {
2317
+ this._offloadDeclined(prop, entry);
2318
+ return;
2319
+ }
2320
+ this._anim.delete(prop);
2321
+ if (!this._anim.size) this.root?._animating.delete(this);
2322
+ }
2323
+
2324
+ /** The presenter could not run `entry` after all — the node turned into a
2325
+ * raster between the swap and the frame. The frame clock takes it from
2326
+ * the top; the property's declared start is where the pixels still are. */
2327
+ _offloadDeclined(prop, entry) {
2328
+ if (this._anim?.get(prop) !== entry || this.destroyed) return;
2329
+ entry.offloaded = false;
2330
+ entry.start = now();
2331
+ this.style = { ...this._targetStyle, ...this._animatedValues() };
2332
+ this.root?._startAnimating(this);
2333
+ }
2334
+
2335
+ /** Keep the frame clock running only for what the clock itself animates;
2336
+ * an offloaded-only node needs one frame — the one that sends the model
2337
+ * and the animation — and not a loop of them. */
2338
+ _scheduleAnimationFrames() {
2339
+ for (const a of this._anim?.values() ?? []) {
2340
+ if (!a.offloaded) {
2341
+ this.root?._startAnimating(this);
2342
+ return;
2343
+ }
2344
+ }
2345
+ this.root?._animating.delete(this);
2346
+ this.root?.invalidate(false, damageForAnimation(this), 'animation');
2347
+ }
2348
+
2194
2349
  /**
2195
2350
  * The style declared a set of loops (`animation`, styles.js): remember
2196
2351
  * them and reconcile what is running against them.
@@ -2237,6 +2392,7 @@ export class Node {
2237
2392
  if (!a.loop) continue;
2238
2393
  if (running && specs.some((spec) => spec.prop === prop)) continue;
2239
2394
  anim.delete(prop);
2395
+ this._cancelOffload(prop, a);
2240
2396
  changed = true;
2241
2397
  if (isLayoutProp(prop)) layoutTouched = true;
2242
2398
  }
@@ -2249,13 +2405,28 @@ export class Node {
2249
2405
  // spinner that jumps back to the start whenever anything above it
2250
2406
  // re-rendered — which is the frame after every state change in the
2251
2407
  // app.
2252
- if (current?.loop && sameAnimation(current, spec)) continue;
2253
- (this._anim ??= new Map()).set(spec.prop, {
2408
+ if (current?.loop && sameAnimation(current, spec)) {
2409
+ // A loop the clock started before the window had a presenter —
2410
+ // one declared at mount runs from `_setRoot`, before `realize` —
2411
+ // moves over the first time a presenter can take it. Its phase is
2412
+ // the render server's from here, which is what a restart costs.
2413
+ if (!current.offloaded && this._offload(spec.prop, current)) {
2414
+ current.offloaded = true;
2415
+ changed = true;
2416
+ }
2417
+ continue;
2418
+ }
2419
+ // a changed declaration, or a transition the loop takes over from:
2420
+ // whatever the presenter ran for the property stops first
2421
+ if (current?.offloaded) this._cancelOffload(spec.prop, current);
2422
+ const entry = {
2254
2423
  ...spec,
2255
2424
  loop: true,
2256
2425
  start: now(),
2257
2426
  value: animationValueAt(spec, 0),
2258
- });
2427
+ };
2428
+ (this._anim ??= new Map()).set(spec.prop, entry);
2429
+ if (this._offload(spec.prop, entry)) entry.offloaded = true;
2259
2430
  changed = true;
2260
2431
  if (isLayoutProp(spec.prop)) layoutTouched = true;
2261
2432
  }
@@ -2269,7 +2440,7 @@ export class Node {
2269
2440
  // a stop has to leave the frame clock idle, and a tick is exactly what
2270
2441
  // there may never be another of.
2271
2442
  if (!this._anim?.size) this.root?._animating.delete(this);
2272
- if (running) this.root?._startAnimating(this);
2443
+ if (running) this._scheduleAnimationFrames();
2273
2444
  if (!write) return true;
2274
2445
  if (layoutTouched && this.yoga) {
2275
2446
  applyLayoutStyle(this.yoga, this.style, before);
@@ -2316,19 +2487,23 @@ export class Node {
2316
2487
  _tickAnimations(now) {
2317
2488
  if (!this._anim?.size) return false;
2318
2489
  let layoutChanged = false;
2490
+ let ticking = 0; // entries the clock runs, as against the presenter's
2319
2491
  const before = this.style;
2320
2492
  for (const [prop, a] of this._anim) {
2493
+ if (a.offloaded) continue;
2321
2494
  if (a.loop) {
2322
2495
  // No end to test for and no rounding to accumulate: the phase is a
2323
2496
  // modulo of the elapsed time, so a bar that has been going for an
2324
2497
  // hour is exactly where the clock says.
2325
2498
  a.value = animationValueAt(a, now - a.start);
2326
2499
  if (isLayoutProp(prop)) layoutChanged = true;
2500
+ ticking++;
2327
2501
  continue;
2328
2502
  }
2329
2503
  const t = a.duration > 0 ? Math.min(1, (now - a.start) / a.duration) : 1;
2330
2504
  a.value = t >= 1 ? a.to : (interpolate(a.from, a.to, ease(t)) ?? a.to);
2331
2505
  if (t >= 1) this._anim.delete(prop);
2506
+ else ticking++;
2332
2507
  if (isLayoutProp(prop)) layoutChanged = true;
2333
2508
  }
2334
2509
  this.style = this._anim.size
@@ -2351,7 +2526,7 @@ export class Node {
2351
2526
  // it *per frame*: a transitioned `color` is a new ink every frame, for
2352
2527
  // this node and for everything inheriting from it.
2353
2528
  if (inheritedTextChanged(this.style, before)) this._retextSubtree();
2354
- return this._anim.size > 0;
2529
+ return ticking > 0;
2355
2530
  }
2356
2531
 
2357
2532
  /**
@@ -2398,11 +2573,129 @@ export class Node {
2398
2573
  if (this._wantsAttention && this.root?._attentionNodes) {
2399
2574
  this.root._attentionNodes.add(this);
2400
2575
  }
2576
+ if (this._cq !== null && this.root?._containerQueryNodes) {
2577
+ this.root._containerQueryNodes.add(this);
2578
+ // it can see the containers above it now — the constructor's
2579
+ // resolution had no ancestors to find one in
2580
+ this._sizeQueriesChanged();
2581
+ }
2401
2582
  for (const child of this.children) {
2402
2583
  if (!child.isWindow) child._registerSizeQueries();
2403
2584
  }
2404
2585
  }
2405
2586
 
2587
+ /**
2588
+ * The sizes this node's `@container` blocks resolve against: for each name
2589
+ * the style asks about, the nearest ancestor declaring it, in this node's
2590
+ * **logical** pixels — the unit the threshold beside `width: 400` was
2591
+ * written in, so the two numbers mean the same thing. Yoga's computed
2592
+ * size rather than `abs`: inside a flush, `abs` is still the previous
2593
+ * frame's.
2594
+ *
2595
+ * A container that has not been laid out yet contributes nothing, so its
2596
+ * blocks do not apply — the way a capability block does not before the
2597
+ * window exists: the fallback design is the one that works everywhere.
2598
+ * "Laid out" is `_placed` between frames and every attached node while
2599
+ * the window is settling a pass it just ran (`_cqFresh`); a fresh yoga
2600
+ * node answers NaN. Null when no container is known, which is the
2601
+ * identity `resolveQueries` keeps.
2602
+ */
2603
+ _containerSizes() {
2604
+ const names = this._cq?.names;
2605
+ if (!names) return null;
2606
+ let sizes = null;
2607
+ const s = this.scale || 1;
2608
+ const fresh = Boolean(this.root?._cqFresh);
2609
+ for (const name of names) {
2610
+ const c = this._containerFor(name);
2611
+ if (!c) {
2612
+ // In a window and nothing above declares one: a forgotten
2613
+ // declaration, not a component rendered outside its context — that
2614
+ // is what the *named* form is for, and a missing name applies
2615
+ // nothing quietly. `root` rather than `parent`: React builds a
2616
+ // subtree bottom-up, so a node can have a parent and no window yet,
2617
+ // and the container it will find is further up.
2618
+ if (DEV && name === '' && this.root) this._noContainer();
2619
+ continue;
2620
+ }
2621
+ if (!c.yoga || !(fresh || c._placed)) continue;
2622
+ const width = c.yoga.getComputedWidth() / s;
2623
+ const height = c.yoga.getComputedHeight() / s;
2624
+ if (!Number.isFinite(width) || !Number.isFinite(height)) continue;
2625
+ (sizes ??= {})[name] = { width, height };
2626
+ }
2627
+ return sizes;
2628
+ }
2629
+
2630
+ /** `_containerSizes()`, unless this node is pinned at the sizes it is
2631
+ * looking at — then the sizes its held answer came from, so a restyle
2632
+ * arriving from React does not undo what the layout pass decided. */
2633
+ _pinnedContainerSizes() {
2634
+ const live = this._containerSizes();
2635
+ const cq = this._cq;
2636
+ const pin = cq.pin;
2637
+ if (!pin) return live;
2638
+ if (pin.key === containersKey(live)) return pin.containers;
2639
+ cq.pin = null;
2640
+ return live;
2641
+ }
2642
+
2643
+ /**
2644
+ * The nearest ancestor whose style declares `container` — any container
2645
+ * for the unnamed query (`''`), the one carrying `name` otherwise, however
2646
+ * many nearer containers that reaches past. A window ends the walk after
2647
+ * offering itself, and a window asks nothing: a `<popup>` inside a
2648
+ * container is a root of its own and asks its own window with `@width`.
2649
+ *
2650
+ * Walked rather than cached: it is a dozen property reads per dependent
2651
+ * per layout pass, and a cache would have to follow every insert, every
2652
+ * reorder and every `container` value that changes above.
2653
+ */
2654
+ _containerFor(name) {
2655
+ if (this.isWindow) return null;
2656
+ for (let n = this.parent; n; n = n.parent) {
2657
+ const c = n.style?.container;
2658
+ if (name === '' ? c === true || typeof c === 'string' : c === name) {
2659
+ return n;
2660
+ }
2661
+ if (n.isWindow) break;
2662
+ }
2663
+ return null;
2664
+ }
2665
+
2666
+ _noContainer() {
2667
+ this._tokenProblem(
2668
+ [
2669
+ `react-x11: <${this.kind}> has an "@container" block and no ` +
2670
+ 'container above it — declare one with `container: true` in an ' +
2671
+ "ancestor's style (or name it and ask for it by name), or ask " +
2672
+ 'the window with "@width"',
2673
+ ],
2674
+ true,
2675
+ 'The block does not apply and the app carries on',
2676
+ );
2677
+ }
2678
+
2679
+ /** Said once per node, in development: a design that cannot settle looks
2680
+ * like a layout bug, and the frame it is pinned at is the only clue. */
2681
+ _warnContainerOscillation() {
2682
+ const cq = this._cq;
2683
+ if (cq.warned) return;
2684
+ cq.warned = true;
2685
+ const asked = [...(cq.names ?? [])]
2686
+ .map((n) => (n === '' ? 'its container' : `"${n}"`))
2687
+ .join(', ');
2688
+ console.warn(
2689
+ `react-x11: the "@container" blocks on <${this.kind}> cannot settle: ` +
2690
+ `a block that matches at one size of ${asked} changes that size to ` +
2691
+ 'one where it no longer matches, and back. A container query must ' +
2692
+ 'not move the size it asks about — give the container a size of its ' +
2693
+ 'own, or minWidth: 0 and a flexBasis so its content cannot grow it. ' +
2694
+ 'The current answer is held until the container moves for another ' +
2695
+ 'reason (docs/styling.md#container-queries).',
2696
+ );
2697
+ }
2698
+
2406
2699
  /** Style names this element claims as its own semantics (see WindowNode).
2407
2700
  * Registered elements declare theirs to `registerElement`, so the common
2408
2701
  * case needs no subclass. */
@@ -2686,12 +2979,14 @@ export class Node {
2686
2979
  * swallow the error instead of raising it late. Those throw at once, like
2687
2980
  * the keyed reorder they resemble.
2688
2981
  */
2689
- _tokenProblem(problems, mounting) {
2982
+ _tokenProblem(problems, mounting, consequence = undefined) {
2690
2983
  if (!STRICT_TOKENS) {
2691
2984
  // every one of them: two misspellings in a style are two things to
2692
2985
  // fix, and a report that named only the first would send someone back
2693
2986
  // for a second run to find the second
2694
- for (const message of problems) reportStyleError(this, message);
2987
+ for (const message of problems) {
2988
+ reportStyleError(this, message, consequence);
2989
+ }
2695
2990
  return;
2696
2991
  }
2697
2992
  const error = new Error(problems[0]);
@@ -2699,10 +2994,16 @@ export class Node {
2699
2994
  else throw error;
2700
2995
  }
2701
2996
 
2702
- /** The owning window resized: re-resolve, since a query block may now
2703
- * match that did not, or the other way round. */
2997
+ /** The owning window resized, the server's answer moved, or a layout pass
2998
+ * moved a container this node asks about: re-resolve, since a query block
2999
+ * may now match that did not, or the other way round. */
2704
3000
  _sizeQueriesChanged() {
2705
- if (!(this._queried || this._supportsQueried) || this.destroyed) return;
3001
+ if (
3002
+ !(this._queried || this._supportsQueried || this._cq !== null) ||
3003
+ this.destroyed
3004
+ ) {
3005
+ return;
3006
+ }
2706
3007
  const before = this.style;
2707
3008
  // a query block may name `fontSize`, and `_syncStyle` → `_retarget` is
2708
3009
  // what pushes that into the subtree; only the node-local text props are
@@ -2710,7 +3011,19 @@ export class Node {
2710
3011
  this._syncStyle(this.props);
2711
3012
  if (localTextStyleChanged(this.style, before)) this._textContentChanged();
2712
3013
  if (this.yoga && this.style !== before) {
2713
- applyLayoutStyle(this.yoga, this.style, before);
3014
+ // A block that moved a layout property changed the tree the content
3015
+ // floors were measured from — the debt a style change from React
3016
+ // leaves too (`invalidate`, reason 'props'). It has to be marked as a
3017
+ // *content* change: the live-resize deferral takes a plain
3018
+ // `_floorsDirty` for the drag itself and lays out against the floors
3019
+ // in hand, which are the old arrangement's, and by the time the
3020
+ // catch-up looks the dirty flags are spent and no leaf's height moved
3021
+ // — so the floor of a card that turned from a row into a column would
3022
+ // stay the row's, and yoga would squeeze the column down to it.
3023
+ if (applyLayoutStyle(this.yoga, this.style, before) && this.root) {
3024
+ this.root._floorsDirty = true;
3025
+ this.root._floorsContentDirty = true;
3026
+ }
2714
3027
  }
2715
3028
  }
2716
3029
 
@@ -3699,11 +4012,58 @@ export class Node {
3699
4012
  this.yoga.getComputedWidth(),
3700
4013
  this.yoga.getComputedHeight(),
3701
4014
  );
4015
+ if (this.props.onLayout) this._reportLayout();
3702
4016
  for (const child of this.children) {
3703
4017
  if (!child.isWindow) child.absolutize(this.abs.x, this.abs.y);
3704
4018
  }
3705
4019
  }
3706
4020
 
4021
+ /**
4022
+ * `onLayout`: the rect a layout pass gave this node, reported when it
4023
+ * changed — React Native's contract, and the seam for a decision that is
4024
+ * not a style (docs/react-features.md): how many columns to build, which
4025
+ * component to render. Where the decision *is* a style, a container
4026
+ * query answers it in the same frame instead (docs/styling.md).
4027
+ *
4028
+ * `x`/`y` are the position **within the parent as laid out** — yoga's
4029
+ * answer, which a scroll does not move — rather than the window
4030
+ * coordinates `abs` holds: a list scrolling under the pointer must not
4031
+ * re-render every row on every notch. Logical pixels, this node's own,
4032
+ * the same division `measure()` makes.
4033
+ *
4034
+ * Deferred, like `onViewport`: this runs inside the layout pass, and a
4035
+ * `setState` from the handler would re-enter it. One report per frame,
4036
+ * because `absolutize` runs once, after the container blocks have
4037
+ * settled — a card whose block changed its height reports the height it
4038
+ * ended the frame at, not the one it had between passes.
4039
+ */
4040
+ _reportLayout() {
4041
+ const s = this.scale;
4042
+ const next = {
4043
+ x: this.yoga.getComputedLeft() / s,
4044
+ y: this.yoga.getComputedTop() / s,
4045
+ width: this.abs.width / s,
4046
+ height: this.abs.height / s,
4047
+ };
4048
+ const last = this._lastLayout;
4049
+ if (
4050
+ last &&
4051
+ last.x === next.x &&
4052
+ last.y === next.y &&
4053
+ last.width === next.width &&
4054
+ last.height === next.height
4055
+ ) {
4056
+ return;
4057
+ }
4058
+ this._lastLayout = next;
4059
+ setImmediate(() => {
4060
+ // the handler as it is *now*: React may have re-rendered in between
4061
+ const notify = this.props.onLayout;
4062
+ if (this.destroyed || !notify) return;
4063
+ callHandler(this, 'onLayout', notify, next);
4064
+ });
4065
+ }
4066
+
3707
4067
  /**
3708
4068
  * absolutize's write to `abs`, funneled through one place so a bounded
3709
4069
  * frame's layout diff sees every node the pass actually moved or resized.
@@ -5458,8 +5818,14 @@ export class TextNode extends Node {
5458
5818
  weight: base.weight,
5459
5819
  style: base.style,
5460
5820
  });
5461
- const capHeight = font?.metrics?.(base.size)?.capHeight;
5462
- if (!capHeight) return null; // no metrics: leave the box alone
5821
+ const measured = font?.metrics?.(base.size)?.capHeight;
5822
+ if (!measured) return null; // no metrics: leave the box alone
5823
+ // Whole pixels: the trimmed box's top is the baseline less this, so a
5824
+ // fractional cap height — 9.15px for a 13px face — puts the baseline
5825
+ // between two rows, and the rasteriser lands the letters a row low on
5826
+ // one backend and half-covers two rows on the other. Rounded, the
5827
+ // baseline sits on a pixel wherever the box does.
5828
+ const capHeight = Math.round(measured);
5463
5829
  const shift = halfLeading(layout);
5464
5830
  const firstBaseline = shift + lines[0].baseline;
5465
5831
  const lastBaseline = shift + lines[lines.length - 1].baseline;
@@ -6045,6 +6411,7 @@ export const Scrollable = (Base) =>
6045
6411
  this.yoga.getComputedWidth(),
6046
6412
  this.yoga.getComputedHeight(),
6047
6413
  );
6414
+ if (this.props.onLayout) this._reportLayout();
6048
6415
  this._absolutizeChildren(this.abs.x, this.abs.y);
6049
6416
  }
6050
6417
 
@@ -8496,8 +8863,7 @@ export class TextInputNode extends Node {
8496
8863
  // colour does that on both a light and a dark palette. `#b3d4fc`
8497
8864
  // under the dark palette's near-white ink is 1.3:1, which is nothing.
8498
8865
  // Tinting the surface instead leaves the ink's own contrast intact.
8499
- ctx.fillStyle =
8500
- this.props.selectionColor ?? tint(this.theme.accent, 0.35);
8866
+ ctx.fillStyle = this.props.selectionColor ?? this.theme.selection;
8501
8867
  // One band per direction run, not one rectangle between the two caret
8502
8868
  // positions: a range is contiguous in logical order and a line is laid
8503
8869
  // out in visual order, so a selection that crosses into an Arabic word
@@ -8520,7 +8886,7 @@ export class TextInputNode extends Node {
8520
8886
  this._paintPreedit(ctx, valueX, textY, style);
8521
8887
 
8522
8888
  if (this._focused && this._caretOn && a === b) {
8523
- ctx.fillStyle = this.props.caretColor ?? style.color;
8889
+ ctx.fillStyle = this.props.caretColor ?? this.theme.caret ?? style.color;
8524
8890
  ctx.fillRect(
8525
8891
  valueX + caretX,
8526
8892
  markY,
@@ -8887,8 +9253,7 @@ export class TextAreaNode extends TextInputNode {
8887
9253
  // colour does that on both a light and a dark palette. `#b3d4fc`
8888
9254
  // under the dark palette's near-white ink is 1.3:1, which is nothing.
8889
9255
  // Tinting the surface instead leaves the ink's own contrast intact.
8890
- ctx.fillStyle =
8891
- this.props.selectionColor ?? tint(this.theme.accent, 0.35);
9256
+ ctx.fillStyle = this.props.selectionColor ?? this.theme.selection;
8892
9257
  // The bands `textRangeRects` reports — one per line and one per
8893
9258
  // direction run inside a line, which is what a selection crossing into
8894
9259
  // an Arabic word actually covers. Two caret positions and a rectangle
@@ -8997,6 +9362,12 @@ export class WindowNode extends Scrollable(Node) {
8997
9362
  // nodes with `@supports` blocks, re-resolved when the server's answer
8998
9363
  // changes rather than on every layout
8999
9364
  this._supportsQueryNodes = new Set();
9365
+ // nodes with `@container` blocks, re-resolved after every layout pass
9366
+ // against the containers they ask about — see _resolveContainerQueries.
9367
+ // `_cqFresh` is true while a pass this window just ran is being settled,
9368
+ // when every attached node has a computed size to offer
9369
+ this._containerQueryNodes = new Set();
9370
+ this._cqFresh = false;
9000
9371
  // nodes whose child list changed and whose own size is pinned: their new
9001
9372
  // arrangement is only measurable once layout has run (see
9002
9373
  // Node._childListChanged)
@@ -9493,6 +9864,13 @@ export class WindowNode extends Scrollable(Node) {
9493
9864
 
9494
9865
  let size = measure();
9495
9866
  if (this._resolveSizeQueries(size.width, size.height)) size = measure();
9867
+ // …and the container blocks against the arrangement that produced it,
9868
+ // so the window is created at the size its content will actually take
9869
+ if (this._containerQueryNodes.size !== 0) {
9870
+ this._settleContainerQueries(() => {
9871
+ size = measure();
9872
+ });
9873
+ }
9496
9874
 
9497
9875
  // A cap the content decides is its natural size, never below a floor
9498
9876
  // that was named as a number: `WM_NORMAL_HINTS` with a min above its own
@@ -10093,6 +10471,16 @@ export class WindowNode extends Scrollable(Node) {
10093
10471
  _initDnd() {
10094
10472
  const wnd = this.window;
10095
10473
  const X = this.app?.X;
10474
+ // A backend with drop machinery of its own (the cocoa backend's
10475
+ // NSDraggingDestination, src/cocoa/dnd.js): the same DropSession, driven
10476
+ // through its local entry points by the window's transport instead of
10477
+ // by XDND ClientMessages. No property to write, nothing to intern.
10478
+ if (typeof wnd?.attachDropTransport === 'function') {
10479
+ this._dnd = new DropSession(this);
10480
+ registerTopLevel(this);
10481
+ wnd.attachDropTransport(this._dnd, this);
10482
+ return;
10483
+ }
10096
10484
  if (
10097
10485
  !X ||
10098
10486
  typeof X.InternAtom !== 'function' ||
@@ -10137,11 +10525,43 @@ export class WindowNode extends Scrollable(Node) {
10137
10525
  * their top-level's count, since that is where the messages arrive. */
10138
10526
  _registerDropTarget(node) {
10139
10527
  (this._dropTargets ??= new Set()).add(node);
10528
+ this._dndTopLevel()?.window?.dropTargetsChanged?.();
10140
10529
  }
10141
10530
 
10142
10531
  _forgetDropTarget(node) {
10143
10532
  this._dropTargets?.delete(node);
10144
10533
  this._dndOwner()?.forget(node);
10534
+ this._dndTopLevel()?.window?.dropTargetsChanged?.();
10535
+ }
10536
+
10537
+ /** The top-level whose drop session — and transport — this window's
10538
+ * targets roll up into. */
10539
+ _dndTopLevel() {
10540
+ let node = this;
10541
+ while (node && !node._dnd) node = node.parent?.root;
10542
+ return node ?? null;
10543
+ }
10544
+
10545
+ /** The concrete type names every `dropAccept` under this top-level asks
10546
+ * for — what a backend that registers its accepted types up front (the
10547
+ * cocoa backend) adds to its base set. Groups and predicates name none. */
10548
+ _dndConcreteTypes() {
10549
+ const out = new Set();
10550
+ const walk = (wn) => {
10551
+ for (const node of wn._dropTargets ?? []) {
10552
+ const accept = node.props.dropAccept;
10553
+ for (const entry of Array.isArray(accept) ? accept : []) {
10554
+ if (typeof entry === 'string' && !(entry in TYPE_GROUPS)) {
10555
+ out.add(entry);
10556
+ }
10557
+ }
10558
+ }
10559
+ for (const child of wn.children) {
10560
+ if (child.isWindow && !child.isPopup) walk(child);
10561
+ }
10562
+ };
10563
+ walk(this);
10564
+ return [...out];
10145
10565
  }
10146
10566
 
10147
10567
  _dndTargetCount() {
@@ -11001,6 +11421,105 @@ export class WindowNode extends Scrollable(Node) {
11001
11421
  return true;
11002
11422
  }
11003
11423
 
11424
+ /** One layout pass at the window's size, with the content floors it
11425
+ * needs: fresh ones when something changed them, the ones in hand during
11426
+ * a live resize, none when nothing moved them. */
11427
+ _layoutStep(width, height) {
11428
+ if (!this._floorsDirty && this._floorsWidth === width) {
11429
+ this._layoutRoot(width, height);
11430
+ } else if (this._deferContentFloors(width)) {
11431
+ this._scheduleFloorsCatchUp();
11432
+ this._layoutRoot(width, height);
11433
+ } else {
11434
+ this._applyContentFloors(width, height);
11435
+ }
11436
+ }
11437
+
11438
+ /**
11439
+ * Resolve the `@container` blocks against the layout just produced, and
11440
+ * lay out again while an answer moves — a container's size is what a pass
11441
+ * *produces*, so it can only be asked about afterwards, and a block that
11442
+ * changed may carry layout properties. `relayout` is whatever pass the
11443
+ * caller runs: `_layoutStep` in a flush, `measure()` while an auto-sized
11444
+ * window is working out how big to be.
11445
+ *
11446
+ * Bounded. Two passes settle the common case (a block that matches at the
11447
+ * width the content took), and nested containers can honestly need a
11448
+ * third — an outer answer moving an inner container past one of its own
11449
+ * thresholds — so the cap is a small fixed number rather than "once".
11450
+ * What it must not do is chase a design no size satisfies; that is
11451
+ * detected per node inside `_resolveContainerQueries`, and pinned.
11452
+ */
11453
+ _settleContainerQueries(relayout) {
11454
+ if (this._containerQueryNodes.size === 0) return;
11455
+ const held = new Map();
11456
+ this._cqFresh = true;
11457
+ try {
11458
+ for (let pass = 0; pass < CONTAINER_QUERY_PASSES; pass++) {
11459
+ if (!this._resolveContainerQueries(held)) return;
11460
+ relayout();
11461
+ }
11462
+ if (DEV && this._resolveContainerQueries(held, false)) {
11463
+ console.warn(
11464
+ 'react-x11: "@container" blocks did not settle in ' +
11465
+ `${CONTAINER_QUERY_PASSES} layout passes; the last one stands. ` +
11466
+ 'More than two nested containers, each changing the next, is ' +
11467
+ 'the shape that gets here (docs/styling.md#container-queries).',
11468
+ );
11469
+ }
11470
+ } finally {
11471
+ this._cqFresh = false;
11472
+ }
11473
+ }
11474
+
11475
+ /**
11476
+ * One round of the above: every dependent whose blocks answer differently
11477
+ * against the containers as they are now is re-resolved, and the caller
11478
+ * hears whether any was. With `apply` false it only answers.
11479
+ *
11480
+ * `held` is what each node has answered so far this frame. A node that
11481
+ * comes back to an answer it already held is a design that oscillates —
11482
+ * the block moves the size it asks about, which CSS forbids by
11483
+ * construction (size containment) and yoga cannot — so it is **pinned**:
11484
+ * the answer it has stands, and stays until the container's size moves
11485
+ * for some other reason. Without the pin the next frame would find the
11486
+ * other answer, apply it, get the other size, and strobe on every layout.
11487
+ */
11488
+ _resolveContainerQueries(held, apply = true) {
11489
+ let changed = false;
11490
+ for (const node of [...this._containerQueryNodes]) {
11491
+ if (node.destroyed) {
11492
+ this._containerQueryNodes.delete(node);
11493
+ continue;
11494
+ }
11495
+ const cq = node._cq;
11496
+ const containers = node._containerSizes();
11497
+ const key = containersKey(containers);
11498
+ if (cq.pin) {
11499
+ if (cq.pin.key === key) continue;
11500
+ cq.pin = null;
11501
+ }
11502
+ const answers = containerAnswers(node._baseStyle, containers);
11503
+ if (answers === cq.answers) continue;
11504
+ if (!apply) return true;
11505
+ let seen = held.get(node);
11506
+ if (seen?.includes(answers)) {
11507
+ cq.pin = { key, containers: cq.containers };
11508
+ if (DEV) node._warnContainerOscillation();
11509
+ continue;
11510
+ }
11511
+ if (!seen) held.set(node, (seen = [cq.answers]));
11512
+ seen.push(answers);
11513
+ node._sizeQueriesChanged();
11514
+ changed = true;
11515
+ }
11516
+ // a block may carry layout properties, so the floors measured from the
11517
+ // styles it is replacing are not the answer any more — the same debt a
11518
+ // window query leaves (`_resolveSizeQueries`)
11519
+ if (changed) this._floorsDirty = true;
11520
+ return changed;
11521
+ }
11522
+
11004
11523
  /**
11005
11524
  * A node in this window has a loop declared on it. Registration is what
11006
11525
  * makes the window watch its own visibility — and only then: a
@@ -11375,13 +11894,12 @@ export class WindowNode extends Scrollable(Node) {
11375
11894
  // ledger's rect moves with the shift (issue #398).
11376
11895
  this._laidOut = true;
11377
11896
  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);
11897
+ this._layoutStep(width, height);
11898
+ // `@container` blocks are answered by the pass, not before it, and a
11899
+ // changed answer is one more pass — before `absolutize`, so the layout
11900
+ // diff below sees one arrangement against the last frame's
11901
+ if (this._containerQueryNodes.size !== 0) {
11902
+ this._settleContainerQueries(() => this._layoutStep(width, height));
11385
11903
  }
11386
11904
  this.abs = { x: 0, y: 0, width, height };
11387
11905
  this._placed = true;