react-x11 2.11.0 → 2.13.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 (100) hide show
  1. package/README.md +278 -129
  2. package/package.json +12 -4
  3. package/src/Reconciler.js +19 -31
  4. package/src/a11y.js +2 -2
  5. package/src/anchor.js +7 -5
  6. package/src/appcontext.js +59 -30
  7. package/src/bootstrap.js +14 -0
  8. package/src/clientmessage.js +1 -1
  9. package/src/cocoa/app.js +303 -49
  10. package/src/cocoa/bezels.js +175 -30
  11. package/src/cocoa/dnd.js +27 -13
  12. package/src/cocoa/fonts.js +3 -3
  13. package/src/cocoa/glarea.js +24 -5
  14. package/src/cocoa/main.d.ts +8 -0
  15. package/src/cocoa/main.js +43 -0
  16. package/src/cocoa/overlay.js +159 -0
  17. package/src/cocoa/panehost.js +15 -5
  18. package/src/cocoa/presenter.js +13 -9
  19. package/src/cocoa/promotion.js +17 -7
  20. package/src/cocoa/relaunch.js +207 -0
  21. package/src/cocoa/threaded.js +246 -0
  22. package/src/cocoa/window.js +256 -42
  23. package/src/components/Select.js +2 -2
  24. package/src/components/anchor.js +3 -3
  25. package/src/components/native.js +12 -7
  26. package/src/components/theme.js +2 -2
  27. package/src/debug.js +1 -1
  28. package/src/decorations.js +1 -1
  29. package/src/editmenu.js +2 -2
  30. package/src/embedding.js +31 -0
  31. package/src/errors.js +46 -0
  32. package/src/events.js +78 -18
  33. package/src/foreignnodes.js +59 -5
  34. package/src/frames.js +2 -2
  35. package/src/glnodes.js +172 -41
  36. package/src/gloverlay.js +383 -0
  37. package/src/grid.js +1653 -0
  38. package/src/host.d.ts +230 -1
  39. package/src/host.js +11 -3
  40. package/src/imagesource.js +1 -1
  41. package/src/index.d.ts +34 -4
  42. package/src/index.js +9 -1
  43. package/src/layouts.js +721 -0
  44. package/src/node.d.ts +16 -3
  45. package/src/node.js +19 -21
  46. package/src/nodes/animation.js +644 -0
  47. package/src/nodes/box.js +21 -0
  48. package/src/nodes/boxpaint.js +473 -0
  49. package/src/nodes/canvas.js +269 -0
  50. package/src/nodes/cascade.js +600 -0
  51. package/src/nodes/damage.js +183 -0
  52. package/src/nodes/edithistory.js +124 -0
  53. package/src/nodes/editmenupopup.js +260 -0
  54. package/src/nodes/hittest.js +185 -0
  55. package/src/nodes/image.js +266 -0
  56. package/src/nodes/install.js +75 -0
  57. package/src/nodes/invalidate.js +465 -0
  58. package/src/nodes/kinds.js +31 -0
  59. package/src/nodes/layout.js +439 -0
  60. package/src/nodes/layouthost.js +949 -0
  61. package/src/nodes/node.js +868 -0
  62. package/src/nodes/paint.js +466 -0
  63. package/src/nodes/position.js +366 -0
  64. package/src/nodes/preedit.js +127 -0
  65. package/src/nodes/queries.js +330 -0
  66. package/src/nodes/rects.js +102 -0
  67. package/src/nodes/scrollable.js +891 -0
  68. package/src/nodes/scrollbars.js +138 -0
  69. package/src/nodes/scrollblit.js +1034 -0
  70. package/src/nodes/selectable.js +142 -0
  71. package/src/nodes/styling.js +225 -0
  72. package/src/nodes/text.js +649 -0
  73. package/src/nodes/textarea.js +391 -0
  74. package/src/nodes/textinput.js +1146 -0
  75. package/src/nodes/util.js +17 -0
  76. package/src/nodes/window/anchoring.js +161 -0
  77. package/src/nodes/window/capabilities.js +190 -0
  78. package/src/nodes/window/debugpaint.js +83 -0
  79. package/src/nodes/window/droptarget.js +145 -0
  80. package/src/nodes/window/floors.js +577 -0
  81. package/src/nodes/window/flush.js +369 -0
  82. package/src/nodes/window/hints.js +482 -0
  83. package/src/nodes/window/listeners.js +222 -0
  84. package/src/nodes/window/popup.js +71 -0
  85. package/src/nodes/window/size.js +591 -0
  86. package/src/nodes/window/window.js +954 -0
  87. package/src/palette.js +1 -1
  88. package/src/registry.js +7 -3
  89. package/src/styles.js +137 -15
  90. package/src/svgnodes.js +2 -1
  91. package/src/testing/harness.js +2 -2
  92. package/src/textselection.js +5 -3
  93. package/src/trace-registry.js +1 -1
  94. package/src/types/components.d.ts +38 -6
  95. package/src/types/elements.d.ts +26 -14
  96. package/src/types/nodes.d.ts +17 -2
  97. package/src/types/style.d.ts +94 -3
  98. package/src/windowstate.js +1 -1
  99. package/src/yoga.js +1 -1
  100. package/src/nodes.js +0 -13120
