react-x11 2.10.2 → 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 (100) 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 +304 -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/screencolor.js +62 -0
  20. package/src/cocoa/threaded.js +246 -0
  21. package/src/cocoa/window.js +256 -42
  22. package/src/components/Select.js +2 -2
  23. package/src/components/anchor.js +3 -3
  24. package/src/components/native.js +12 -7
  25. package/src/components/theme.js +2 -2
  26. package/src/debug.js +1 -1
  27. package/src/decorations.js +1 -1
  28. package/src/editmenu.js +2 -2
  29. package/src/errors.js +46 -0
  30. package/src/events.js +6 -6
  31. package/src/foreignnodes.js +3 -2
  32. package/src/frames.js +2 -2
  33. package/src/glnodes.js +1 -1
  34. package/src/grid.js +1653 -0
  35. package/src/host.d.ts +230 -0
  36. package/src/host.js +11 -3
  37. package/src/imagesource.js +1 -1
  38. package/src/index.d.ts +21 -4
  39. package/src/index.js +9 -1
  40. package/src/layouts.js +721 -0
  41. package/src/node.d.ts +4 -2
  42. package/src/node.js +19 -21
  43. package/src/nodes/animation.js +644 -0
  44. package/src/nodes/box.js +21 -0
  45. package/src/nodes/boxpaint.js +473 -0
  46. package/src/nodes/canvas.js +269 -0
  47. package/src/nodes/cascade.js +600 -0
  48. package/src/nodes/damage.js +183 -0
  49. package/src/nodes/edithistory.js +124 -0
  50. package/src/nodes/editmenupopup.js +260 -0
  51. package/src/nodes/hittest.js +185 -0
  52. package/src/nodes/image.js +266 -0
  53. package/src/nodes/install.js +75 -0
  54. package/src/nodes/invalidate.js +465 -0
  55. package/src/nodes/kinds.js +31 -0
  56. package/src/nodes/layout.js +439 -0
  57. package/src/nodes/layouthost.js +949 -0
  58. package/src/nodes/node.js +868 -0
  59. package/src/nodes/paint.js +466 -0
  60. package/src/nodes/position.js +366 -0
  61. package/src/nodes/preedit.js +127 -0
  62. package/src/nodes/queries.js +330 -0
  63. package/src/nodes/rects.js +102 -0
  64. package/src/nodes/scrollable.js +891 -0
  65. package/src/nodes/scrollbars.js +138 -0
  66. package/src/nodes/scrollblit.js +1034 -0
  67. package/src/nodes/selectable.js +142 -0
  68. package/src/nodes/styling.js +225 -0
  69. package/src/nodes/text.js +649 -0
  70. package/src/nodes/textarea.js +391 -0
  71. package/src/nodes/textinput.js +1146 -0
  72. package/src/nodes/util.js +17 -0
  73. package/src/nodes/window/anchoring.js +161 -0
  74. package/src/nodes/window/capabilities.js +190 -0
  75. package/src/nodes/window/debugpaint.js +83 -0
  76. package/src/nodes/window/droptarget.js +145 -0
  77. package/src/nodes/window/floors.js +577 -0
  78. package/src/nodes/window/flush.js +334 -0
  79. package/src/nodes/window/hints.js +482 -0
  80. package/src/nodes/window/listeners.js +222 -0
  81. package/src/nodes/window/popup.js +71 -0
  82. package/src/nodes/window/size.js +591 -0
  83. package/src/nodes/window/window.js +945 -0
  84. package/src/palette.js +1 -1
  85. package/src/registry.js +7 -3
  86. package/src/screencolor.js +212 -38
  87. package/src/screencolorhooks.js +6 -2
  88. package/src/styles.js +137 -15
  89. package/src/svgnodes.js +2 -1
  90. package/src/testing/harness.js +2 -2
  91. package/src/textselection.js +5 -3
  92. package/src/trace-registry.js +1 -1
  93. package/src/types/components.d.ts +38 -6
  94. package/src/types/elements.d.ts +11 -1
  95. package/src/types/nodes.d.ts +33 -5
  96. package/src/types/screencolor.d.ts +20 -14
  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';
