react-x11 2.11.0 → 2.12.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 (96) hide show
  1. package/README.md +278 -129
  2. package/package.json +10 -3
  3. package/src/Reconciler.js +15 -17
  4. package/src/a11y.js +2 -2
  5. package/src/anchor.js +7 -5
  6. package/src/bootstrap.js +14 -0
  7. package/src/clientmessage.js +1 -1
  8. package/src/cocoa/app.js +292 -49
  9. package/src/cocoa/bezels.js +175 -30
  10. package/src/cocoa/dnd.js +27 -13
  11. package/src/cocoa/fonts.js +3 -3
  12. package/src/cocoa/glarea.js +20 -3
  13. package/src/cocoa/main.d.ts +8 -0
  14. package/src/cocoa/main.js +43 -0
  15. package/src/cocoa/panehost.js +15 -5
  16. package/src/cocoa/presenter.js +13 -9
  17. package/src/cocoa/promotion.js +4 -7
  18. package/src/cocoa/relaunch.js +207 -0
  19. package/src/cocoa/threaded.js +246 -0
  20. package/src/cocoa/window.js +256 -42
  21. package/src/components/Select.js +2 -2
  22. package/src/components/anchor.js +3 -3
  23. package/src/components/native.js +12 -7
  24. package/src/components/theme.js +2 -2
  25. package/src/debug.js +1 -1
  26. package/src/decorations.js +1 -1
  27. package/src/editmenu.js +2 -2
  28. package/src/errors.js +46 -0
  29. package/src/events.js +6 -6
  30. package/src/foreignnodes.js +3 -2
  31. package/src/frames.js +2 -2
  32. package/src/glnodes.js +1 -1
  33. package/src/grid.js +1653 -0
  34. package/src/host.d.ts +230 -0
  35. package/src/host.js +11 -3
  36. package/src/imagesource.js +1 -1
  37. package/src/index.d.ts +21 -4
  38. package/src/index.js +9 -1
  39. package/src/layouts.js +721 -0
  40. package/src/node.d.ts +4 -2
  41. package/src/node.js +19 -21
  42. package/src/nodes/animation.js +644 -0
  43. package/src/nodes/box.js +21 -0
  44. package/src/nodes/boxpaint.js +473 -0
  45. package/src/nodes/canvas.js +269 -0
  46. package/src/nodes/cascade.js +600 -0
  47. package/src/nodes/damage.js +183 -0
  48. package/src/nodes/edithistory.js +124 -0
  49. package/src/nodes/editmenupopup.js +260 -0
  50. package/src/nodes/hittest.js +185 -0
  51. package/src/nodes/image.js +266 -0
  52. package/src/nodes/install.js +75 -0
  53. package/src/nodes/invalidate.js +465 -0
  54. package/src/nodes/kinds.js +31 -0
  55. package/src/nodes/layout.js +439 -0
  56. package/src/nodes/layouthost.js +949 -0
  57. package/src/nodes/node.js +868 -0
  58. package/src/nodes/paint.js +466 -0
  59. package/src/nodes/position.js +366 -0
  60. package/src/nodes/preedit.js +127 -0
  61. package/src/nodes/queries.js +330 -0
  62. package/src/nodes/rects.js +102 -0
  63. package/src/nodes/scrollable.js +891 -0
  64. package/src/nodes/scrollbars.js +138 -0
  65. package/src/nodes/scrollblit.js +1034 -0
  66. package/src/nodes/selectable.js +142 -0
  67. package/src/nodes/styling.js +225 -0
  68. package/src/nodes/text.js +649 -0
  69. package/src/nodes/textarea.js +391 -0
  70. package/src/nodes/textinput.js +1146 -0
  71. package/src/nodes/util.js +17 -0
  72. package/src/nodes/window/anchoring.js +161 -0
  73. package/src/nodes/window/capabilities.js +190 -0
  74. package/src/nodes/window/debugpaint.js +83 -0
  75. package/src/nodes/window/droptarget.js +145 -0
  76. package/src/nodes/window/floors.js +577 -0
  77. package/src/nodes/window/flush.js +334 -0
  78. package/src/nodes/window/hints.js +482 -0
  79. package/src/nodes/window/listeners.js +222 -0
  80. package/src/nodes/window/popup.js +71 -0
  81. package/src/nodes/window/size.js +591 -0
  82. package/src/nodes/window/window.js +945 -0
  83. package/src/palette.js +1 -1
  84. package/src/registry.js +7 -3
  85. package/src/styles.js +137 -15
  86. package/src/svgnodes.js +2 -1
  87. package/src/testing/harness.js +2 -2
  88. package/src/textselection.js +5 -3
  89. package/src/trace-registry.js +1 -1
  90. package/src/types/components.d.ts +38 -6
  91. package/src/types/elements.d.ts +11 -1
  92. package/src/types/nodes.d.ts +17 -2
  93. package/src/types/style.d.ts +94 -3
  94. package/src/windowstate.js +1 -1
  95. package/src/yoga.js +1 -1
  96. package/src/nodes.js +0 -13120