package/src/cocoa/app.js CHANGED
@@ -4,14 +4,18 @@
4
4
  // createWindow / fonts / clipboard / X-stub / close, plus the event pump
5
5
  // that stands in for the X socket.
6
6
  //
7
- // The run loop is deliberately the simple version (docs/macos.md §"Input
8
- // and the run loop", option 1): Node's loop is master and a timer pumps
9
- // AppKit. Input latency is the pump cadence; AppKit's internal modal loops
10
- // (live resize, menu tracking) stall JS timers — but not the delegate
11
- // callbacks, which is why live resize still relayouts: the resize event
12
- // flushes the frame synchronously on its way through (`flushPendingFrames`,
13
- // the same early-flush a click gets). The CFRunLoop drain (option 2) is the
14
- // measured upgrade, not the first version.
7
+ // Two run loops. By default Node's loop is master and a timer pumps AppKit
8
+ // (docs/macos.md §"Input and the run loop", option 1): input waits for the
9
+ // pump, and AppKit's modal loops (live resize, menu tracking, a drag) stall
10
+ // JS timers — but not the delegate callbacks, which is why live resize
11
+ // still relayouts: the resize event flushes the frame synchronously on its
12
+ // way through (`flushPendingFrames`, the same early-flush a click gets).
13
+ // Under the launcher (`node --import react-x11/cocoa-main`, src/cocoa/
14
+ // main.js) the app runs on a worker instead, AppKit keeps the main thread
15
+ // in a real `[NSApp run]`, and this object is fed by the bridge's batches
16
+ // (`_routeBatch`) with no pump at all (docs/macos.md §"JS on a worker").
17
+ import { isMainThread } from 'node:worker_threads';
18
+
15
19
  import { cssColorStraight } from 'ntk';
16
20
 
17
21
  import { deliverActivate, deliverOpen } from '../application.js';
@@ -22,6 +26,7 @@ import { setScreensForTests } from '../screens.js';
22
26
  import { setScaleForTests } from '../scale.js';
23
27
  import { BezelStore } from './bezels.js';
24
28
  import { CocoaGLArea, cocoaGLConfig, resolveCocoaGLRuntime } from './glarea.js';
29
+ import { CocoaOverlayPane } from './overlay.js';
25
30
  import { CocoaDockMenu } from './dock.js';
26
31
  import { CocoaGlobalMenuExport } from './globalmenu.js';
27
32
  import { CocoaStatusItem } from './statusitem.js';
@@ -37,6 +42,7 @@ import { CocoaSurface } from './surface.js';
37
42
  import { CocoaWindow } from './window.js';
38
43
  import { decodeKey, modifierMask } from './keymap.js';
39
44
  import { loadNative } from './native.js';
45
+ import { requestAppKit, threadedChannel } from './threaded.js';
40
46
 
41
47
  // The frame interval: how often a scheduled frame may paint, in ms. The
42
48
  // default is the period of the display the window is on — `listScreens`
@@ -50,12 +56,25 @@ const PUMP_INTERVAL_MS = 8;
50
56
  // How early a pump tick may take a frame that is not quite due, in ms — the
51
57
  // drift of a timer, not a fraction of the pump (`_frameDue`).
52
58
  const FRAME_SLACK_MS = 1;