@@ -30,12 +34,14 @@ import { CocoaNotifications } from './notifications.js';
30
34
  import { CocoaPaneHost } from './panehost.js';
31
35
  import { CocoaPermissions } from './permissions.js';
32
36
  import { CocoaPaneWindow } from './panewindow.js';
37
+ import { CocoaColorSampler } from './screencolor.js';
33
38
  import { CocoaFilePanels } from './filepanels.js';
34
39
  import { CocoaFontManager } from './fonts.js';
35
40
  import { CocoaSurface } from './surface.js';
36
41
  import { CocoaWindow } from './window.js';
37
42
  import { decodeKey, modifierMask } from './keymap.js';
38
43
  import { loadNative } from './native.js';
44
+ import { requestAppKit, threadedChannel } from './threaded.js';
39
45
 
40
46
  // The frame interval: how often a scheduled frame may paint, in ms. The
41
47
  // default is the period of the display the window is on — `listScreens`
@@ -49,12 +55,25 @@ const PUMP_INTERVAL_MS = 8;
49
55
  // How early a pump tick may take a frame that is not quite due, in ms — the
50
56
  // drift of a timer, not a fraction of the pump (`_frameDue`).
51
57
  const FRAME_SLACK_MS = 1;
58
+ // Threaded mode's live-resize handshake (windowkit/appkit#53): how long
59
+ // AppKit may hold a resize tick for a frame painted at the new size, in ms.
60
+ // The edge moves when the delegate returns, so the budget is how long a
61
+ // slow frame may hold the drag back. 50 covers what a large tree costs —
62
+ // the Cocoa `resize` cell measures 28ms a tick at 3,662 nodes
63
+ // (docs/macos.md) — with room, where the bridge's own test met every tick
64
+ // in 3.1ms against 3ms of layout. Past three 60Hz frames an edge that waits
65
+ // reads as the drag stuttering, so a tree slower than that lets the edge
66
+ // go first and its frame land after (Flutter's Windows embedder waits up
67
+ // to 100, docs/windows.md §"Resize"). `createRoot({ cocoa: { resizeWait } })`
68
+ // sets it; 0 is no handshake at all.
69
+ const RESIZE_WAIT_MS = 50;
52
70
 