@@ -12,6 +12,11 @@ import { CocoaLayerPresenter } from './presenter.js';
12
12
  import { CocoaPromotion } from './promotion.js';
13
13
 
14
14
  let nextWindowId = 1;
15
+ // How long a worker's flip may keep its window's back buffer before the
16
+ // window draws into it anyway (`_armFence`). The release is reported once
17
+ // the replacing frame has committed, a fraction of a millisecond later, and
18
+ // a window must not freeze on a report that never came.
19
+ const FENCE_TIMEOUT_MS = 100;
15
20
 
16
21
  export class CocoaWindow {
17
22
  constructor(app, attributes = {}) {
@@ -34,6 +39,9 @@ export class CocoaWindow {
34
39
  // — the period of the display it is on (`_refreshFrameInterval`).
35
40
  this._rafLast = 0;
36
41
  this._frameInterval = 0;
42
+ // …and when a frame last reached glass, whoever painted it: the pump's
43
+ // paced frame or an input answered on the spot (`frameInFlight`)
44
+ this._presentedAt = -Infinity;
37
45
 
38
46
  const s = this.scale;
39
47
  // Snapped to whole POINTS: AppKit rounds window sizes to the point
@@ -75,7 +83,7 @@ export class CocoaWindow {
75
83
  // Transparent to the pointer, the preview is passed over and the hit
76
84
  // reaches what it covers (#488; @windowkit/appkit >= 0.6.0, an older
77
85
  // bridge ignores the option). The drop side is excluded separately:
78
- // nodes.js `_initDnd` gives a preview no DropSession and registers
86
+ // nodes/window/droptarget.js `_initDnd` gives a preview no DropSession and registers
79
87
  // nothing.
80
88
  if (attributes.dragPreview) options.ignoresMouseEvents = true;
81
89
  // The root layer's background is the "what newly exposed area shows"
@@ -87,14 +95,48 @@ export class CocoaWindow {
87
95
  const parsed = app._parseColor(attributes.backgroundColor);
88
96
  if (parsed) options.backgroundColor = parsed;
89
97
  }
98
+ // Where it was asked to go, until AppKit says where it went
99
+ // (`_refreshOrigin`) — which on a worker is not before the window exists.
100
+ this.x = Math.round(attributes.x ?? 0);
101
+ this.y = Math.round(attributes.y ?? 0);
102
+ this._screenOrigin = { x: this.x, y: this.y };
103
+ // AppKit's content size in points, as the bridge last reported it
104
+ // (`_frameSize`)
105
+ this._points = null;
106
+ // Threaded mode's swapchain fence (`frameInFlight`): the IOSurface id
107
+ // on the layer as far as this window has asked, the one the last flip
108
+ // is taking off glass until the bridge says it is released, and the
109
+ // catch-up copy the back buffer is owed once it is back.
110
+ this._onGlass = null;
111
+ this._awaiting = null;
112
+ this._catchUp = null;
113
+ this._fenceTimer = null;
114
+ this._shadowTimer = null;
115
+ // AppKit's live resize, between the bridge's `window-live-resize` begin
116
+ // and end (`CocoaApp._routeLiveResize`): a tick of it lays out with the
117
+ // floors it has, and fresh ones are measured after the drag
118
+ // (nodes/window/size.js, `_deferContentFloors`)
119
+ this.liveResizing = false;
90
120
  this._h = this._native.createWindow2(options);
121
+ // On a worker the bridge answers a handle at the call and makes the
122
+ // window when its command runs: the number is null until then, and
123
+ // `window-created` brings it (`_created`).
91
124
  this.windowNumber = this._native.windowNumber(this._h);
125
+ // What the app's window map and the events name this window by: the
126
+ // handle on a worker, where every window event carries it, and the
127
+ // number in pump mode, where the events carry only that.
128
+ this._key = app._threaded ? this._h : this.windowNumber;
92
129
  this._layer = this._native.windowRootLayer(this._h);
130
+ // A worker's frame lands after AppKit has moved the window's edge, so
131
+ // AppKit is asked to wait for it (`RESIZE_WAIT_MS`, src/cocoa/app.js).
132
+ if (app._threaded && app._resizeWait > 0) {
133
+ this._native.setResizeHandshake(this._h, { waitMs: app._resizeWait });
134
+ }
93
135
  this._refreshOrigin();
94
136
  this._refreshFrameInterval();
95
137
  if (attributes.sizeHints) this.setSizeHints(attributes.sizeHints);
96
138
 
97
- // How many damage rects a frame may keep before merging them (nodes.js,
139
+ // How many damage rects a frame may keep before merging them (nodes/damage.js,
98
140
  // MAX_DAMAGE_RECTS is the X11 answer). A pass here costs one CoreGraphics
99
141
  // clip and a culled walk, where an X pass costs the server a clip mask,
100
142
  // so a frame in which a clock, a graph and a status row all ticked keeps
@@ -106,7 +148,7 @@ export class CocoaWindow {
106
148
  // The retained layer presenter (docs/macos.md Tier L), behind
107
149
  // REACT_X11_COCOA_PRESENTER=layers while the surface path is the
108
150
  // measured default. Its hooks exist only in this mode, so the feature
109
- // detection in nodes.js keeps the surface path byte-identical; the
151
+ // detection in src/nodes/ keeps the surface path byte-identical; the
110
152
  // scroll blit is shadowed off because a layer frame has no backing
111
153
  // bitmap to blit. The last two are the animation seam: a transition or
112
154
  // a loop the presenter takes runs in the render server and schedules no
@@ -159,6 +201,8 @@ export class CocoaWindow {
159
201
 
160
202
  _refreshOrigin() {
161
203
  const f = this._native.getWindowFrame(this._h);
204
+ // a worker's window AppKit has not made yet: the asked-for origin stands
205
+ if (!f) return;
162
206
  const s = this.scale;
163
207
  this.x = Math.round(f.x * s);
164
208
  this.y = Math.round(f.y * s);
@@ -177,8 +221,17 @@ export class CocoaWindow {
177
221
  this._frameInterval = this.app.frameIntervalFor(this);
178
222
  }
179
223
 
224
+ /** `window-created`: AppKit has made the window a worker asked for, and
225
+ * published where it put it. */
226
+ _created(ev) {
227
+ this.windowNumber = ev.windowNumber;
228
+ this._refreshOrigin();
229
+ this._refreshFrameInterval();
230
+ }
231
+
180
232
  /** Native geometry changed (delegate event, points). */
181
233
  _nativeResized(points) {
234
+ this._points = { width: points.width, height: points.height };
182
235
  const s = this.scale;
183
236
  this.width = Math.max(1, Math.round(points.width * s));
184
237
  this.height = Math.max(1, Math.round(points.height * s));
@@ -186,12 +239,6 @@ export class CocoaWindow {
186
239
  this.y = Math.round(points.y * s);
187
240
  this._screenOrigin = { x: this.x, y: this.y };
188
241
  this._refreshFrameInterval();
189
- // AppKit's inLiveResize, as the delegate reported it: the renderer
190
- // answers a live tick with the layout floors it has and measures fresh
191
- // ones after the drag (nodes.js, `_deferContentFloors`). Cleared by
192
- // the pump (`_endLiveResizes`), because the pump cannot run while the
193
- // resize loop owns the thread — a tick of it is the drag being over.
194
- if (points.live === true) this.liveResizing = true;
195
242
  }
196
243
 
197
244
  resize(width, height) {
@@ -251,12 +298,14 @@ export class CocoaWindow {
251
298
  this.app.cancelAttention(this._attentionRequest);
252
299
  this._attentionRequest = null;
253
300
  }
301
+ clearTimeout(this._shadowTimer);
302
+ this._shadowTimer = null;
254
303
  this._promotion?.destroy();
255
304
  this._native.destroyWindow2(this._h);
256
305
  this._releaseBacking();
257
306
  }
258
307
 
259
- // --- window-manager-ish surface (feature-detected by nodes.js) -----------
308
+ // --- window-manager-ish surface (feature-detected by src/nodes/) ---------
260
309
 
261
310
  setTitle(title) {
262
311
  this.title = title;
@@ -287,7 +336,7 @@ export class CocoaWindow {
287
336
  * until the state is removed or the window goes. Every other name resolves
288
337
  * `false` — ntk's own contract for a state the server cannot honour —
289
338
  * because the bridge has no zoom/miniaturize/fullscreen verbs yet
290
- * (windowkit/appkit#15 scoped them out); nodes.js swallows the false.
339
+ * (windowkit/appkit#15 scoped them out); WindowNode swallows the false.
291
340
  */
292
341
  setWmState(names, action = 'add') {
293
342
  const list = Array.isArray(names) ? names : [names];
@@ -312,9 +361,9 @@ export class CocoaWindow {
312
361
  // --- drag and drop (src/cocoa/dnd.js) ------------------------------------
313
362
 
314
363
  /**
315
- * The drop side: nodes.js hands over the window's DropSession at realize
364
+ * The drop side: WindowNode hands over the window's DropSession at realize
316
365
  * (`_initDnd`), and from then on the app routes this window's `drag-*`
317
- * events into it. Its presence on the window is what tells nodes.js the
366
+ * events into it. Its presence on the window is what tells WindowNode the
318
367
  * backend has drop machinery of its own.
319
368
  */
320
369
  attachDropTransport(session, node) {
@@ -404,6 +453,9 @@ export class CocoaWindow {
404
453
  * that frame is on glass.
405
454
  */
406
455
  _ensureSurface() {
456
+ // a paint that did not wait for the fence: the back buffer gets its
457
+ // catch-up now, so what it paints lands over the frame before it
458
+ this._settleBack();
407
459
  const w = this.width;
408
460
  const h = this.height;
409
461
  if (
@@ -461,10 +513,15 @@ export class CocoaWindow {
461
513
  }
462
514
  this._chain = null;
463
515
  this._surface = null;
516
+ // a new pair owes nothing to what the old one was showing
517
+ clearTimeout(this._fenceTimer);
518
+ this._fenceTimer = null;
519
+ this._awaiting = null;
520
+ this._catchUp = null;
464
521
  }
465
522
 
466
523
  /**
467
- * The per-flush painted rects (nodes.js's swapchain seam), accumulated
524
+ * The per-flush painted rects (WindowNode's swapchain seam), accumulated
468
525
  * until the next present: they are what the flip's catch-up copy covers.
469
526
  * `'full'`/null collapse the set — one full copy beats bookkeeping.
470
527
  */
@@ -473,7 +530,7 @@ export class CocoaWindow {
473
530
  if (this._freshSurface) {
474
531
  this._freshSurface = false;
475
532
  // A full flush painted every pixel of the new surface, and a resize
476
- // event's flush is one (nodes.js, the 'resize' listener). A bounded
533
+ // event's flush is one (nodes/window/listeners.js, the 'resize' listener). A bounded
477
534
  // one left garbage outside its rects: hold the present until the full
478
535
  // frame asked for here lands, so the garbage is never on glass.
479
536
  if (rects) {
@@ -537,6 +594,9 @@ export class CocoaWindow {
537
594
  * caller falls back to the plain repaint. */
538
595
  scrollRegion(rect, dx, dy) {
539
596
  if (!this._surface) return false;
597
+ // the band moves over the frame before this one, not over a buffer
598
+ // still owed its catch-up
599
+ this._settleBack();
540
600
  if (!Number.isInteger(dx) || !Number.isInteger(dy)) return false;
541
601
  const moved = this._native.scrollSurface(
542
602
  this._surface,
@@ -566,8 +626,51 @@ export class CocoaWindow {
566
626
  return Boolean(moved);
567
627
  }
568
628
 
629
+ /**
630
+ * Whether this window's last frame still holds the buffer the next one
631
+ * would be drawn into — the X11 contract's fence (src/frames.js). The
632
+ * pump never needs it: a pump-mode flip is applied in the call, so the
633
+ * other buffer is off glass before the next paint. A worker's flip is a
634
+ * command the UI thread applies later, and until the bridge reports the
635
+ * buffer it replaced as released (`_surfaceReleased`), drawing into that
636
+ * buffer draws into what is on glass. A new size is never in flight: it
637
+ * paints into a new pair that nothing is showing.
638
+ *
639
+ * And while a batch is being routed (`CocoaApp._routeBatch`), its frame
640
+ * is the one owed: it goes out when the batch is done, answering every
641
+ * input in it at once. The early flush a discrete event asks for on its
642
+ * way out (src/frames.js) waits those few microseconds, rather than
643
+ * painting a state the next event in the same batch replaces.
644
+ */
569
645
  frameInFlight() {
570
- return false;
646
+ if (this.app._batching) return true;
647
+ return (
648
+ this._awaiting != null &&
649
+ this._surfaceSize?.width === this.width &&
650
+ this._surfaceSize?.height === this.height
651
+ );
652
+ }
653
+
654
+ /**
655
+ * Whether this window flipped less than one of its frame intervals ago —
656
+ * the wheel's gate (`CocoaApp._routeWheel`), and only the wheel's.
657
+ *
658
+ * A trackpad routinely lands two scroll events in one pump tick, and each
659
+ * used to paint and flip on the spot: the second frame was drawn into the
660
+ * buffer the first flip had taken off glass microseconds before, while the
661
+ * WindowServer could still be compositing from it. A scroll's frame blits,
662
+ * then repaints what the shift got wrong, so a composite caught between the
663
+ * two shows the blit alone — a held sticky header dragged up a pixel for a
664
+ * frame, or the rows under it painted over it, then corrected. No other
665
+ * input has both halves of that: a resize paints into a pair it has just
666
+ * made, and a press or a key does not come in bursts inside a refresh.
667
+ *
668
+ * Off with the interval: `frameInterval: 0` asks for no pacing at all.
669
+ */
670
+ _flippedRecently(now = performance.now()) {
671
+ return (
672
+ this._frameInterval > 0 && now - this._presentedAt < this._frameInterval
673
+ );
571
674
  }
572
675
 
573
676
  requestAnimationFrame(cb) {
@@ -597,47 +700,158 @@ export class CocoaWindow {
597
700
  // again next tick and the frame goes out the moment the window is back.
598
701
  if (this._holdPresent || !this._visible()) return false;
599
702
  this._dirty = false;
703
+ this._presentedAt = performance.now();
600
704
  if (this._chain) {
601
705
  const shown = this._chain.back;
602
706
  this._native.surfaceUnlock(shown.handle);
603
- this._native.setLayerContentsIOSurface(this._layer, shown.iosurfaceId);
707
+ this._flip(() =>
708
+ this._native.setLayerContentsIOSurface(this._layer, shown.iosurfaceId),
709
+ );
604
710
  this._chain.back = this._chain.front;
605
711
  this._chain.front = shown;
606
712
  this._surface = this._chain.back.handle;
607
713
  // a different native surface owns the graphics state now — the
608
714
  // context re-syncs its sticky state off the generation
609
715
  this._surfaceGen++;
610
- this._native.surfaceLock(this._surface);
611
- const damage = this._flushDamage;
716
+ const catchUp = { from: shown.handle, damage: this._flushDamage };
612
717
  this._flushDamage = null;
613
- this._native.copySurfaceRegion(
614
- shown.handle,
615
- this._surface,
616
- damage === 'full' || !damage
617
- ? null
618
- : damage.flatMap((r) => [
619
- Math.floor(r.x),
620
- Math.floor(r.y),
621
- Math.ceil(r.width) + 1,
622
- Math.ceil(r.height) + 1,
623
- ]),
624
- );
625
- if (this._transparentWindow) this.app._shadowStale.add(this);
718
+ // The buffer drawn into next is the one this flip takes off glass —
719
+ // when it was on glass at all: a new pair's first flip replaces a
720
+ // buffer of the old pair, or nothing. On a worker the flip has not
721
+ // happened yet, so its catch-up and the next paint wait for the
722
+ // bridge to say it has (`frameInFlight`).
723
+ const previous = this._onGlass;
724
+ this._onGlass = shown.iosurfaceId;
725
+ if (this.app._threaded && previous === this._chain.back.iosurfaceId) {
726
+ this._awaiting = previous;
727
+ this._catchUp = catchUp;
728
+ this._armFence();
729
+ } else {
730
+ this._settle(catchUp);
731
+ }
732
+ this._shadowAfterFlip();
626
733
  return true;
627
734
  }
628
- this._native.surfaceToLayer(this._surface, this._layer);
629
- // AppKit derives a transparent window's shadow from the content's
630
- // opaque shape and does not recompute it on repaints — a popup whose
631
- // card lands a frame after the map keeps the full-frame square AppKit
632
- // guessed first. Recompute — but only once this present's transaction
633
- // has actually flushed to the render server, or the recompute reads
634
- // the frame BEFORE this one and keeps the square rim for menus that
635
- // paint once and are only hovered after.
636
- if (this._transparentWindow) this.app._shadowStale.add(this);
735
+ this._flip(() => this._native.surfaceToLayer(this._surface, this._layer));
736
+ this._shadowAfterFlip();
737
+ return true;
738
+ }
739
+
740
+ /**
741
+ * Hand a frame to the layer. In pump mode that is the verb alone: the
742
+ * bridge flips in a transaction of its own, in the call. On a worker the
743
+ * flip is recorded and committed as one frame (windowkit/appkit#52), and
744
+ * the commit says what size the frame was painted at, which is what a
745
+ * resize waits for (`setResizeHandshake`, #53). Actions off, as the
746
+ * bridge's own transaction has them.
747
+ */
748
+ _flip(apply) {
749
+ if (!this.app._threaded) return apply();
750
+ const native = this._native;
751
+ native.txBegin({ disableActions: true });
752
+ try {
753
+ return apply();
754
+ } finally {
755
+ native.txCommit(this._frameSize());
756
+ }
757
+ }
758
+
759
+ /**
760
+ * The size this window's frames are painted at, in the points AppKit
761
+ * measures a window by: the bridge's own figures when they describe the
762
+ * current size, so that the handshake's comparison is exact.
763
+ */
764
+ _frameSize() {
765
+ const p = this._points;
766
+ const s = this.scale;
767
+ if (
768
+ p &&
769
+ Math.max(1, Math.round(p.width * s)) === this.width &&
770
+ Math.max(1, Math.round(p.height * s)) === this.height
771
+ ) {
772
+ return { width: p.width, height: p.height };
773
+ }
774
+ return { width: this.width / s, height: this.height / s };
775
+ }
776
+
777
+ /** The back buffer is off glass: lock it for drawing and copy across what
778
+ * the frame now shown painted, so that the next frame starts from it. */
779
+ _settle({ from, damage }) {
780
+ this._native.surfaceLock(this._surface);
781
+ this._native.copySurfaceRegion(
782
+ from,
783
+ this._surface,
784
+ damage === 'full' || !damage
785
+ ? null
786
+ : damage.flatMap((r) => [
787
+ Math.floor(r.x),
788
+ Math.floor(r.y),
789
+ Math.ceil(r.width) + 1,
790
+ Math.ceil(r.height) + 1,
791
+ ]),
792
+ );
793
+ }
794
+
795
+ /** Stop waiting on the fence: the back buffer's catch-up, now. */
796
+ _settleBack() {
797
+ if (this._awaiting == null) return;
798
+ clearTimeout(this._fenceTimer);
799
+ this._fenceTimer = null;
800
+ this._awaiting = null;
801
+ const catchUp = this._catchUp;
802
+ this._catchUp = null;
803
+ if (catchUp && this._chain) this._settle(catchUp);
804
+ }
805
+
806
+ /** `surface-released` for `id`: true when it was the buffer this window
807
+ * was waiting on. */
808
+ _surfaceReleased(id) {
809
+ if (this._awaiting == null || this._awaiting !== id) return false;
810
+ this._settleBack();
637
811
  return true;
638
812
  }
639
813
 
814
+ _armFence() {
815
+ clearTimeout(this._fenceTimer);
816
+ this._fenceTimer = setTimeout(() => {
817
+ this._fenceTimer = null;
818
+ if (this._awaiting == null || this.destroyed) return;
819
+ this._settleBack();
820
+ this.app._tickFrames();
821
+ this.app._presentAll();
822
+ }, FENCE_TIMEOUT_MS);
823
+ }
824
+
825
+ /**
826
+ * AppKit derives a transparent window's shadow from the content's opaque
827
+ * shape and does not recompute it on repaints — a popup whose card lands
828
+ * a frame after the map keeps the full-frame square AppKit guessed first.
829
+ * Recompute — but only once this present's transaction has actually
830
+ * flushed to the render server, or the recompute reads the frame BEFORE
831
+ * this one and keeps the square rim for menus that paint once and are
832
+ * only hovered after. In pump mode that is the next pump tick; on a
833
+ * worker, where the flip is a command and the recompute would be another
834
+ * one drained beside it, a frame interval later.
835
+ */
836
+ _shadowAfterFlip() {
837
+ if (!this._transparentWindow) return;
838
+ if (!this.app._threaded) {
839
+ this.app._shadowStale.add(this);
840
+ return;
841
+ }
842
+ if (this._shadowTimer) return;
843
+ this._shadowTimer = setTimeout(
844
+ () => {
845
+ this._shadowTimer = null;
846
+ if (!this.destroyed) this._native.invalidateWindowShadow(this._h);
847
+ },
848
+ Math.max(1, Math.round(this._frameInterval || 16)),
849
+ );
850
+ }
851
+
852
+ /** The window's content as a PNG at `path`; resolves whether it was
853
+ * written. A promise on either thread: on a worker AppKit answers later. */
640
854
  snapshot(path) {
641
- return this._native.snapshotWindow(this._h, path);
855
+ return this.app._ask('snapshotWindow', this._h, path);
642
856
  }
643
857
  }
@@ -73,7 +73,7 @@ const itemHeight = (fontSize) => capBand(fontSize) + ITEM_PAD * 2;
73
73
  // what NSPopUpButtonCell does with a title too long for its cell. It is
74
74
  // also what makes the label *give way*: an eliding `nowrap` floors at the
75
75
  // mark it would end in, where a clipping one keeps its full width and
76
- // pushes whatever contains it wider (nodes.js, `_wrapWidth`).
76
+ // pushes whatever contains it wider (nodes/text.js, `_wrapWidth`).
77
77
  const ONE_LINE = Object.freeze({
78
78
  textWrap: 'nowrap',
79
79
  textOverflow: 'ellipsis',
@@ -139,7 +139,7 @@ const MENU_BORDER = 1;
139
139
  // the trigger (`menuAnchorOptions`).
140
140
  const TRIGGER_PAD_LEFT = 10;
141
141
  // the scrollbar is drawn *over* the content rather than insetting it
142
- // (`nodes.js`, SCROLLBAR_WIDTH), so a menu that scrolls reserves the room
142
+ // (`nodes/scrollbars.js`, SCROLLBAR_WIDTH), so a menu that scrolls reserves the room
143
143
  const SCROLLBAR_WIDTH = 6;
144
144
 
145
145
  function normalizeOption(option) {
@@ -50,7 +50,7 @@ function sameAnchorRect(a, b) {
50
50
  * hanging over stale ground.
51
51
  *
52
52
  * Subscribes to the anchoring node's owner window (`WindowNode.onAnchorChange`,
53
- * `nodes.js`) rather than polling: that fires exactly on the events which
53
+ * `nodes/window/anchoring.js`) rather than polling: that fires exactly on the events which
54
54
  * can actually move the rect — a layout pass and a fresh `_screenOrigin` —
55
55
  * so a still trigger costs nothing and a moved one is caught the same frame.
56
56
  * `getOptions` is read fresh through a ref on every notification, so callers
@@ -64,7 +64,7 @@ function sameAnchorRect(a, b) {
64
64
  * it floating over content it no longer belongs to, detached from anything
65
65
  * the user can see it points at. So once the trigger is entirely past a
66
66
  * clipping ancestor's own bounds or past the owner window itself
67
- * (`Node._offscreen()`, the same check paint culling uses), tracking calls
67
+ * (`Node._offscreen()`, paint culling's check less its slop), tracking calls
68
68
  * `onOutOfView` instead of measuring — closing the popup, or hiding it, is
69
69
  * the caller's call — and does not resume until re-opened. With no
70
70
  * `onOutOfView` this just stops updating rather than snapping to a rect
@@ -133,7 +133,7 @@ export function useAnchorTracking(
133
133
  * with it, so the first click anywhere goes to dismissing it.
134
134
  *
135
135
  * The window manager's own focus, then, rather than anything in the tree:
136
- * `WindowNode.onWindowFocusChange` (nodes.js), which the event manager
136
+ * `WindowNode.onWindowFocusChange` (nodes/window/window.js), which the event manager
137
137
  * notifies from the same place it suspends the caret.
138
138
  */
139
139
  export function useDismissOnWindowBlur(ref, active, onDismiss) {
@@ -17,7 +17,7 @@
17
17
  // `native={false}` (the widgets keep today's drawn rendering everywhere the
18
18
  // bezel is off, so custom-designed apps lose nothing).
19
19
 
20
- import React, { useCallback } from 'react';
20
+ import React, { useCallback, useRef } from 'react';
21
21
  import { useAppOrNull } from '../appcontext.js';
22
22
  import { useTheme } from './theme.js';
23
23
 
@@ -252,18 +252,23 @@ export function Bezel({
252
252
  if (value !== undefined) params.value = value;
253
253
  const sig = JSON.stringify(params);
254
254
  const store = app?.nativeBezels;
255
+ // What this canvas drew last, drawn again while a worker's store is still
256
+ // drawing the next one (src/cocoa/bezels.js, "On a worker"): the old
257
+ // state for a frame reads better than no control at all.
258
+ const last = useRef(null);
255
259
  // Keyed on the parameter signature so an unchanged bezel keeps its
256
260
  // `onDraw` identity — CanvasNode invalidates when the closure changes,
257
261
  // and a Button re-rendered by its parent must not repaint its bezel.
258
262
  const onDraw = useCallback(
259
263
  (ctx, info) => {
260
264
  if (!store || !info.width || !info.height) return;
261
- const bezel = store.get(
262
- JSON.parse(sig),
263
- info.width,
264
- info.height,
265
- info.scale,
266
- );
265
+ const node = info.node;
266
+ const bezel =
267
+ store.get(JSON.parse(sig), info.width, info.height, info.scale, () => {
268
+ if (!node.destroyed) node.invalidate();
269
+ }) ?? last.current;
270
+ if (!bezel) return;
271
+ last.current = bezel;
267
272
  ctx.drawImage(
268
273
  { _surfaceHandle: bezel.surface },
269
274
  bezel.sx,
@@ -183,7 +183,7 @@ function planted(children, theme, style) {
183
183
  *
184
184
  * **With no provider it is the desktop's**, and this re-renders when the
185
185
  * desktop changes, which is what makes a react-x11 app that says nothing
186
- * about colour go dark on a dark desktop. `node.theme` in `nodes.js` answers
186
+ * about colour go dark on a dark desktop. `node.theme` in `nodes/cascade.js` answers
187
187
  * the same question for the other route.
188
188
  *
189
189
  * Identity matters: widgets plant what this returns on their own root node,
@@ -368,7 +368,7 @@ export const capBand = (fontSize) => Math.round(fontSize * 0.72);
368
368
  *
369
369
  * `undefined` for the width when off, never `0`: a node with no
370
370
  * `outlineWidth` of its own is one core can leave out of the widened damage
371
- * rects entirely (`_outlineExtent`, src/nodes.js), and `0` is the documented
371
+ * rects entirely (`_outlineExtent`, src/nodes/boxpaint.js), and `0` is the documented
372
372
  * way to opt *out* of a ring the node would otherwise get.
373
373
  */
374
374
  export const focusRingStyle = (theme, on) =>
package/src/debug.js CHANGED
@@ -243,7 +243,7 @@ function stackForSeq(X, seq16) {
243
243
 
244
244
  /**
245
245
  * Every 2d context this app paints windows through. react-x11 caches one
246
- * per window node (nodes.js), which is where ntk keeps `shapeStats`.
246
+ * per window node (`WindowNode`), which is where ntk keeps `shapeStats`.
247
247
  */
248
248
  function contextsOf(app) {
249
249
  return (app?._rootChildren ?? [])
@@ -1,6 +1,6 @@
1
1
  // The two decorations that are not a colour: the gradient behind a box
2
2
  // (`backgroundImage`) and the shadow under it (`boxShadow`). Both are pure
3
- // here — strings in, numbers out — so the renderer half in `nodes.js` is
3
+ // here — strings in, numbers out — so the renderer half in `nodes/boxpaint.js` is
4
4
  // only geometry and compositing, and the parsing can be tested without a
5
5
  // server (issue #345).
6
6
  //
package/src/editmenu.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * wiring at all. That rules out building it from the `Menu` components,
7
7
  * which live a layer above the nodes and cannot be mounted from one — so
8
8
  * the rows are measured and drawn here, and a node hangs them in a
9
- * `<popup>` (`openEditMenu`, nodes.js, which is the public seam).
9
+ * `<popup>` (`openEditMenu`, nodes/editmenupopup.js, which is the public seam).
10
10
  *
11
11
  * Deliberately free of node imports: this takes plain data, a measuring
12
12
  * function and a 2d context. It can be tested without a tree, and falls
@@ -64,7 +64,7 @@ export function editMenuColors(theme) {
64
64
  /**
65
65
  * The rows, in the standard order, each enabled exactly when it would do
66
66
  * something — the whole of the menu's policy, as a pure function of the
67
- * verbs a target offers (`openEditMenu`, nodes.js).
67
+ * verbs a target offers (`openEditMenu`, nodes/editmenupopup.js).
68
68
  *
69
69
  * **A verb that was not handed over is a row that is not there**, rather
70
70
  * than a greyed one. It was motivated by `<textinput sensitive>`, where a
package/src/errors.js CHANGED
@@ -59,6 +59,35 @@ export function reportHandlerError(node, handler, error) {
59
59
  markFailed();
60
60
  }
61
61
 
62
+ /**
63
+ * A throw from a layout or a placement (docs/extending.md) — code that runs
64
+ * inside a frame, where no error boundary is on the stack either. Reported
65
+ * the way a handler's throw is, `onUncaughtError` included, and the frame
66
+ * carries on with the fallback `consequence` names: one bad algorithm must
67
+ * not stop the window from painting.
68
+ */
69
+ export function reportLayoutError(node, what, error, consequence) {
70
+ const element = node?.kind ? `<${node.kind}>` : '(unknown)';
71
+ const owner = ownerName(node);
72
+ const custom = node?.app && perContainer.get(node.app);
73
+ if (custom) {
74
+ custom(error, {
75
+ componentStack: owner ? `\n in ${owner}` : undefined,
76
+ element,
77
+ handler: what,
78
+ node,
79
+ });
80
+ return;
81
+ }
82
+ console.error(
83
+ `react-x11: ${what} on ${element}${owner ? ` in ${owner}` : ''} threw. ` +
84
+ 'It ran inside a frame rather than a render, so no error boundary ' +
85
+ `could catch it. ${consequence}.`,
86
+ error,
87
+ );
88
+ markFailed();
89
+ }
90
+
62
91
  /**
63
92
  * `REACT_X11_STRICT_TOKENS=1` makes a `$token` the theme does not define
64
93
  * fatal again, for a build that would rather stop than paint something
@@ -104,6 +133,23 @@ export function reportStyleError(
104
133
  markFailed();
105
134
  }
106
135
 
136
+ /**
137
+ * A style that asks for something that is not there — a layout or a
138
+ * placement nobody registered, an option of the wrong type. Reported and
139
+ * carried on from, once per node and message, the way an unknown token is;
140
+ * with no strict switch, because the fallback it names is a real one and
141
+ * not a property silently dropped.
142
+ */
143
+ export function reportStyleProblem(node, message, consequence) {
144
+ const seen = reportedStyleErrors.get(node);
145
+ if (seen?.has(message)) return;
146
+ if (seen) seen.add(message);
147
+ else reportedStyleErrors.set(node, new Set([message]));
148
+ const owner = ownerName(node);
149
+ console.error(`${message}${owner ? ` — in ${owner}` : ''}. ${consequence}.`);
150
+ markFailed();
151
+ }
152
+
107
153
  /** Wrap a call to user code so a throw is reported instead of escaping. */
108
154
  export function callHandler(node, handler, fn, ev) {
109
155
  try {