59
+ // Threaded mode's live-resize handshake (windowkit/appkit#53): how long
60
+ // AppKit may hold a resize tick for a frame painted at the new size, in ms.
61
+ // The edge moves when the delegate returns, so the budget is how long a
62
+ // slow frame may hold the drag back. 50 covers what a large tree costs —
63
+ // the Cocoa `resize` cell measures 28ms a tick at 3,662 nodes
64
+ // (docs/macos.md) — with room, where the bridge's own test met every tick
65
+ // in 3.1ms against 3ms of layout. Past three 60Hz frames an edge that waits
66
+ // reads as the drag stuttering, so a tree slower than that lets the edge
67
+ // go first and its frame land after (Flutter's Windows embedder waits up
68
+ // to 100, docs/windows.md §"Resize"). `createRoot({ cocoa: { resizeWait } })`
69
+ // sets it; 0 is no handshake at all.
70
+ const RESIZE_WAIT_MS = 50;
53
71
 
54
72
  export class CocoaApp {
55
73
  constructor(native, options = {}) {
56
74
  this._native = native;
57
75
  this.options = options;
58
- this._windows = new Map(); // windowNumber -> CocoaWindow
76
+ // CocoaWindow._key -> CocoaWindow: the number, or on a worker the handle
77
+ this._windows = new Map();
59
78
  this._grabWindow = null;
60
79
  this._rafQueue = []; // [{ cb, wnd }]
61
80
  // the app's own frame clock, for a frame no window owns (a pane's)
@@ -83,6 +102,22 @@ export class CocoaApp {
83
102
  this._activeDrag = null;
84
103
  // set by a quit request; read by close() to end the process
85
104
  this._quitting = false;
105
+ // Threaded mode (`start({ channel })`): fed by the bridge's batches
106
+ // instead of a pump. `_batching` is set while one is routed, and what
107
+ // its inputs owe is paid once, at its end (`_routeBatch`).
108
+ this._threaded = false;
109
+ this._batching = false;
110
+ this._inputOwed = false;
111
+ this._wheeled = null;
112
+ this._unsubscribe = null;
113
+ const resizeWait = options.cocoa?.resizeWait ?? RESIZE_WAIT_MS;
114
+ if (!(resizeWait >= 0) || !Number.isFinite(resizeWait)) {
115
+ throw new TypeError(
116
+ 'react-x11: cocoa.resizeWait is a number of milliseconds, 0 for ' +
117
+ `none — got ${resizeWait}.`,
118
+ );
119
+ }
120
+ this._resizeWait = resizeWait;
86
121
 
87
122
  // The activation policy has to be fixed before the app finishes
88
123
  // launching — a Regular launch registers a Dock tile, so an agent app
@@ -92,6 +127,27 @@ export class CocoaApp {
92
127
  // only when the root asked for one: a bridge without the option, or a
93
128
  // fake, is left alone.
94
129
  const policy = options.cocoa?.activationPolicy;
130
+ // Under the launcher (src/cocoa/main.js) the app launched before this
131
+ // code ran, so the policy is a switch after the fact, and a Regular
132
+ // launch has already registered its Dock tile. The bridge publishes
133
+ // what the app launched as, and takes the launch policy from
134
+ // APPKIT_ACTIVATION_POLICY (windowkit/appkit#67): a mismatch is said,
135
+ // with the variable that fixes it.
136
+ if (policy != null && threadedChannel()) {
137
+ const launched = native.activationPolicy?.();
138
+ if (
139
+ launched &&
140
+ launched !== policy &&
141
+ process.env.NODE_ENV !== 'production'
142
+ ) {
143
+ console.warn(
144
+ `react-x11: cocoa.activationPolicy is '${policy}', but the app ` +
145
+ `launched as '${launched}' — under react-x11/cocoa-main it is up ` +
146
+ 'before its code runs. Start it with ' +
147
+ `APPKIT_ACTIVATION_POLICY=${policy} to launch as '${policy}'.`,
148
+ );
149
+ }
150
+ }
95
151
  if (policy != null && typeof native.initApp === 'function') {
96
152
  native.initApp({ activationPolicy: policy });
97
153
  }
@@ -159,7 +215,9 @@ export class CocoaApp {
159
215
  // 'auto'` policy both test for this property, so a backend without it
160
216
  // (X11, the headless mock) draws the themed controls with no further
161
217
  // branching.
162
- this.nativeBezels = new BezelStore(native);
218
+ this.nativeBezels = new BezelStore(native, {
219
+ answersLater: () => this._threaded,
220
+ });
163
221
 
164
222
  // macOS privacy authorizations (src/cocoa/permissions.js). Present
165
223
  // exactly when the bridge has them (>= 0.5), and its presence is the
@@ -242,8 +300,27 @@ export class CocoaApp {
242
300
  };
243
301
  this._atoms = new Map();
244
302
 
303
+ // ntk's clipboard shape over `NSPasteboard.general` — which is the one
304
+ // pasteboard, and it is CLIPBOARD's. X's other everyday selection,
305
+ // PRIMARY, is taken by *selecting text at all* and pasted with a middle
306
+ // click, and core takes it on every selection gesture (textselection.js
307
+ // `own()`, the text controls' `_ownSelection`). Sent to the pasteboard,
308
+ // each of those gestures was a Copy: a drag across a paragraph replaced
309
+ // the link the user had just copied in another app.
310
+ //
311
+ // So every other name — PRIMARY, SECONDARY, one of the app's own — is a
312
+ // selection nobody on this desktop can paste from: a write resolves and
313
+ // changes nothing, a read finds no owner, `targets()` is empty, `clear()`
314
+ // leaves the pasteboard alone. Deliberately not a PRIMARY kept in this
315
+ // process: its point is the paste in *another* application, and inside
316
+ // one all it would keep is a middle click that types into a text field,
317
+ // which no Mac text field does. An app that wants select-to-copy anyway
318
+ // — a terminal — writes CLIPBOARD from `onSelectionChange`
319
+ // (docs/clipboard.md, "Two clipboards").
320
+ const isClipboard = (selection = 'CLIPBOARD') => selection === 'CLIPBOARD';
245
321
  this.clipboard = {
246
- write: (data) => {
322
+ write: (data, { selection } = {}) => {
323
+ if (!isClipboard(selection)) return Promise.resolve();
247
324
  const text =
248
325
  typeof data === 'string'
249
326
  ? data
@@ -251,28 +328,39 @@ export class CocoaApp {
251
328
  native.pasteboardWriteText(String(text));
252
329
  return Promise.resolve();
253
330
  },
254
- clear: () => {
255
- native.pasteboardClear();
331
+ clear: (selection) => {
332
+ if (isClipboard(selection)) native.pasteboardClear();
256
333
  return Promise.resolve();
257
334
  },
258
- targets: () => {
259
- const text = native.pasteboardReadText();
260
- return Promise.resolve(text == null ? [] : ['UTF8_STRING', 'STRING']);
335
+ targets: ({ selection } = {}) => {
336
+ if (!isClipboard(selection)) return Promise.resolve([]);
337
+ return this._ask('pasteboardReadText').then((text) =>
338
+ text == null ? [] : ['UTF8_STRING', 'STRING'],
339
+ );
261
340
  },
262
- read: ({ target } = {}) => {
263
- const text = native.pasteboardReadText();
264
- if (text == null) {
341
+ read: ({ selection, target } = {}) => {
342
+ if (!isClipboard(selection)) {
265
343
  return Promise.reject(
266
- new Error('clipboard: nothing to paste — the pasteboard is empty'),
344
+ new Error(
345
+ `clipboard: nothing to paste — ${selection} is an X11 ` +
346
+ 'selection, and on macOS only CLIPBOARD is the pasteboard',
347
+ ),
267
348
  );
268
349
  }
269
- if (target === undefined) return Promise.resolve(text);
270
- if (target === 'UTF8_STRING' || target === 'STRING') {
271
- return Promise.resolve(Buffer.from(text, 'utf8'));
272
- }
273
- return Promise.reject(
274
- new Error(`clipboard: cannot convert the pasteboard to ${target}`),
275
- );
350
+ return this._ask('pasteboardReadText').then((text) => {
351
+ if (text == null) {
352
+ throw new Error(
353
+ 'clipboard: nothing to paste — the pasteboard is empty',
354
+ );
355
+ }
356
+ if (target === undefined) return text;
357
+ if (target === 'UTF8_STRING' || target === 'STRING') {
358
+ return Buffer.from(text, 'utf8');
359
+ }
360
+ throw new Error(
361
+ `clipboard: cannot convert the pasteboard to ${target}`,
362
+ );
363
+ });
276
364
  },
277
365
  watch: () => Promise.resolve(() => {}),
278
366
  };
@@ -282,6 +370,18 @@ export class CocoaApp {
282
370
  return typeof value === 'string' ? cssColorStraight(value) : null;
283
371
  }
284
372
 
373
+ /**
374
+ * A question only AppKit can answer, as a promise: asked in the call on
375
+ * the main thread, and through a callback from a worker — the bridge's
376
+ * rule for every read that has to ask AppKit rather than read what it
377
+ * published (windowkit/appkit#51).
378
+ */
379
+ _ask(verb, ...args) {
380
+ const native = this._native;
381
+ if (!this._threaded) return Promise.resolve(native[verb](...args));
382
+ return new Promise((resolve) => native[verb](...args, resolve));
383
+ }
384
+
285
385
  findArgbVisual() {
286
386
  // every Cocoa window composites; the "visual" is a formality
287
387
  return { visual: 1, depth: 32 };
@@ -319,6 +419,16 @@ export class CocoaApp {
319
419
  return cocoaGLConfig(this, spec);
320
420
  }
321
421
 
422
+ /**
423
+ * The pane a `<glarea>`'s children are drawn on (src/gloverlay.js): one
424
+ * transparent layer above the surface's, which Core Animation composites —
425
+ * so the overlay blends with the GL frame here, where an X11 pane is an
426
+ * opaque child window. Having this at all is how the overlay knows.
427
+ */
428
+ createOverlayPane(attributes) {
429
+ return new CocoaOverlayPane(this, attributes);
430
+ }
431
+
322
432
  /**
323
433
  * The Frame host seam (src/frame/index.js): a pane's composited region
324
434
  * in this window. Its presence is what routes <Frame> to the shared-
@@ -375,7 +485,7 @@ export class CocoaApp {
375
485
  }
376
486
 
377
487
  _registerWindow(wnd) {
378
- this._windows.set(wnd.windowNumber, wnd);
488
+ this._windows.set(wnd._key, wnd);
379
489
  }
380
490
 
381
491
  /**
@@ -516,14 +626,14 @@ export class CocoaApp {
516
626
  }
517
627
 
518
628
  _unregisterWindow(wnd) {
519
- this._windows.delete(wnd.windowNumber);
629
+ this._windows.delete(wnd._key);
520
630
  if (this._grabWindow === wnd) this._grabWindow = null;
521
631
  }
522
632
 
523
633
  // --- the pump ------------------------------------------------------------
524
634
 
525
- start({ pumpInterval = PUMP_INTERVAL_MS } = {}) {
526
- if (this._pump) return;
635
+ start({ pumpInterval = PUMP_INTERVAL_MS, channel = null } = {}) {
636
+ if (this._pump || this._threaded) return;
527
637
  this._pumpInterval = pumpInterval;
528
638
  const native = this._native;
529
639
  // A pane process has no NSApplication to pump — no windows, no events,
@@ -535,10 +645,13 @@ export class CocoaApp {
535
645
  }, pumpInterval);
536
646
  return;
537
647
  }
648
+ if (channel) {
649
+ this._startThreaded(channel);
650
+ return;
651
+ }
538
652
  native.initApp();
539
653
  native.setBackendEventCallback((ev) => this._route(ev));
540
654
  this._pump = setInterval(() => {
541
- this._endLiveResizes();
542
655
  native.pump2(); // flushes the previous tick's CATransaction
543
656
  if (this._shadowStale.size) {
544
657
  for (const wnd of this._shadowStale) {
@@ -552,13 +665,17 @@ export class CocoaApp {
552
665
  }
553
666
 
554
667
  /**
555
- * A pump tick means no modal loop owns the thread, so no window is being
556
- * resized live right now: the flag the delegate set on the last tick of a
557
- * drag comes off here, ahead of the frames that tick, and the catch-up
558
- * frame a deferred layout owes (nodes.js) runs on this very tick.
668
+ * Threaded mode: the launcher's channel is open (src/cocoa/threaded.js),
669
+ * the main thread is in `[NSApp run]`, and this app is on a worker. There
670
+ * is no pump. Events arrive in the bridge's batches whenever AppKit has
671
+ * them, from inside its modal loops too, and the frame clock arms a timer
672
+ * for every frame it is owed, at whatever distance (`_armFrameTimer`).
559
673
  */
560
- _endLiveResizes() {
561
- for (const wnd of this._windows.values()) wnd.liveResizing = false;
674
+ _startThreaded(channel) {
675
+ this._threaded = true;
676
+ this._pumpInterval = Infinity;
677
+ this._native.initApp();
678
+ this._unsubscribe = channel.subscribe((batch) => this._routeBatch(batch));
562
679
  }
563
680
 
564
681
  /**
@@ -576,7 +693,7 @@ export class CocoaApp {
576
693
  */
577
694
  _requestFrame(cb, wnd = null) {
578
695
  this._rafQueue.push({ cb, wnd });
579
- if (this._pump && this._rafQueue.length === 1) {
696
+ if ((this._pump || this._threaded) && this._rafQueue.length === 1) {
580
697
  const now = performance.now();
581
698
  this._armFrameTimer(Math.max(1, this._frameWait(wnd ?? this, now)), now);
582
699
  }
@@ -632,7 +749,8 @@ export class CocoaApp {
632
749
  * its own transaction (the bridge flushes on the flip), so what is
633
750
  * painted here is on glass without waiting for the next pump. What the
634
751
  * tick alone still does is pump AppKit's events, which is what
635
- * `pumpInterval` stays the cadence of.
752
+ * `pumpInterval` stays the cadence of. With no pump at all — threaded
753
+ * mode, where `_pumpInterval` is Infinity — every frame is one of these.
636
754
  */
637
755
  _armFrameTimer(wait, now) {
638
756
  if (!(wait > 0 && wait < this._pumpInterval)) return;
@@ -671,6 +789,15 @@ export class CocoaApp {
671
789
  this._rafQueue = [];
672
790
  let soonest = Infinity;
673
791
  for (const entry of queue) {
792
+ // A window whose last flip has not given its back buffer back yet
793
+ // (threaded mode's fence, `CocoaWindow.frameInFlight`) waits before
794
+ // its clock is asked, or the clock would count a frame that did not
795
+ // run. The release is an event, and the batch it arrives in ticks
796
+ // again.
797
+ if (entry.wnd?.frameInFlight?.()) {
798
+ this._rafQueue.push(entry);
799
+ continue;
800
+ }
674
801
  const clock = entry.wnd ?? this;
675
802
  if (!due.has(clock)) due.set(clock, this._frameDue(clock, now));
676
803
  if (!due.get(clock)) {
@@ -717,11 +844,51 @@ export class CocoaApp {
717
844
  * exactly when it was worth showing.
718
845
  */
719
846
  _afterInput() {
847
+ // threaded mode pays it once, for the whole batch (`_routeBatch`)
848
+ if (this._batching) {
849
+ this._inputOwed = true;
850
+ return;
851
+ }
720
852
  flushSyncWork();
721
853
  flushPendingFrames();
722
854
  this._presentAll();
723
855
  }
724
856
 
857
+ /**
858
+ * Threaded mode's delivery: every event the bridge emitted since the
859
+ * last wake, in order. Each is routed as the pump routes it, but what an
860
+ * input owes — React's half, the paint, the present (`_afterInput`) — is
861
+ * paid once, when the batch is done: ten moves and a click that crossed
862
+ * while this thread was busy are one frame, not eleven. Then what a pump
863
+ * tick does: the frames that are due, and the ones a window's visibility
864
+ * or its back buffer was holding, which an occlusion change or a
865
+ * `surface-released` in this very batch may just have freed.
866
+ */
867
+ _routeBatch(batch) {
868
+ if (this._closed) return;
869
+ const started = performance.now();
870
+ this._batching = true;
871
+ try {
872
+ for (const ev of batch) this._route(ev);
873
+ } finally {
874
+ this._batching = false;
875
+ }
876
+ if (this._closed) return;
877
+ if (this._inputOwed) {
878
+ this._inputOwed = false;
879
+ this._afterInput();
880
+ }
881
+ // a frame that answered the wheel is this refresh's (`_routeWheel`)
882
+ if (this._wheeled) {
883
+ for (const wnd of this._wheeled) {
884
+ if (wnd._presentedAt >= started) wnd._rafLast = wnd._presentedAt;
885
+ }
886
+ this._wheeled = null;
887
+ }
888
+ this._tickFrames();
889
+ this._presentAll();
890
+ }
891
+
725
892
  // --- event routing -------------------------------------------------------
726
893
 
727
894
  _route(ev) {
@@ -802,15 +969,25 @@ export class CocoaApp {
802
969
  // id nobody knows is an animation already forgotten (cancelled, or
803
970
  // its layer dropped), and the bridge's report is just late
804
971
  return this._animationEnds.get(ev.id)?.(ev);
972
+ case 'window-live-resize':
973
+ return this._routeLiveResize(ev);
974
+ case 'window-created':
975
+ // a worker's window, made on the UI thread after the call that
976
+ // answered its handle
977
+ return this._window(ev)?._created(ev);
978
+ case 'surface-released':
979
+ return this._routeSurfaceReleased(ev);
805
980
  default:
806
981
  return undefined;
807
982
  }
808
983
  }
809
984
 
985
+ /** The window an event is about. On a worker every window event names
986
+ * the bridge's handle, which is what windows are keyed by there; in pump
987
+ * mode the events name the number. */
810
988
  _window(ev) {
811
- return ev.windowNumber != null
812
- ? (this._windows.get(ev.windowNumber) ?? null)
813
- : null;
989
+ const key = ev.handle ?? ev.windowNumber;
990
+ return key != null ? (this._windows.get(key) ?? null) : null;
814
991
  }
815
992
 
816
993
  /**
@@ -894,11 +1071,34 @@ export class CocoaApp {
894
1071
  });
895
1072
  // Painted now, like a press. On X11 the wheel is paced on the frame
896
1073
  // clock because ntk coalesces a touchpad's dozens of reports per frame
897
- // into one event; AppKit already delivers scroll events at the
898
- // display's rate, so answering each one is answering once per refresh
899
- // — and answering it on the next frame tick instead was a 15ms median
900
- // between the notch and the scroll, most of a refresh period of nothing.
1074
+ // into one event; AppKit mostly delivers scroll events at the display's
1075
+ // rate, so answering each one is answering once per refresh — and
1076
+ // answering it on the next frame tick instead was a 15ms median between
1077
+ // the notch and the scroll, most of a refresh period of nothing.
1078
+ //
1079
+ // Mostly: a trackpad's momentum lands two in one tick often, and a
1080
+ // second flip inside the refresh draws into the buffer the first one
1081
+ // just took off glass (`CocoaWindow._flippedRecently` says why that
1082
+ // shows). So the rest of a burst lands React's half and leaves the
1083
+ // paint to the paced frame the scroll already asked for — the model
1084
+ // has scrolled, and the next refresh shows all of it.
1085
+ if (wnd._flippedRecently()) {
1086
+ flushSyncWork();
1087
+ return;
1088
+ }
1089
+ // in a batch the frame goes out when the batch is done, and the clock
1090
+ // restarts from it there (`_routeBatch`)
1091
+ if (this._batching) {
1092
+ (this._wheeled ??= new Set()).add(wnd);
1093
+ this._inputOwed = true;
1094
+ return;
1095
+ }
1096
+ const started = performance.now();
901
1097
  this._afterInput();
1098
+ // …and a frame that answered the wheel is this refresh's frame: the
1099
+ // window's clock restarts from it, or a pump tick a few milliseconds
1100
+ // later finds the clock due and flips again.
1101
+ if (wnd._presentedAt >= started) wnd._rafLast = wnd._presentedAt;
902
1102
  }
903
1103
 
904
1104
  _routeKey(ev) {
@@ -934,6 +1134,10 @@ export class CocoaApp {
934
1134
  // During a live resize AppKit's modal loop owns the thread and Node
935
1135
  // timers stall; flushing here is what keeps layout tracking the drag.
936
1136
  this._afterInput();
1137
+ // On a worker no modal loop holds this thread: a microtask queued here
1138
+ // runs as soon as the batch returns, and what it commits schedules its
1139
+ // frame like any other.
1140
+ if (this._threaded) return;
937
1141
  // The React HALF of the response — an anchored popup following the
938
1142
  // window, an onResize setState — commits on a microtask AFTER this
939
1143
  // handler returns, and the pump that would paint it is the thing the
@@ -959,6 +1163,7 @@ export class CocoaApp {
959
1163
  * present waits with them (`CocoaWindow._visible`); on, the next pump
960
1164
  * tick runs the catch-up frame and puts it on glass — no early flush,
961
1165
  * since nothing was asked for and the pump is at most one interval away.
1166
+ * On a worker the end of the batch the event came in is that tick.
962
1167
  */
963
1168
  _routeOcclusion(ev) {
964
1169
  const wnd = this._window(ev);
@@ -966,6 +1171,33 @@ export class CocoaApp {
966
1171
  wnd._occluded = ev.visible === false;
967
1172
  }
968
1173
 
1174
+ /**
1175
+ * `windowWillStartLiveResize:` / `windowDidEndLiveResize:`, as the bridge
1176
+ * brackets the drag AppKit's tracking loop runs (windowkit/appkit#63).
1177
+ * Between the two a tick lays out with the floors it has; the end is the
1178
+ * catch-up frame's cue (nodes/window/size.js), which the next frame tick
1179
+ * runs — in pump mode the tick after the loop lets go, on a worker the
1180
+ * end of this batch.
1181
+ */
1182
+ _routeLiveResize(ev) {
1183
+ const wnd = this._window(ev);
1184
+ if (!wnd || wnd.destroyed) return;
1185
+ wnd.liveResizing = ev.phase === 'begin';
1186
+ }
1187
+
1188
+ /**
1189
+ * `surface-released`: a worker's frame took an IOSurface off a layer and
1190
+ * the frame that replaced it has committed (windowkit/appkit#52). The
1191
+ * window whose back buffer that was stops waiting on it
1192
+ * (`CocoaWindow._surfaceReleased`), and the tick at the end of this
1193
+ * batch runs the frame it was holding.
1194
+ */
1195
+ _routeSurfaceReleased(ev) {
1196
+ for (const wnd of this._windows.values()) {
1197
+ if (wnd._surfaceReleased(ev.id)) return;
1198
+ }
1199
+ }
1200
+
969
1201
  _routeAccessibility(ev) {
970
1202
  for (const fn of [...this._a11yListeners]) fn(ev);
971
1203
  }
@@ -1126,7 +1358,9 @@ export class CocoaApp {
1126
1358
  this._frameTimer = null;
1127
1359
  this._cocoaGL?.destroy();
1128
1360
  this._cocoaGL = null;
1129
- this._native.setBackendEventCallback(null);
1361
+ this._unsubscribe?.();
1362
+ this._unsubscribe = null;
1363
+ if (!this._threaded) this._native.setBackendEventCallback(null);
1130
1364
  for (const wnd of [...this._windows.values()]) wnd.destroy();
1131
1365
  // A quit the app accepted: the tree is down and the connection closed,
1132
1366
  // and macOS is still waiting on the Cancel the bridge answered with. End
@@ -1187,12 +1421,32 @@ export function screenLayout(screens, scale) {
1187
1421
  */
1188
1422
  export async function createCocoaApp(options = {}) {
1189
1423
  const native = loadNative();
1424
+ // The launcher's channel, when the app is on its worker. A worker without
1425
+ // it has no thread running AppKit: the pump cannot run off the main
1426
+ // thread (the bridge refuses), and a window asked for would never be made.
1427
+ const channel = threadedChannel();
1428
+ if (!channel && !isMainThread) {
1429
+ throw new Error(
1430
+ 'react-x11: the cocoa backend runs on a worker thread only under its ' +
1431
+ 'launcher, which keeps AppKit on the main thread — start the app ' +
1432
+ 'with `node --import react-x11/cocoa-main app.js`, or create the ' +
1433
+ 'root on the main thread.',
1434
+ );
1435
+ }
1436
+ // AppKit is launched on the first cocoa root, not before — an app on a
1437
+ // worker that never makes one gets no Dock tile (src/cocoa/relaunch.js)
1438
+ if (channel) await requestAppKit(native);
1190
1439
  const app = new CocoaApp(native, options);
1191
1440
 
1192
1441
  setScaleForTests(app, app.scale, 'cocoa');
1193
1442
  setScreensForTests(app, screenLayout(app._screens, app.scale));
1194
1443
  setCompositingForTests(app, true);
1195
1444
 
1196
- app.start(options.cocoa ?? {});
1445
+ app.start({ ...options.cocoa, channel });
1446
+ // On a worker a control's bezel is measured and drawn on the UI thread
1447
+ // and answered later (windowkit/appkit#54), so the metrics layout reads
1448
+ // synchronously are asked for now, beside the rest of startup — the yoga
1449
+ // load `createRoot` runs alongside takes longer.
1450
+ if (channel) await app.nativeBezels.prefetch(app.scale);
1197
1451
  return app;
1198
1452
  }