53
71
  export class CocoaApp {
54
72
  constructor(native, options = {}) {
55
73
  this._native = native;
56
74
  this.options = options;
57
- this._windows = new Map(); // windowNumber -> CocoaWindow
75
+ // CocoaWindow._key -> CocoaWindow: the number, or on a worker the handle
76
+ this._windows = new Map();
58
77
  this._grabWindow = null;
59
78
  this._rafQueue = []; // [{ cb, wnd }]
60
79
  // the app's own frame clock, for a frame no window owns (a pane's)
@@ -82,6 +101,22 @@ export class CocoaApp {
82
101
  this._activeDrag = null;
83
102
  // set by a quit request; read by close() to end the process
84
103
  this._quitting = false;
104
+ // Threaded mode (`start({ channel })`): fed by the bridge's batches
105
+ // instead of a pump. `_batching` is set while one is routed, and what
106
+ // its inputs owe is paid once, at its end (`_routeBatch`).
107
+ this._threaded = false;
108
+ this._batching = false;
109
+ this._inputOwed = false;
110
+ this._wheeled = null;
111
+ this._unsubscribe = null;
112
+ const resizeWait = options.cocoa?.resizeWait ?? RESIZE_WAIT_MS;
113
+ if (!(resizeWait >= 0) || !Number.isFinite(resizeWait)) {
114
+ throw new TypeError(
115
+ 'react-x11: cocoa.resizeWait is a number of milliseconds, 0 for ' +
116
+ `none — got ${resizeWait}.`,
117
+ );
118
+ }
119
+ this._resizeWait = resizeWait;
85
120
 
86
121
  // The activation policy has to be fixed before the app finishes
87
122
  // launching — a Regular launch registers a Dock tile, so an agent app
@@ -91,6 +126,27 @@ export class CocoaApp {
91
126
  // only when the root asked for one: a bridge without the option, or a
92
127
  // fake, is left alone.
93
128
  const policy = options.cocoa?.activationPolicy;
129
+ // Under the launcher (src/cocoa/main.js) the app launched before this
130
+ // code ran, so the policy is a switch after the fact, and a Regular
131
+ // launch has already registered its Dock tile. The bridge publishes
132
+ // what the app launched as, and takes the launch policy from
133
+ // APPKIT_ACTIVATION_POLICY (windowkit/appkit#67): a mismatch is said,
134
+ // with the variable that fixes it.
135
+ if (policy != null && threadedChannel()) {
136
+ const launched = native.activationPolicy?.();
137
+ if (
138
+ launched &&
139
+ launched !== policy &&
140
+ process.env.NODE_ENV !== 'production'
141
+ ) {
142
+ console.warn(
143
+ `react-x11: cocoa.activationPolicy is '${policy}', but the app ` +
144
+ `launched as '${launched}' — under react-x11/cocoa-main it is up ` +
145
+ 'before its code runs. Start it with ' +
146
+ `APPKIT_ACTIVATION_POLICY=${policy} to launch as '${policy}'.`,
147
+ );
148
+ }
149
+ }
94
150
  if (policy != null && typeof native.initApp === 'function') {
95
151
  native.initApp({ activationPolicy: policy });
96
152
  }
@@ -158,7 +214,9 @@ export class CocoaApp {
158
214
  // 'auto'` policy both test for this property, so a backend without it
159
215
  // (X11, the headless mock) draws the themed controls with no further
160
216
  // branching.
161
- this.nativeBezels = new BezelStore(native);
217
+ this.nativeBezels = new BezelStore(native, {
218
+ answersLater: () => this._threaded,
219
+ });
162
220
 
163
221
  // macOS privacy authorizations (src/cocoa/permissions.js). Present
164
222
  // exactly when the bridge has them (>= 0.5), and its presence is the
@@ -189,6 +247,17 @@ export class CocoaApp {
189
247
  ? new CocoaCalendars(this)
190
248
  : null;
191
249
 
250
+ // The system colour sampler, `NSColorSampler` (src/cocoa/screencolor.js).
251
+ // Present exactly when the bridge has it (>= 0.9), and its presence is
252
+ // the top rung of src/screencolor.js's ladder for this app — an older
253
+ // bridge leaves that ladder where it was, which on this backend is no
254
+ // rung at all: `useEyedropper().supported` stays false and a picker
255
+ // draws no dropper button.
256
+ this.colorSampler =
257
+ typeof native.sampleScreenColor === 'function'
258
+ ? new CocoaColorSampler(native)
259
+ : null;
260
+
192
261
  // The GL policy, glbackend.js's shape. No GLX exists here, so the
193
262
  // default is 'auto' (the direct backend where the runtime loads);
194
263
  // useSupports('shaders') stays false until the first <glarea> resolves
@@ -230,8 +299,27 @@ export class CocoaApp {
230
299
  };
231
300
  this._atoms = new Map();
232
301
 
302
+ // ntk's clipboard shape over `NSPasteboard.general` — which is the one
303
+ // pasteboard, and it is CLIPBOARD's. X's other everyday selection,
304
+ // PRIMARY, is taken by *selecting text at all* and pasted with a middle
305
+ // click, and core takes it on every selection gesture (textselection.js
306
+ // `own()`, the text controls' `_ownSelection`). Sent to the pasteboard,
307
+ // each of those gestures was a Copy: a drag across a paragraph replaced
308
+ // the link the user had just copied in another app.
309
+ //
310
+ // So every other name — PRIMARY, SECONDARY, one of the app's own — is a
311
+ // selection nobody on this desktop can paste from: a write resolves and
312
+ // changes nothing, a read finds no owner, `targets()` is empty, `clear()`
313
+ // leaves the pasteboard alone. Deliberately not a PRIMARY kept in this
314
+ // process: its point is the paste in *another* application, and inside
315
+ // one all it would keep is a middle click that types into a text field,
316
+ // which no Mac text field does. An app that wants select-to-copy anyway
317
+ // — a terminal — writes CLIPBOARD from `onSelectionChange`
318
+ // (docs/clipboard.md, "Two clipboards").
319
+ const isClipboard = (selection = 'CLIPBOARD') => selection === 'CLIPBOARD';
233
320
  this.clipboard = {
234
- write: (data) => {
321
+ write: (data, { selection } = {}) => {
322
+ if (!isClipboard(selection)) return Promise.resolve();
235
323
  const text =
236
324
  typeof data === 'string'
237
325
  ? data
@@ -239,28 +327,39 @@ export class CocoaApp {
239
327
  native.pasteboardWriteText(String(text));
240
328
  return Promise.resolve();
241
329
  },
242
- clear: () => {
243
- native.pasteboardClear();
330
+ clear: (selection) => {
331
+ if (isClipboard(selection)) native.pasteboardClear();
244
332
  return Promise.resolve();
245
333
  },
246
- targets: () => {
247
- const text = native.pasteboardReadText();
248
- return Promise.resolve(text == null ? [] : ['UTF8_STRING', 'STRING']);
334
+ targets: ({ selection } = {}) => {
335
+ if (!isClipboard(selection)) return Promise.resolve([]);
336
+ return this._ask('pasteboardReadText').then((text) =>
337
+ text == null ? [] : ['UTF8_STRING', 'STRING'],
338
+ );
249
339
  },
250
- read: ({ target } = {}) => {
251
- const text = native.pasteboardReadText();
252
- if (text == null) {
340
+ read: ({ selection, target } = {}) => {
341
+ if (!isClipboard(selection)) {
253
342
  return Promise.reject(
254
- new Error('clipboard: nothing to paste — the pasteboard is empty'),
343
+ new Error(
344
+ `clipboard: nothing to paste — ${selection} is an X11 ` +
345
+ 'selection, and on macOS only CLIPBOARD is the pasteboard',
346
+ ),
255
347
  );
256
348
  }
257
- if (target === undefined) return Promise.resolve(text);
258
- if (target === 'UTF8_STRING' || target === 'STRING') {
259
- return Promise.resolve(Buffer.from(text, 'utf8'));
260
- }
261
- return Promise.reject(
262
- new Error(`clipboard: cannot convert the pasteboard to ${target}`),
263
- );
349
+ return this._ask('pasteboardReadText').then((text) => {
350
+ if (text == null) {
351
+ throw new Error(
352
+ 'clipboard: nothing to paste — the pasteboard is empty',
353
+ );
354
+ }
355
+ if (target === undefined) return text;
356
+ if (target === 'UTF8_STRING' || target === 'STRING') {
357
+ return Buffer.from(text, 'utf8');
358
+ }
359
+ throw new Error(
360
+ `clipboard: cannot convert the pasteboard to ${target}`,
361
+ );
362
+ });
264
363
  },
265
364
  watch: () => Promise.resolve(() => {}),
266
365
  };
@@ -270,6 +369,18 @@ export class CocoaApp {
270
369
  return typeof value === 'string' ? cssColorStraight(value) : null;
271
370
  }
272
371
 
372
+ /**
373
+ * A question only AppKit can answer, as a promise: asked in the call on
374
+ * the main thread, and through a callback from a worker — the bridge's
375
+ * rule for every read that has to ask AppKit rather than read what it
376
+ * published (windowkit/appkit#51).
377
+ */
378
+ _ask(verb, ...args) {
379
+ const native = this._native;
380
+ if (!this._threaded) return Promise.resolve(native[verb](...args));
381
+ return new Promise((resolve) => native[verb](...args, resolve));
382
+ }
383
+
273
384
  findArgbVisual() {
274
385
  // every Cocoa window composites; the "visual" is a formality
275
386
  return { visual: 1, depth: 32 };
@@ -363,7 +474,7 @@ export class CocoaApp {
363
474
  }
364
475
 
365
476
  _registerWindow(wnd) {
366
- this._windows.set(wnd.windowNumber, wnd);
477
+ this._windows.set(wnd._key, wnd);
367
478
  }
368
479
 
369
480
  /**
@@ -504,14 +615,14 @@ export class CocoaApp {
504
615
  }
505
616
 
506
617
  _unregisterWindow(wnd) {
507
- this._windows.delete(wnd.windowNumber);
618
+ this._windows.delete(wnd._key);
508
619
  if (this._grabWindow === wnd) this._grabWindow = null;
509
620
  }
510
621
 
511
622
  // --- the pump ------------------------------------------------------------
512
623
 
513
- start({ pumpInterval = PUMP_INTERVAL_MS } = {}) {
514
- if (this._pump) return;
624
+ start({ pumpInterval = PUMP_INTERVAL_MS, channel = null } = {}) {
625
+ if (this._pump || this._threaded) return;
515
626
  this._pumpInterval = pumpInterval;
516
627
  const native = this._native;
517
628
  // A pane process has no NSApplication to pump — no windows, no events,
@@ -523,10 +634,13 @@ export class CocoaApp {
523
634
  }, pumpInterval);
524
635
  return;
525
636
  }
637
+ if (channel) {
638
+ this._startThreaded(channel);
639
+ return;
640
+ }
526
641
  native.initApp();
527
642
  native.setBackendEventCallback((ev) => this._route(ev));
528
643
  this._pump = setInterval(() => {
529
- this._endLiveResizes();
530
644
  native.pump2(); // flushes the previous tick's CATransaction
531
645
  if (this._shadowStale.size) {
532
646
  for (const wnd of this._shadowStale) {
@@ -540,13 +654,17 @@ export class CocoaApp {
540
654
  }
541
655
 
542
656
  /**
543
- * A pump tick means no modal loop owns the thread, so no window is being
544
- * resized live right now: the flag the delegate set on the last tick of a
545
- * drag comes off here, ahead of the frames that tick, and the catch-up
546
- * frame a deferred layout owes (nodes.js) runs on this very tick.
657
+ * Threaded mode: the launcher's channel is open (src/cocoa/threaded.js),
658
+ * the main thread is in `[NSApp run]`, and this app is on a worker. There
659
+ * is no pump. Events arrive in the bridge's batches whenever AppKit has
660
+ * them, from inside its modal loops too, and the frame clock arms a timer
661
+ * for every frame it is owed, at whatever distance (`_armFrameTimer`).
547
662
  */
548
- _endLiveResizes() {
549
- for (const wnd of this._windows.values()) wnd.liveResizing = false;
663
+ _startThreaded(channel) {
664
+ this._threaded = true;
665
+ this._pumpInterval = Infinity;
666
+ this._native.initApp();
667
+ this._unsubscribe = channel.subscribe((batch) => this._routeBatch(batch));
550
668
  }
551
669
 
552
670
  /**
@@ -564,7 +682,7 @@ export class CocoaApp {
564
682
  */
565
683
  _requestFrame(cb, wnd = null) {
566
684
  this._rafQueue.push({ cb, wnd });
567
- if (this._pump && this._rafQueue.length === 1) {
685
+ if ((this._pump || this._threaded) && this._rafQueue.length === 1) {
568
686
  const now = performance.now();
569
687
  this._armFrameTimer(Math.max(1, this._frameWait(wnd ?? this, now)), now);
570
688
  }
@@ -620,7 +738,8 @@ export class CocoaApp {
620
738
  * its own transaction (the bridge flushes on the flip), so what is
621
739
  * painted here is on glass without waiting for the next pump. What the
622
740
  * tick alone still does is pump AppKit's events, which is what
623
- * `pumpInterval` stays the cadence of.
741
+ * `pumpInterval` stays the cadence of. With no pump at all — threaded
742
+ * mode, where `_pumpInterval` is Infinity — every frame is one of these.
624
743
  */
625
744
  _armFrameTimer(wait, now) {
626
745
  if (!(wait > 0 && wait < this._pumpInterval)) return;
@@ -659,6 +778,15 @@ export class CocoaApp {
659
778
  this._rafQueue = [];
660
779
  let soonest = Infinity;
661
780
  for (const entry of queue) {
781
+ // A window whose last flip has not given its back buffer back yet
782
+ // (threaded mode's fence, `CocoaWindow.frameInFlight`) waits before
783
+ // its clock is asked, or the clock would count a frame that did not
784
+ // run. The release is an event, and the batch it arrives in ticks
785
+ // again.
786
+ if (entry.wnd?.frameInFlight?.()) {
787
+ this._rafQueue.push(entry);
788
+ continue;
789
+ }
662
790
  const clock = entry.wnd ?? this;
663
791
  if (!due.has(clock)) due.set(clock, this._frameDue(clock, now));
664
792
  if (!due.get(clock)) {
@@ -705,11 +833,51 @@ export class CocoaApp {
705
833
  * exactly when it was worth showing.
706
834
  */
707
835
  _afterInput() {
836
+ // threaded mode pays it once, for the whole batch (`_routeBatch`)
837
+ if (this._batching) {
838
+ this._inputOwed = true;
839
+ return;
840
+ }
708
841
  flushSyncWork();
709
842
  flushPendingFrames();
710
843
  this._presentAll();
711
844
  }
712
845
 
846
+ /**
847
+ * Threaded mode's delivery: every event the bridge emitted since the
848
+ * last wake, in order. Each is routed as the pump routes it, but what an
849
+ * input owes — React's half, the paint, the present (`_afterInput`) — is
850
+ * paid once, when the batch is done: ten moves and a click that crossed
851
+ * while this thread was busy are one frame, not eleven. Then what a pump
852
+ * tick does: the frames that are due, and the ones a window's visibility
853
+ * or its back buffer was holding, which an occlusion change or a
854
+ * `surface-released` in this very batch may just have freed.
855
+ */
856
+ _routeBatch(batch) {
857
+ if (this._closed) return;
858
+ const started = performance.now();
859
+ this._batching = true;
860
+ try {
861
+ for (const ev of batch) this._route(ev);
862
+ } finally {
863
+ this._batching = false;
864
+ }
865
+ if (this._closed) return;
866
+ if (this._inputOwed) {
867
+ this._inputOwed = false;
868
+ this._afterInput();
869
+ }
870
+ // a frame that answered the wheel is this refresh's (`_routeWheel`)
871
+ if (this._wheeled) {
872
+ for (const wnd of this._wheeled) {
873
+ if (wnd._presentedAt >= started) wnd._rafLast = wnd._presentedAt;
874
+ }
875
+ this._wheeled = null;
876
+ }
877
+ this._tickFrames();
878
+ this._presentAll();
879
+ }
880
+
713
881
  // --- event routing -------------------------------------------------------
714
882
 
715
883
  _route(ev) {
@@ -790,15 +958,25 @@ export class CocoaApp {
790
958
  // id nobody knows is an animation already forgotten (cancelled, or
791
959
  // its layer dropped), and the bridge's report is just late
792
960
  return this._animationEnds.get(ev.id)?.(ev);
961
+ case 'window-live-resize':
962
+ return this._routeLiveResize(ev);
963
+ case 'window-created':
964
+ // a worker's window, made on the UI thread after the call that
965
+ // answered its handle
966
+ return this._window(ev)?._created(ev);
967
+ case 'surface-released':
968
+ return this._routeSurfaceReleased(ev);
793
969
  default:
794
970
  return undefined;
795
971
  }
796
972
  }
797
973
 
974
+ /** The window an event is about. On a worker every window event names
975
+ * the bridge's handle, which is what windows are keyed by there; in pump
976
+ * mode the events name the number. */
798
977
  _window(ev) {
799
- return ev.windowNumber != null
800
- ? (this._windows.get(ev.windowNumber) ?? null)
801
- : null;
978
+ const key = ev.handle ?? ev.windowNumber;
979
+ return key != null ? (this._windows.get(key) ?? null) : null;
802
980
  }
803
981
 
804
982
  /**
@@ -882,11 +1060,34 @@ export class CocoaApp {
882
1060
  });
883
1061
  // Painted now, like a press. On X11 the wheel is paced on the frame
884
1062
  // clock because ntk coalesces a touchpad's dozens of reports per frame
885
- // into one event; AppKit already delivers scroll events at the
886
- // display's rate, so answering each one is answering once per refresh
887
- // — and answering it on the next frame tick instead was a 15ms median
888
- // between the notch and the scroll, most of a refresh period of nothing.
1063
+ // into one event; AppKit mostly delivers scroll events at the display's
1064
+ // rate, so answering each one is answering once per refresh — and
1065
+ // answering it on the next frame tick instead was a 15ms median between
1066
+ // the notch and the scroll, most of a refresh period of nothing.
1067
+ //
1068
+ // Mostly: a trackpad's momentum lands two in one tick often, and a
1069
+ // second flip inside the refresh draws into the buffer the first one
1070
+ // just took off glass (`CocoaWindow._flippedRecently` says why that
1071
+ // shows). So the rest of a burst lands React's half and leaves the
1072
+ // paint to the paced frame the scroll already asked for — the model
1073
+ // has scrolled, and the next refresh shows all of it.
1074
+ if (wnd._flippedRecently()) {
1075
+ flushSyncWork();
1076
+ return;
1077
+ }
1078
+ // in a batch the frame goes out when the batch is done, and the clock
1079
+ // restarts from it there (`_routeBatch`)
1080
+ if (this._batching) {
1081
+ (this._wheeled ??= new Set()).add(wnd);
1082
+ this._inputOwed = true;
1083
+ return;
1084
+ }
1085
+ const started = performance.now();
889
1086
  this._afterInput();
1087
+ // …and a frame that answered the wheel is this refresh's frame: the
1088
+ // window's clock restarts from it, or a pump tick a few milliseconds
1089
+ // later finds the clock due and flips again.
1090
+ if (wnd._presentedAt >= started) wnd._rafLast = wnd._presentedAt;
890
1091
  }
891
1092
 
892
1093
  _routeKey(ev) {
@@ -922,6 +1123,10 @@ export class CocoaApp {
922
1123
  // During a live resize AppKit's modal loop owns the thread and Node
923
1124
  // timers stall; flushing here is what keeps layout tracking the drag.
924
1125
  this._afterInput();
1126
+ // On a worker no modal loop holds this thread: a microtask queued here
1127
+ // runs as soon as the batch returns, and what it commits schedules its
1128
+ // frame like any other.
1129
+ if (this._threaded) return;
925
1130
  // The React HALF of the response — an anchored popup following the
926
1131
  // window, an onResize setState — commits on a microtask AFTER this
927
1132
  // handler returns, and the pump that would paint it is the thing the
@@ -947,6 +1152,7 @@ export class CocoaApp {
947
1152
  * present waits with them (`CocoaWindow._visible`); on, the next pump
948
1153
  * tick runs the catch-up frame and puts it on glass — no early flush,
949
1154
  * since nothing was asked for and the pump is at most one interval away.
1155
+ * On a worker the end of the batch the event came in is that tick.
950
1156
  */
951
1157
  _routeOcclusion(ev) {
952
1158
  const wnd = this._window(ev);
@@ -954,6 +1160,33 @@ export class CocoaApp {
954
1160
  wnd._occluded = ev.visible === false;
955
1161
  }
956
1162
 
1163
+ /**
1164
+ * `windowWillStartLiveResize:` / `windowDidEndLiveResize:`, as the bridge
1165
+ * brackets the drag AppKit's tracking loop runs (windowkit/appkit#63).
1166
+ * Between the two a tick lays out with the floors it has; the end is the
1167
+ * catch-up frame's cue (nodes/window/size.js), which the next frame tick
1168
+ * runs — in pump mode the tick after the loop lets go, on a worker the
1169
+ * end of this batch.
1170
+ */
1171
+ _routeLiveResize(ev) {
1172
+ const wnd = this._window(ev);
1173
+ if (!wnd || wnd.destroyed) return;
1174
+ wnd.liveResizing = ev.phase === 'begin';
1175
+ }
1176
+
1177
+ /**
1178
+ * `surface-released`: a worker's frame took an IOSurface off a layer and
1179
+ * the frame that replaced it has committed (windowkit/appkit#52). The
1180
+ * window whose back buffer that was stops waiting on it
1181
+ * (`CocoaWindow._surfaceReleased`), and the tick at the end of this
1182
+ * batch runs the frame it was holding.
1183
+ */
1184
+ _routeSurfaceReleased(ev) {
1185
+ for (const wnd of this._windows.values()) {
1186
+ if (wnd._surfaceReleased(ev.id)) return;
1187
+ }
1188
+ }
1189
+
957
1190
  _routeAccessibility(ev) {
958
1191
  for (const fn of [...this._a11yListeners]) fn(ev);
959
1192
  }
@@ -1114,7 +1347,9 @@ export class CocoaApp {
1114
1347
  this._frameTimer = null;
1115
1348
  this._cocoaGL?.destroy();
1116
1349
  this._cocoaGL = null;
1117
- this._native.setBackendEventCallback(null);
1350
+ this._unsubscribe?.();
1351
+ this._unsubscribe = null;
1352
+ if (!this._threaded) this._native.setBackendEventCallback(null);
1118
1353
  for (const wnd of [...this._windows.values()]) wnd.destroy();
1119
1354
  // A quit the app accepted: the tree is down and the connection closed,
1120
1355
  // and macOS is still waiting on the Cancel the bridge answered with. End
@@ -1175,12 +1410,32 @@ export function screenLayout(screens, scale) {
1175
1410
  */
1176
1411
  export async function createCocoaApp(options = {}) {
1177
1412
  const native = loadNative();
1413
+ // The launcher's channel, when the app is on its worker. A worker without
1414
+ // it has no thread running AppKit: the pump cannot run off the main
1415
+ // thread (the bridge refuses), and a window asked for would never be made.
1416
+ const channel = threadedChannel();
1417
+ if (!channel && !isMainThread) {
1418
+ throw new Error(
1419
+ 'react-x11: the cocoa backend runs on a worker thread only under its ' +
1420
+ 'launcher, which keeps AppKit on the main thread — start the app ' +
1421
+ 'with `node --import react-x11/cocoa-main app.js`, or create the ' +
1422
+ 'root on the main thread.',
1423
+ );
1424
+ }
1425
+ // AppKit is launched on the first cocoa root, not before — an app on a
1426
+ // worker that never makes one gets no Dock tile (src/cocoa/relaunch.js)
1427
+ if (channel) await requestAppKit(native);
1178
1428
  const app = new CocoaApp(native, options);
1179
1429
 
1180
1430
  setScaleForTests(app, app.scale, 'cocoa');
1181
1431
  setScreensForTests(app, screenLayout(app._screens, app.scale));
1182
1432
  setCompositingForTests(app, true);
1183
1433
 
1184
- app.start(options.cocoa ?? {});
1434
+ app.start({ ...options.cocoa, channel });
1435
+ // On a worker a control's bezel is measured and drawn on the UI thread
1436
+ // and answered later (windowkit/appkit#54), so the metrics layout reads
1437
+ // synchronously are asked for now, beside the rest of startup — the yoga
1438
+ // load `createRoot` runs alongside takes longer.
1439
+ if (channel) await app.nativeBezels.prefetch(app.scale);
1185
1440
  return app;
1186
1441
  }