react-x11 2.16.0 → 2.17.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 (62) hide show
  1. package/README.md +38 -23
  2. package/package.json +3 -1
  3. package/src/Reconciler.js +82 -23
  4. package/src/a11y.js +18 -1
  5. package/src/acceleratorhooks.js +40 -6
  6. package/src/anchor.js +20 -2
  7. package/src/appcontext.js +8 -0
  8. package/src/appearance.js +36 -0
  9. package/src/{cocoa → backend}/context2d.js +27 -7
  10. package/src/capabilities.js +99 -1
  11. package/src/cocoa/app.js +204 -6
  12. package/src/cocoa/fonts.js +1 -1
  13. package/src/cocoa/overlay.js +2 -2
  14. package/src/cocoa/panewindow.js +2 -2
  15. package/src/cocoa/presenter.js +2 -2
  16. package/src/cocoa/surface.js +3 -3
  17. package/src/cocoa/window.js +2 -2
  18. package/src/events.js +21 -0
  19. package/src/foreignnodes.js +8 -3
  20. package/src/frame/index.js +30 -4
  21. package/src/glnodes.js +12 -1
  22. package/src/idle.js +59 -1
  23. package/src/index.d.ts +51 -1
  24. package/src/index.js +30 -3
  25. package/src/keysymchars.js +47 -0
  26. package/src/keysyms.d.ts +19 -1
  27. package/src/keysyms.js +107 -8
  28. package/src/launcher.js +17 -8
  29. package/src/launcherhooks.js +24 -10
  30. package/src/node.d.ts +1 -1
  31. package/src/nodes/cascade.js +9 -0
  32. package/src/nodes/node.js +6 -1
  33. package/src/nodes/window/hints.js +21 -2
  34. package/src/nodes/window/window.js +2 -2
  35. package/src/notifications.js +39 -14
  36. package/src/screens.js +159 -24
  37. package/src/taskbarhooks.js +164 -0
  38. package/src/transfer.js +20 -1
  39. package/src/trayhooks.js +1 -1
  40. package/src/types/capabilities.d.ts +32 -3
  41. package/src/types/elements.d.ts +23 -1
  42. package/src/types/events.d.ts +21 -0
  43. package/src/types/filedialog.d.ts +3 -1
  44. package/src/types/launcher.d.ts +20 -6
  45. package/src/types/taskbar.d.ts +79 -0
  46. package/src/wayland/context2d.js +1 -1
  47. package/src/wayland/xkb.js +170 -59
  48. package/src/win32/a11y.js +604 -0
  49. package/src/win32/app.js +768 -0
  50. package/src/win32/bezels.js +158 -0
  51. package/src/win32/dnd.js +283 -0
  52. package/src/win32/fonts.js +497 -0
  53. package/src/win32/glarea.js +548 -0
  54. package/src/win32/ime.js +267 -0
  55. package/src/win32/keymap.js +116 -0
  56. package/src/win32/native.js +54 -0
  57. package/src/win32/panehost.js +106 -0
  58. package/src/win32/panewindow.js +343 -0
  59. package/src/win32/shell.js +426 -0
  60. package/src/win32/surface.js +192 -0
  61. package/src/win32/window.js +659 -0
  62. package/src/windowid.js +128 -20
@@ -79,6 +79,21 @@ function soleApp() {
79
79
  return showing.length === 1 ? showing[0] : null;
80
80
  }
81
81
 
82
+ /**
83
+ * The mechanism a backend says it built one rung on, or null.
84
+ *
85
+ * Backends install the *same method names* for the same rung on purpose --
86
+ * `createStatusItem` is how `useTray` stays one hook -- so a method name
87
+ * cannot tell two mechanisms apart, and a probe that tried reported
88
+ * Shell_NotifyIcon as `cocoa`. A backend with more than one mechanism to
89
+ * distinguish declares them (`app.shellMechanisms`); one with nothing to
90
+ * disambiguate says nothing and the method checks below still answer.
91
+ */
92
+ function shellMechanism(target, rung) {
93
+ const declared = target?.shellMechanisms;
94
+ return typeof declared?.[rung] === 'string' ? declared[rung] : null;
95
+ }
96
+
82
97
  // ---------------------------------------------------------------------------
83
98
  // notifications
84
99
  // ---------------------------------------------------------------------------
@@ -154,6 +169,27 @@ async function probeNotifications({ app } = {}) {
154
169
  });
155
170
  }
156
171
 
172
+ if (backend === 'win32') {
173
+ // A Shell_NotifyIcon balloon: text with a severity, shown by the tray
174
+ // icon and kept by the Action Center afterwards. Everything richer is a
175
+ // toast, which is WinRT and a different mechanism entirely.
176
+ return frozen('win32', {
177
+ actions: false,
178
+ events: false, // NIN_BALLOONUSERCLICK is not routed
179
+ update: false,
180
+ close: false,
181
+ body: true,
182
+ bodyMarkup: false,
183
+ bodyImage: false,
184
+ // The balloon shows the severity's own glyph. NIIF_USER would put the
185
+ // caller's icon there and is not wired up, so an icon cannot be chosen.
186
+ icon: false,
187
+ sound: true, // the shell plays one; NIIF_NOSOUND is not set
188
+ persistence: true, // it lands in the Action Center
189
+ urgency: true, // low/normal/critical -> NIIF_NONE/INFO/ERROR
190
+ });
191
+ }
192
+
157
193
  // `osascript` and `notify-send`: one way, and that is the whole of it.
158
194
  // `notify-send -p` prints an id on newer libnotify, which is why `update`
159
195
  // is not flatly false there — see notifications.js.
@@ -178,6 +214,33 @@ async function probeNotifications({ app } = {}) {
178
214
 
179
215
  /** The tray on an app that has one of its own, which needs nothing asked. */
180
216
  function trayNow(target) {
217
+ // The mechanism is *declared* by the backend, not guessed from a method
218
+ // name, because two backends deliberately install the same names -- that
219
+ // sharing is what lets `useTray` be one hook -- and a probe that read
220
+ // `createStatusItem` as "this is AppKit" reported Shell_NotifyIcon as
221
+ // `cocoa`, with SF Symbols and click modifiers it does not have.
222
+ if (shellMechanism(target, 'tray') === 'shellnotifyicon') {
223
+ return frozen('shellnotifyicon', {
224
+ menu: true,
225
+ // No name vocabulary: the shell has no icon theme to look a name up
226
+ // in, so an icon here is always pixels or a file.
227
+ iconName: false,
228
+ iconBytes: true,
229
+ attention: false,
230
+ overlay: false,
231
+ tooltip: true,
232
+ // An NSStatusItem can show a label beside its icon; a notify icon is
233
+ // an icon. `title` is accepted and used as the tooltip, which is a
234
+ // fallback rather than the feature.
235
+ title: false,
236
+ click: true,
237
+ clickPosition: true,
238
+ clickRect: false,
239
+ clickModifiers: false,
240
+ // WM_MOUSEWHEEL is not delivered to a notify icon.
241
+ scroll: false,
242
+ });
243
+ }
181
244
  if (typeof target?.createStatusItem === 'function') {
182
245
  return frozen('cocoa', {
183
246
  menu: true,
@@ -234,15 +297,45 @@ async function probeTray({ app } = {}) {
234
297
 
235
298
  /** The Dock tile on an app that has one, which needs nothing asked. */
236
299
  function launcherNow(target) {
300
+ // Declared, not guessed -- see `trayNow`. This backend installs
301
+ // `setDockBadge` too, so the cocoa branch below used to answer for it and
302
+ // say `progress: false` with a working taskbar progress bar right there.
303
+ if (shellMechanism(target, 'launcher') === 'taskbar') {
304
+ return frozen('taskbar', {
305
+ badge: true,
306
+ // Drawn into the overlay icon, so it is text rather than a count --
307
+ // but a 16x16 overlay holds about three glyphs and longer labels come
308
+ // out as `99+`.
309
+ badgeText: true,
310
+ progress: true, // ITaskbarList3::SetProgressValue
311
+ urgent: true, // FlashWindowEx
312
+ // The taskbar button's menu is the jump list, whose entries start a
313
+ // *new* process; nothing on it can call back into this one. That is a
314
+ // different feature from the Dock menu, and `tasks` is its name --
315
+ // reporting `menu: true` here would promise a callback that never
316
+ // comes.
317
+ menu: false,
318
+ needsDesktopFile: false,
319
+ tasks: typeof target.jumpList === 'function',
320
+ thumbnailToolbar: typeof target.thumbnailToolbar === 'function',
321
+ recentDocuments: typeof target.noteRecentDocument === 'function',
322
+ });
323
+ }
237
324
  if (typeof target?.setDockBadge === 'function') {
238
325
  return frozen('cocoa', {
239
326
  badge: true,
240
327
  badgeText: true, // the tile takes any label
241
328
  progress: false, // NSDockTile has no progress bar
242
329
  urgent: true, // requestUserAttention, via window states
243
- menu: typeof target.setDockMenu === 'function',
330
+ menu: typeof target.setLauncherMenu === 'function',
244
331
  // The Dock always shows the app; nothing has to be installed for it.
245
332
  needsDesktopFile: false,
333
+ // The Dock has no static-task menu and no hover toolbar. macOS does
334
+ // keep a Recent list (`noteNewRecentDocumentURL:`), but this backend
335
+ // has nothing wired to it, and the map says what the backend can do.
336
+ tasks: false,
337
+ thumbnailToolbar: false,
338
+ recentDocuments: typeof target.noteRecentDocument === 'function',
246
339
  });
247
340
  }
248
341
  return null;
@@ -283,6 +376,11 @@ async function probeLauncher({ app } = {}) {
283
376
  urgent: true,
284
377
  menu: true, // the quicklist
285
378
  needsDesktopFile: true,
379
+ // The quicklist is the menu; `Actions=` in the .desktop file are the
380
+ // closest thing to static tasks and are not driven from here.
381
+ tasks: false,
382
+ thumbnailToolbar: false,
383
+ recentDocuments: false,
286
384
  });
287
385
  }
288
386
 
package/src/cocoa/app.js CHANGED
@@ -22,7 +22,7 @@ import { deliverActivate, deliverOpen } from '../application.js';
22
22
  import { flushPendingFrames } from '../frames.js';
23
23
  import { flushSyncWork } from '../priority.js';
24
24
  import { setCompositingForTests } from '../compositing.js';
25
- import { setScreensForTests } from '../screens.js';
25
+ import { setScreenPolling, setScreensForTests } from '../screens.js';
26
26
  import { setScaleForTests } from '../scale.js';
27
27
  import { BezelStore } from './bezels.js';
28
28
  import { CocoaGLArea, cocoaGLConfig, resolveCocoaGLRuntime } from './glarea.js';
@@ -38,7 +38,7 @@ import { CocoaPaneWindow } from './panewindow.js';
38
38
  import { CocoaColorSampler } from './screencolor.js';
39
39
  import { CocoaFilePanels } from './filepanels.js';
40
40
  import { CocoaFontManager } from './fonts.js';
41
- import { releaseImageUpload } from './context2d.js';
41
+ import { releaseImageUpload } from '../backend/context2d.js';
42
42
  import { CocoaSurface } from './surface.js';
43
43
  import { CocoaSymbols } from './symbols.js';
44
44
  import { CocoaWindow } from './window.js';
@@ -70,6 +70,23 @@ const FRAME_SLACK_MS = 1;
70
70
  // to 100, docs/windows.md §"Resize"). `createRoot({ cocoa: { resizeWait } })`
71
71
  // sets it; 0 is no handshake at all.
72
72
  const RESIZE_WAIT_MS = 50;
73
+ // How often the screen layout is re-read while something is watching it, in
74
+ // ms. There is no event to wait for — the bridge keeps its `NSScreen` copy
75
+ // current on macOS's notification and emits nothing (#617) — so
76
+ // `useScreens()`'s promise to re-render when a monitor is plugged in is
77
+ // kept by asking. 500 is half a second behind a replug, which is under the
78
+ // time it takes to look at the screen, against one `listScreens()` read a
79
+ // second (a lock and a few doubles on a worker, an `NSScreen.screens` walk
80
+ // on the main thread). Only while a component is subscribed:
81
+ // `createRoot({ cocoa: { screenPoll } })` sets it, 0 turns it off, and
82
+ // nothing polls in an app that never calls `useScreens()`.
83
+ const SCREEN_POLL_MS = 500;
84
+ // The floor under a re-read driven by something that is *not* a clock: a
85
+ // window reporting where it is, which a drag reports every frame and a
86
+ // display change reports once. A second is far too long to matter to a
87
+ // person and short enough that a rearrangement is noticed by the time they
88
+ // have finished moving the window (`_recheckScreens`).
89
+ const SCREEN_RECHECK_MS = 1000;
73
90
 
74
91
  export class CocoaApp {
75
92
  constructor(native, options = {}) {
@@ -157,6 +174,26 @@ export class CocoaApp {
157
174
  const screens = native.listScreens();
158
175
  this.scale = screens[0]?.scale ?? 1;
159
176
  this._screens = screens;
177
+ // …and how the layout stays current, because nothing pushes it: the
178
+ // bridge republishes its copy on macOS's own notification but emits no
179
+ // event, so this side asks — before a placement reads the layout, when
180
+ // a window reports a move, and on a clock while `useScreens()` has a
181
+ // subscriber (`refreshScreens`, #617). `_screensAt` is when the last
182
+ // read happened, which is what throttles the ones that are not clocks.
183
+ this._screensAt = performance.now();
184
+ this._screenTimer = null;
185
+ const screenPoll = options.cocoa?.screenPoll ?? SCREEN_POLL_MS;
186
+ if (!(screenPoll >= 0) || !Number.isFinite(screenPoll)) {
187
+ throw new TypeError(
188
+ 'react-x11: cocoa.screenPoll is a number of milliseconds, 0 for ' +
189
+ `none — got ${screenPoll}.`,
190
+ );
191
+ }
192
+ this._screenPoll = screenPoll;
193
+ setScreenPolling(this, {
194
+ revalidate: () => this.refreshScreens(),
195
+ watched: (on) => this._pollScreens(on),
196
+ });
160
197
 
161
198
  // The name the Dock, ⌘-Tab and the menu bar print. An unbundled
162
199
  // process is registered with LaunchServices under its executable —
@@ -167,7 +204,7 @@ export class CocoaApp {
167
204
  native.setAppName(String(appName));
168
205
  }
169
206
 
170
- // The Dock menu (src/cocoa/dock.js), installed by `useDockMenu`.
207
+ // The Dock menu (src/cocoa/dock.js), installed by `useLauncherMenu`.
171
208
  this._dockMenu = new CocoaDockMenu(this);
172
209
  // The tray items (src/cocoa/statusitem.js), by the bridge's handle —
173
210
  // which is what a click event names them by.
@@ -504,6 +541,102 @@ export class CocoaApp {
504
541
  this._windows.set(wnd._key, wnd);
505
542
  }
506
543
 
544
+ /**
545
+ * Re-read the screen layout and publish it if the desk has changed — the
546
+ * answer to a monitor plugged in, unplugged, rearranged, woken or made
547
+ * primary (#617). Returns whether anything moved.
548
+ *
549
+ * **`listScreens()` is always current; this side's copy was not.** The
550
+ * bridge republishes its `NSScreen` snapshot on
551
+ * `NSApplicationDidChangeScreenParametersNotification` and answers a
552
+ * pump-mode call from AppKit live — what was read once and kept forever
553
+ * is `_screens`, taken in the constructor. So the whole fix is to ask
554
+ * again, and the read is cheap enough to ask on demand: before a
555
+ * placement clamps a popup to a monitor (`availableArea` through
556
+ * `setScreenPolling`), when a window reports a move (`_recheckScreens`),
557
+ * and on a clock while `useScreens()` is mounted (`_pollScreens`).
558
+ *
559
+ * Public, and the seam for an app that knows before any of those do —
560
+ * `app.refreshScreens()` publishes whatever the OS says right now.
561
+ *
562
+ * **The app's scale is not re-derived.** Every rect this backend speaks
563
+ * in is points × `app.scale`, fixed at startup, and a window's origin,
564
+ * an event's coordinates and a surface's pixels all already exist in
565
+ * that space; moving it under them is a different and much larger change
566
+ * than re-reading a layout. The layout is converted at the scale the rest
567
+ * of the app uses, which is what keeps `monitorAt()` answering with the
568
+ * head a window is actually on (see `screenLayout`).
569
+ */
570
+ refreshScreens() {
571
+ if (this._closed) return false;
572
+ this._screensAt = performance.now();
573
+ let screens = null;
574
+ try {
575
+ screens = this._native.listScreens?.();
576
+ } catch {
577
+ // a bridge going away, or a fake with nothing to say
578
+ return false;
579
+ }
580
+ // An empty answer is "could not tell", never "no displays": a Mac with
581
+ // the lid shut and no panel attached still has the desk it had, and
582
+ // publishing nothing would take every monitor out from under
583
+ // `availableArea` and size the next window against the void.
584
+ if (!screens?.length) return false;
585
+ if (sameScreens(screens, this._screens)) return false;
586
+ this._screens = screens;
587
+ setScreensForTests(this, screenLayout(screens, this.scale));
588
+ // A monitor's refresh rate can change under a window that never moved —
589
+ // a mode switch, a panel woken at 60Hz — and a window's clock is read
590
+ // from this list rather than kept by the display (`frameIntervalFor`).
591
+ for (const wnd of this._windows.values()) {
592
+ if (!wnd.destroyed) wnd._refreshFrameInterval?.();
593
+ }
594
+ return true;
595
+ }
596
+
597
+ /**
598
+ * The layout, re-read at most once every `SCREEN_RECHECK_MS`.
599
+ *
600
+ * For the signals that mean "something about the desk may have moved"
601
+ * rather than "it did": a window reporting its position, which a drag
602
+ * reports every frame and a display change reports once. Plugging a
603
+ * monitor in moves the windows that were on it, so this is the one place
604
+ * the OS does tell us something — it just does not say what.
605
+ */
606
+ _recheckScreens() {
607
+ if (performance.now() - this._screensAt < SCREEN_RECHECK_MS) return false;
608
+ return this.refreshScreens();
609
+ }
610
+
611
+ /**
612
+ * The layout on a clock, while something is subscribed to it.
613
+ *
614
+ * `useScreens()` says it re-renders when a monitor is plugged in or
615
+ * unplugged and when the arrangement changes. On X11 that is a RandR
616
+ * event; here there is nothing to wait for, so while a component is
617
+ * watching, this asks every `cocoa.screenPoll` ms — on an unref'd timer,
618
+ * which never holds the process open and never wakes an app that is
619
+ * waiting on nothing else. An app that never calls `useScreens()` pays
620
+ * nothing at all: the paths where a stale layout is visible ask for
621
+ * themselves.
622
+ *
623
+ * Started and stopped by the session as the first subscriber arrives and
624
+ * the last one leaves (`setScreenPolling`), so a hook that unmounts takes
625
+ * the clock with it.
626
+ */
627
+ _pollScreens(on) {
628
+ if (this._screenTimer) {
629
+ clearInterval(this._screenTimer);
630
+ this._screenTimer = null;
631
+ }
632
+ if (!on || this._closed || !(this._screenPoll > 0)) return;
633
+ this._screenTimer = setInterval(
634
+ () => this.refreshScreens(),
635
+ this._screenPoll,
636
+ );
637
+ this._screenTimer.unref?.();
638
+ }
639
+
507
640
  /**
508
641
  * How often `wnd` may paint, in ms: the explicit `frameInterval` when the
509
642
  * root was given one, else the period of the screen under the window's
@@ -570,8 +703,12 @@ export class CocoaApp {
570
703
  this._native.setDockBadge(label == null ? null : String(label));
571
704
  }
572
705
 
573
- /** The menu behind a right-click on the Dock icon — `useDockMenu()`. */
574
- setDockMenu(items) {
706
+ /**
707
+ * The menu behind a right-click on the launcher icon — `useLauncherMenu()`.
708
+ * Here the launcher is the Dock, which is why the AppKit call below keeps
709
+ * its own name: `_native.setDockMenu` *is* what Apple calls it.
710
+ */
711
+ setLauncherMenu(items) {
575
712
  this._dockMenu.update(items);
576
713
  }
577
714
 
@@ -1140,6 +1277,10 @@ export class CocoaApp {
1140
1277
  _routeGeometry(ev) {
1141
1278
  const wnd = this._window(ev);
1142
1279
  if (!wnd || wnd.destroyed) return;
1280
+ // Before the window re-paces itself against the screen list below: a
1281
+ // display plugged in or removed moves the windows that were on it, and
1282
+ // this is the only thing the bridge says about it (`_recheckScreens`).
1283
+ this._recheckScreens();
1143
1284
  wnd._nativeResized(ev);
1144
1285
  wnd.emit('resize', {
1145
1286
  width: wnd.width,
@@ -1375,6 +1516,7 @@ export class CocoaApp {
1375
1516
  this._pump = null;
1376
1517
  if (this._frameTimer) clearTimeout(this._frameTimer);
1377
1518
  this._frameTimer = null;
1519
+ this._pollScreens(false);
1378
1520
  this._cocoaGL?.destroy();
1379
1521
  this._cocoaGL = null;
1380
1522
  this._unsubscribe?.();
@@ -1413,6 +1555,12 @@ export class CocoaApp {
1413
1555
  * *width* as a bound to every other head: a second display wider than the
1414
1556
  * built-in had its right edge pulled in by the difference, and every
1415
1557
  * anchored popup that reached past it was clamped back (issue #453).
1558
+ *
1559
+ * **Everything the bridge says about a screen comes through**, not only its
1560
+ * rects. `primary` and the panel's refresh rate were dropped here, so
1561
+ * `useScreens().primary` read null on macOS — every entry `primary: false`
1562
+ * — and `refreshRate` null beside a `frameIntervalFor` that was pacing
1563
+ * windows on that very number (#617).
1416
1564
  */
1417
1565
  export function screenLayout(screens, scale) {
1418
1566
  const rect = (r) => ({
@@ -1423,9 +1571,17 @@ export function screenLayout(screens, scale) {
1423
1571
  });
1424
1572
  const primary = screens?.[0];
1425
1573
  return {
1426
- monitors: (screens ?? []).map((screen) => ({
1574
+ monitors: (screens ?? []).map((screen, i) => ({
1427
1575
  ...rect(screen),
1428
1576
  ...(screen.visible ? { visible: rect(screen.visible) } : null),
1577
+ // `NSScreen.screens[0]` **is** the primary — the screen with the menu
1578
+ // bar, which is where macOS puts a window that names no position —
1579
+ // and the bridge flags it as well; the index is the same fact for a
1580
+ // bridge that does not.
1581
+ primary: screen.primary ?? i === 0,
1582
+ // `NSScreen.maximumFramesPerSecond`, as `fps`. 0 is the OS declining
1583
+ // to say (before macOS 12), which is `useScreens()`'s null.
1584
+ refreshRate: screen.fps > 0 ? screen.fps : null,
1429
1585
  })),
1430
1586
  // Still published for `useScreens().workArea`, which is one rect for
1431
1587
  // the desktop by definition; the primary's is the closest macOS has.
@@ -1433,6 +1589,48 @@ export function screenLayout(screens, scale) {
1433
1589
  };
1434
1590
  }
1435
1591
 
1592
+ /**
1593
+ * Whether two `listScreens()` answers describe the same desk.
1594
+ *
1595
+ * Every field the layout is built from, in the order they arrived — which
1596
+ * is `NSScreen.screens`, so the order is the arrangement and the primary,
1597
+ * and a change in it is a change. `fps` counts because a window's frame
1598
+ * clock is read from it, and `scale` because a screen that switched mode
1599
+ * is not the screen it was even at the same size.
1600
+ *
1601
+ * Pure, and exported for that reason: it decides whether a re-read
1602
+ * re-renders every `useScreens()` subscriber, and a poll that publishes an
1603
+ * unchanged layout twice a second is a render loop rather than a fix.
1604
+ */
1605
+ export function sameScreens(a, b) {
1606
+ if (!a || !b || a.length !== b.length) return false;
1607
+ for (let i = 0; i < a.length; i++) {
1608
+ const x = a[i];
1609
+ const y = b[i];
1610
+ if (
1611
+ x.x !== y.x ||
1612
+ x.y !== y.y ||
1613
+ x.width !== y.width ||
1614
+ x.height !== y.height ||
1615
+ x.scale !== y.scale ||
1616
+ x.fps !== y.fps ||
1617
+ x.primary !== y.primary ||
1618
+ !sameRect(x.visible, y.visible)
1619
+ ) {
1620
+ return false;
1621
+ }
1622
+ }
1623
+ return true;
1624
+ }
1625
+
1626
+ /** Two `visible` rects, either of which a bridge may not have reported. */
1627
+ function sameRect(a, b) {
1628
+ if (!a || !b) return !a === !b;
1629
+ return (
1630
+ a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
1631
+ );
1632
+ }
1633
+
1436
1634
  /**
1437
1635
  * Build the app and seed the platform stores the way the mock seeds them —
1438
1636
  * `beginScale`/`beginScreens`/`beginCompositing` find a session already
@@ -846,7 +846,7 @@ export class CocoaFontManager {
846
846
  }
847
847
 
848
848
  /**
849
- * The CTFont a glyph run draws with (`CocoaContext2D.drawGlyphs`): a face
849
+ * The CTFont a glyph run draws with (`BackendContext2D.drawGlyphs`): a face
850
850
  * of this engine's at the run's size, or an ntk `Font` — `openFont()`'s —
851
851
  * resolved to CoreText from the same bytes, so the glyph ids it shaped
852
852
  * with hold. Null for anything else, and the run is skipped.
@@ -14,7 +14,7 @@
14
14
  // `map`, `unmap`, `getContext`, `destroy` — plus `present`, which puts what
15
15
  // was painted on the layer: ntk blits an X window's backing store on its
16
16
  // own, where a layer's contents are a copy the bitmap has to be pushed to.
17
- import { CocoaContext2D } from './context2d.js';
17
+ import { BackendContext2D } from '../backend/context2d.js';
18
18
 
19
19
  export const OVERLAY_Z = 1e7 + 1;
20
20
 
@@ -115,7 +115,7 @@ export class CocoaOverlayPane {
115
115
 
116
116
  getContext() {
117
117
  if (!this._ctx) {
118
- this._ctx = new CocoaContext2D(
118
+ this._ctx = new BackendContext2D(
119
119
  this._native,
120
120
  () => this._ensureSurface(),
121
121
  () => {
@@ -14,7 +14,7 @@
14
14
  // via emit. Geometry and input arrive as channel messages (the host owns
15
15
  // layout and hit-testing — this is CPU offloading, not isolation), and the
16
16
  // only outbound traffic is pane-present.
17
- import { CocoaContext2D } from './context2d.js';
17
+ import { BackendContext2D } from '../backend/context2d.js';
18
18
 
19
19
  let nextPaneId = 1;
20
20
 
@@ -183,7 +183,7 @@ export class CocoaPaneWindow {
183
183
 
184
184
  getContext() {
185
185
  if (!this._ctx) {
186
- this._ctx = new CocoaContext2D(
186
+ this._ctx = new BackendContext2D(
187
187
  this._native,
188
188
  () => this._ensureSurface(),
189
189
  () => {
@@ -30,7 +30,7 @@ import { Node } from '../nodes/node.js';
30
30
  import { addDamageRect, damageToPaint } from '../nodes/damage.js';
31
31
  import { intersectRects } from '../nodes/rects.js';
32
32
  import { EASING_CONTROL_POINTS, TRANSITION_CONTROL_POINTS } from '../styles.js';
33
- import { CocoaContext2D } from './context2d.js';
33
+ import { BackendContext2D } from '../backend/context2d.js';
34
34
 
35
35
  export const RASTER_PAD = 2; // antialiasing/italic overhang outside the ink bounds
36
36
 
@@ -431,7 +431,7 @@ export class RasterState {
431
431
  this.height = height;
432
432
  this.gen++;
433
433
  if (!this.ctx) {
434
- this.ctx = new CocoaContext2D(
434
+ this.ctx = new BackendContext2D(
435
435
  presenter.native,
436
436
  () => this.surface,
437
437
  () => this.gen,
@@ -6,7 +6,7 @@
6
6
  // buffer the same way on both backends and names neither:
7
7
  //
8
8
  // const surface = new Surface(app, { width, height }); // device pixels
9
- // const ctx = surface.getContext('2d'); // a CocoaContext2D
9
+ // const ctx = surface.getContext('2d'); // a BackendContext2D
10
10
  // ctx.fillRect(0, 0, width, height);
11
11
  // surface.copyWithin({ x: 0, y: 0, width, height }, 0, -rowHeight);
12
12
  // windowCtx.drawImage(surface, x, y); // one composite
@@ -43,7 +43,7 @@
43
43
  // one from `contentBox()` numbers, which are device pixels already
44
44
  // (docs/scale.md). The bridge is told the app's scale so the bitmap carries
45
45
  // it — inert for a `drawImage` source, right for a layer's contents.
46
- import { CocoaContext2D } from './context2d.js';
46
+ import { BackendContext2D } from '../backend/context2d.js';
47
47
 
48
48
  export class CocoaSurface {
49
49
  constructor(app, { width, height, format = 'argb32' } = {}) {
@@ -113,7 +113,7 @@ export class CocoaSurface {
113
113
 
114
114
  _context() {
115
115
  if (!this._ctx) {
116
- this._ctx = new CocoaContext2D(
116
+ this._ctx = new BackendContext2D(
117
117
  this._native,
118
118
  () => this._handle(),
119
119
  () => 1,
@@ -6,7 +6,7 @@
6
6
  // an X window — attributes, reported width/height, event coordinates,
7
7
  // _screenOrigin. The divide-by-scale into Cocoa points happens against the
8
8
  // native layer and nowhere above it.
9
- import { CocoaContext2D } from './context2d.js';
9
+ import { BackendContext2D } from '../backend/context2d.js';
10
10
  import { CocoaDropTransport, dragSpec } from './dnd.js';
11
11
  import { CocoaLayerPresenter } from './presenter.js';
12
12
  import { CocoaPromotion } from './promotion.js';
@@ -794,7 +794,7 @@ export class CocoaWindow {
794
794
 
795
795
  getContext() {
796
796
  if (!this._ctx) {
797
- this._ctx = new CocoaContext2D(
797
+ this._ctx = new BackendContext2D(
798
798
  this._native,
799
799
  () => this._ensureSurface(),
800
800
  () => {
package/src/events.js CHANGED
@@ -113,6 +113,27 @@ class SyntheticEvent {
113
113
  this.ctrlKey = Boolean(native?.buttons & MOD.Control);
114
114
  this.altKey = Boolean(native?.buttons & MOD.Alt);
115
115
  this.metaKey = Boolean(native?.buttons & MOD.Super);
116
+ // Where the pointer is on the **virtual screen**, in the same logical
117
+ // pixels `x`/`y` are in — the DOM's name for the DOM's quantity, and the
118
+ // one an app should reach for when it places something outside the
119
+ // window (a context menu at the pointer, a drag preview).
120
+ //
121
+ // `nativeEvent.rootx`/`rooty` is X11's name for it, is still there, and
122
+ // is still in *device* pixels: the same split as `ev.x` against
123
+ // `nativeEvent.x`. Defined exactly where the backend reported a position
124
+ // — an X11 KeyPress carries one too, so this is not pointer-events-only —
125
+ // and absent otherwise, because a made-up 0 would read as the screen's
126
+ // top-left corner rather than as "no answer".
127
+ if (native?.rootx !== undefined && native?.rootx !== null) {
128
+ // The **window's** scale, not the target's. `x`/`y` are in the target's
129
+ // unit on purpose, so a subtree zoomed by a `scale` prop reads its own
130
+ // — but a screen coordinate is not in that subtree's space at all, and
131
+ // dividing it by a zoom factor would put it somewhere nobody is. The
132
+ // drag events have always computed it this way (src/dnd.js).
133
+ const screen = manager.scale;
134
+ this.screenX = native.rootx / screen;
135
+ this.screenY = (native.rooty ?? 0) / screen;
136
+ }
116
137
  this.defaultPrevented = false;
117
138
  this.propagationStopped = false;
118
139
  if (extra) Object.assign(this, extra);
@@ -192,10 +192,15 @@ export class ForeignNode extends Node {
192
192
  */
193
193
  _refuse() {
194
194
  this._refused = true;
195
+ // Named by the capability, not by the backend that happens to have it.
196
+ // An app cannot act on "use X11", and a second backend growing embedding
197
+ // would make that wording wrong as well as unhelpful; what an app *can*
198
+ // act on is the question the last sentence names (AGENTS.md,
199
+ // "Vocabulary").
195
200
  const err = new Error(
196
- 'react-x11: <foreign> needs the X11 backend this one has no ' +
197
- 'cross-process window embedding, so nothing can be put in it. Ask ' +
198
- "useSupports('embedding') before rendering one.",
201
+ 'react-x11: <foreign> needs a backend with cross-process window ' +
202
+ 'embedding, and this one has none — so nothing can be put in it. ' +
203
+ "Ask useSupports('embedding') before rendering one.",
199
204
  );
200
205
  this.error = err;
201
206
  // The client is the whole reason this node is a Tab stop by default, and
@@ -44,6 +44,7 @@ import React, {
44
44
  } from 'react';
45
45
 
46
46
  import { useAppOrNull } from '../appcontext.js';
47
+ import { canEmbed } from '../embedding.js';
47
48
  import { FrameEnv } from './env.js';
48
49
  import { CallbackTable, PROTOCOL } from './protocol.js';
49
50
 
@@ -221,11 +222,14 @@ export function Frame({
221
222
  ref,
222
223
  }) {
223
224
  const env = useContext(FrameEnv);
224
- // A backend that composites panes from shared memory (Cocoa) declares
225
- // itself with createPaneHost; everything else embeds the pane's real
226
- // window through <foreign>, exactly as before.
225
+ // A backend that composites panes from a shared buffer (Cocoa, Windows)
226
+ // declares itself with createPaneHost; everything else embeds the pane's
227
+ // real window through <foreign>, exactly as before.
227
228
  const appOrNull = useAppOrNull();
228
229
  const paneApp = appOrNull?.createPaneHost ? appOrNull : null;
230
+ // Two mechanisms, one question: is there any way to *show* a pane here?
231
+ // Asked before the fork rather than after it — see the session effect.
232
+ const canShowPane = Boolean(paneApp) || canEmbed(appOrNull);
229
233
  const [state, setState] = useState({
230
234
  phase: 'starting',
231
235
  windowId: null,
@@ -262,6 +266,28 @@ export function Frame({
262
266
  setState({ phase: 'failed', windowId: null, error });
263
267
  };
264
268
 
269
+ // Before the fork, not after it. A backend with neither mechanism used
270
+ // to start the pane, let it load its module and mount, and only then
271
+ // discover at the embed that there was nowhere to put it — a whole
272
+ // process spawned and killed to reach a conclusion the app object had
273
+ // all along. The fallback it renders is the same one either way; what
274
+ // changes is that it renders at once and costs nothing.
275
+ if (!canShowPane) {
276
+ fail(
277
+ Object.assign(
278
+ new Error(
279
+ 'react-x11: <Frame> needs a backend that can show a pane — one ' +
280
+ 'that composites panes from a shared buffer, or one with ' +
281
+ 'cross-process window embedding — and this one has neither, ' +
282
+ 'so no pane was started. Ask ' +
283
+ "useSupports('embedding') before rendering one.",
284
+ ),
285
+ { phase: 'embed' },
286
+ ),
287
+ );
288
+ return undefined;
289
+ }
290
+
265
291
  let t;
266
292
  try {
267
293
  t = makeTransport({ src: source, display });
@@ -396,7 +422,7 @@ export function Frame({
396
422
  // ask, and everything is unref'd so nothing holds the host open
397
423
  if (session.current === s) session.current = null;
398
424
  };
399
- }, [source, display, generation, makeTransport]);
425
+ }, [source, display, generation, makeTransport, canShowPane]);
400
426
 
401
427
  // One update per commit that changed the pane's inputs, props and env in
402
428
  // the same message — so a theme flip and the state change that caused it