react-x11 2.12.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -60,6 +60,7 @@
60
60
  "examples:frame": "tsx examples/frame.jsx",
61
61
  "labs:text-baseline": "tsx examples/labs/text-baseline.jsx",
62
62
  "labs:direct-gl": "tsx examples/labs/direct-gl.jsx",
63
+ "labs:gl-overlay": "tsx examples/labs/gl-overlay.jsx",
63
64
  "examples:viewer3d": "tsx examples/viewer3d.jsx",
64
65
  "examples:transparent": "tsx examples/transparent.jsx",
65
66
  "examples:windows": "tsx examples/windows.jsx",
@@ -105,7 +106,7 @@
105
106
  "optionalDependencies": {
106
107
  "@windowkit/appkit": "^0.10.0",
107
108
  "dbus-native": "^0.15.1",
108
- "x11-dri": "^0.7.0"
109
+ "x11-dri": "^0.8.0"
109
110
  },
110
111
  "peerDependencies": {
111
112
  "@babel/core": "^8.0.0",
package/src/Reconciler.js CHANGED
@@ -138,17 +138,18 @@ const HostConfig = {
138
138
  return {
139
139
  isInsideText: false,
140
140
  isInsideSvg: false,
141
- isInside3d: false,
142
141
  };
143
142
  },
144
143
 
145
144
  getChildHostContext(parentHostContext, type) {
145
+ // A `<glarea>`'s children are drawn nodes like any box's — 2D content
146
+ // above the surface (src/gloverlay.js) — so it opens no context of its
147
+ // own. A scene graph over the surface is `@react-x11/components/three`,
148
+ // with a reconciler of its own.
146
149
  return {
147
150
  isInsideText: parentHostContext.isInsideText || type === 'text',
148
151
  // <svg> children are declarative SVG elements, not react-x11 nodes
149
152
  isInsideSvg: parentHostContext.isInsideSvg || type === 'svg',
150
- // inside <glarea> the children are scene nodes, not drawn nodes
151
- isInside3d: parentHostContext.isInside3d || type === 'glarea',
152
153
  };
153
154
  },
154
155
 
@@ -194,17 +195,6 @@ const HostConfig = {
194
195
  '<text> spans and strings are.',
195
196
  );
196
197
  }
197
- if (hostContext.isInside3d) {
198
- // `<glarea>` is a leaf here: it owns the surface, the frame clock and
199
- // the swap, and `onDraw` is the escape hatch. A *scene graph* over it
200
- // — meshes, materials, lights, post-processing, on either backend —
201
- // is `@react-x11/components/three`, which brings its own reconciler.
202
- throw new Error(
203
- `react-x11: <${type}> is not an element — <glarea> takes no ` +
204
- 'children. Draw through `onDraw`, or use ' +
205
- '`@react-x11/components/three` for a scene graph. See docs/gl.md.',
206
- );
207
- }
208
198
  let node;
209
199
  switch (type) {
210
200
  case 'window':
package/src/appcontext.js CHANGED
@@ -26,7 +26,9 @@ import {
26
26
  compositingActive,
27
27
  watchCompositing,
28
28
  } from './compositing.js';
29
+ import { canEmbed } from './embedding.js';
29
30
  import { hasDirectGL, watchDirectGL } from './glbackend.js';
31
+ import { canOverlay } from './gloverlay.js';
30
32
 
31
33
  const AppContext = createContext(null);
32
34
 
@@ -57,17 +59,29 @@ export function useApp() {
57
59
  return app;
58
60
  }
59
61
 
60
- const SUPPORTS_FEATURES = new Set([
61
- 'transparency',
62
- 'shaders',
63
- 'nativeControls',
64
- ]);
65
-
66
- // 'nativeControls' is a property of the backend, decided before the first
67
- // render and never changing after — so its subscription has nothing to
68
- // deliver and its snapshot is a property test.
62
+ // 'nativeControls', 'embedding' and 'glOverlay' are properties of the
63
+ // backend, decided before the first render and never changing after — so
64
+ // their subscription has nothing to deliver and their snapshot is a property
65
+ // test.
69
66
  const NEVER_CHANGES = () => () => {};
70
67
 
68
+ // What `useSupports` watches and reads, per feature. `read` answers a
69
+ // boolean, so the snapshot is stable for a given state — returning the
70
+ // visual object for 'transparency' would tear on every render.
71
+ const FEATURES = {
72
+ transparency: {
73
+ watch: watchCompositing,
74
+ read: (app) => compositingActive(app) && Boolean(argbVisual(app)),
75
+ },
76
+ shaders: { watch: watchDirectGL, read: hasDirectGL },
77
+ nativeControls: {
78
+ watch: NEVER_CHANGES,
79
+ read: (app) => Boolean(app.nativeBezels),
80
+ },
81
+ embedding: { watch: NEVER_CHANGES, read: canEmbed },
82
+ glOverlay: { watch: NEVER_CHANGES, read: canOverlay },
83
+ };
84
+
71
85
  /**
72
86
  * Can this **display** do something, as a value a component can branch on?
73
87
  *
@@ -127,40 +141,55 @@ const NEVER_CHANGES = () => () => {};
127
141
  * first render already reads the final answer. A policy raised after
128
142
  * connecting has missed that probe, and re-renders its readers when it
129
143
  * settles rather than leaving them with two different answers.
144
+ *
145
+ * `'embedding'` is true when this connection can take another process's
146
+ * window into its own — the X11 backend; never Cocoa, which has no such
147
+ * primitive, and never the headless mock. It is the question to ask before
148
+ * rendering a `<foreign>`, which refuses with one `onError` where the answer
149
+ * is no:
150
+ *
151
+ * ```jsx
152
+ * const embedding = useSupports('embedding');
153
+ * <box style={{ flexGrow: 1 }}>
154
+ * {embedding ? <foreign onReady={spawnInto} /> : <text>X11 only</text>}
155
+ * </box>
156
+ * ```
157
+ *
158
+ * Like `'nativeControls'`, it is a property of the backend and never changes.
159
+ *
160
+ * `'glOverlay'` is true when the children of a `<glarea>` are drawn above its
161
+ * GL surface on this connection — laid out in its box, painted on panes over
162
+ * the surface, hit before it. Both backends draw them; what differs is
163
+ * translucency, composited by Core Animation on the Cocoa backend and opaque
164
+ * on X11 (docs/elements.md says exactly how). It is the question to ask
165
+ * before handing a surface its HUD rather than drawing that some other way:
166
+ *
167
+ * ```jsx
168
+ * const overlay = useSupports('glOverlay');
169
+ * <glarea onDraw={drawMap}>{overlay && <Legend />}</glarea>
170
+ * ```
171
+ *
172
+ * A property of the backend too, and it never changes.
130
173
  */
131
174
  export function useSupports(feature) {
132
175
  const app = useApp();
133
- if (!SUPPORTS_FEATURES.has(feature)) {
176
+ const spec = Object.hasOwn(FEATURES, feature) ? FEATURES[feature] : null;
177
+ if (!spec) {
134
178
  throw new Error(
135
179
  `react-x11: useSupports(${JSON.stringify(feature)}) — unknown feature ` +
136
- `(expected one of ${[...SUPPORTS_FEATURES].join(', ')})`,
180
+ `(expected one of ${Object.keys(FEATURES).join(', ')})`,
137
181
  );
138
182
  }
139
- // Both features go through the same store, so the hooks below run in the
183
+ // Every feature goes through the same store, so the hooks below run in the
140
184
  // same order whatever is being asked about. Where compositing comes and
141
185
  // goes for as long as the app runs, the backend settles at most once — and
142
186
  // watching that one moment is what keeps two components rendered either
143
187
  // side of it from disagreeing (see watchDirectGL).
144
188
  const subscribe = useCallback(
145
- (onChange) =>
146
- feature === 'nativeControls'
147
- ? NEVER_CHANGES()
148
- : feature === 'shaders'
149
- ? watchDirectGL(app, onChange)
150
- : watchCompositing(app, onChange),
151
- [app, feature],
152
- );
153
- // a boolean, so the snapshot is stable for a given state — returning the
154
- // visual object here would tear on every render
155
- const snapshot = useCallback(
156
- () =>
157
- feature === 'nativeControls'
158
- ? Boolean(app.nativeBezels)
159
- : feature === 'shaders'
160
- ? hasDirectGL(app)
161
- : compositingActive(app) && Boolean(argbVisual(app)),
162
- [app, feature],
189
+ (onChange) => spec.watch(app, onChange),
190
+ [app, spec],
163
191
  );
192
+ const snapshot = useCallback(() => spec.read(app), [app, spec]);
164
193
  return useSyncExternalStore(subscribe, snapshot, snapshot);
165
194
  }
166
195
 
package/src/cocoa/app.js CHANGED
@@ -26,6 +26,7 @@ import { 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';
29
+ import { CocoaOverlayPane } from './overlay.js';
29
30
  import { CocoaDockMenu } from './dock.js';
30
31
  import { CocoaGlobalMenuExport } from './globalmenu.js';
31
32
  import { CocoaStatusItem } from './statusitem.js';
@@ -418,6 +419,16 @@ export class CocoaApp {
418
419
  return cocoaGLConfig(this, spec);
419
420
  }
420
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
+
421
432
  /**
422
433
  * The Frame host seam (src/frame/index.js): a pane's composited region
423
434
  * in this window. Its presence is what routes <Frame> to the shared-
@@ -14,7 +14,10 @@
14
14
  // `destroy()`, `requestAnimationFrame`. On X11 that child is a real X
15
15
  // window stacked above the parent's drawing; here it is a sublayer of the
16
16
  // window's root layer with a high zPosition — the same "GL sits above the
17
- // 2D" semantics, by the same mechanism the platform gives us.
17
+ // 2D" semantics, by the same mechanism the platform gives us. The layer
18
+ // takes no input: pointer events are the NSWindow's, and the window's hit
19
+ // test answers the surface for a point over it (`GlAreaNode.hitSurface`),
20
+ // which is where X11's event propagation ends up too.
18
21
  //
19
22
  // ## The API ladder
20
23
  //
@@ -142,7 +145,6 @@ export class CocoaGLArea {
142
145
  this.scale = this.parent.scale ?? app.scale ?? 1;
143
146
  this.destroyed = false;
144
147
  this._reactX11Node = null;
145
- this.onWheel = options.onWheel ?? null;
146
148
  this.layer = this._native.createLayer();
147
149
  this._native.addSublayer(this.parent._layer, this.layer);
148
150
  this.rect = null;
@@ -0,0 +1,159 @@
1
+ // The pane a `<glarea>`'s children are drawn on, on the Cocoa backend: a
2
+ // transparent bitmap layer over the surface (src/gloverlay.js).
3
+ //
4
+ // A sublayer of the window's root layer like the surface's own
5
+ // (src/cocoa/glarea.js), one step above it: the GL layer sits at zPosition
6
+ // 1e7, over everything both presenters put on the root layer, and this one
7
+ // at 1e7 + 1, over that. Core Animation composites it, so what the children
8
+ // leave transparent shows the GL frame, and a translucent fill, an
9
+ // antialiased edge or a shadow blends with it — the one backend where the
10
+ // overlay is not opaque. Every surface's GL layer shares the one zPosition,
11
+ // so where two surfaces overlap, both overlays are above both frames.
12
+ //
13
+ // It speaks the verbs of a window the overlay drives on X11 — `setState`,
14
+ // `map`, `unmap`, `getContext`, `destroy` — plus `present`, which puts what
15
+ // was painted on the layer: ntk blits an X window's backing store on its
16
+ // own, where a layer's contents are a copy the bitmap has to be pushed to.
17
+ import { CocoaContext2D } from './context2d.js';
18
+
19
+ export const OVERLAY_Z = 1e7 + 1;
20
+
21
+ export class CocoaOverlayPane {
22
+ constructor(app, options) {
23
+ this.app = app;
24
+ this.parent = options.parent;
25
+ this._native = app._native;
26
+ this.scale = this.parent.scale ?? app.scale ?? 1;
27
+ this.destroyed = false;
28
+ this.layer = this._native.createLayer();
29
+ this._native.addSublayer(this.parent._layer, this.layer);
30
+ this.rect = null;
31
+ this._surface = null;
32
+ this._surfaceSize = null;
33
+ this._gen = 0;
34
+ this._ctx = null;
35
+ this._dirty = false;
36
+ // Hidden until something is on it: a layer shows its contents from the
37
+ // moment it is added, and before the first present there are none to
38
+ // show, only whatever the render server makes of that.
39
+ this._presented = false;
40
+ this._hidden = false;
41
+ this.setState({
42
+ x: options.x ?? 0,
43
+ y: options.y ?? 0,
44
+ width: options.width ?? 1,
45
+ height: options.height ?? 1,
46
+ });
47
+ }
48
+
49
+ get width() {
50
+ return this.rect?.width ?? 0;
51
+ }
52
+
53
+ get height() {
54
+ return this.rect?.height ?? 0;
55
+ }
56
+
57
+ /** Geometry in device px, the unit the overlay's rects are in. */
58
+ setState(rect) {
59
+ if (this.destroyed) return;
60
+ this.rect = rect;
61
+ const s = this.scale;
62
+ this._setLayerProps({
63
+ frame: [rect.x / s, rect.y / s, rect.width / s, rect.height / s],
64
+ zPosition: OVERLAY_Z,
65
+ hidden: this._hidden || !this._presented,
66
+ });
67
+ }
68
+
69
+ map() {
70
+ this._hidden = false;
71
+ if (this._presented) this._setLayerProps({ hidden: false });
72
+ }
73
+
74
+ unmap() {
75
+ this._hidden = true;
76
+ this._setLayerProps({ hidden: true });
77
+ }
78
+
79
+ /** Implicit animations off, for the reason `CocoaGLArea._setLayerProps`
80
+ * gives: no frame's transaction covers a layer that is not a presenter's. */
81
+ _setLayerProps(props) {
82
+ if (this.destroyed) return;
83
+ const native = this._native;
84
+ native.txBegin({ disableActions: true });
85
+ try {
86
+ native.setLayerProps(this.layer, props);
87
+ } finally {
88
+ native.txCommit();
89
+ }
90
+ }
91
+
92
+ /** The bitmap, the pane's size — a new size is a new bitmap, cleared, and
93
+ * the old one freed now rather than by the handle's finalizer. */
94
+ _ensureSurface() {
95
+ const w = Math.max(1, this.rect?.width ?? 1);
96
+ const h = Math.max(1, this.rect?.height ?? 1);
97
+ const size = this._surfaceSize;
98
+ if (!this._surface || size.width !== w || size.height !== h) {
99
+ this._release();
100
+ this._surface = this._native.createSurface(w, h, this.scale);
101
+ this._native.ctxClearRect(this._surface, 0, 0, w, h);
102
+ this._surfaceSize = { width: w, height: h };
103
+ this._gen++;
104
+ }
105
+ return this._surface;
106
+ }
107
+
108
+ _release() {
109
+ const surface = this._surface;
110
+ this._surface = null;
111
+ if (surface && typeof this._native.releaseSurface === 'function') {
112
+ this._native.releaseSurface(surface);
113
+ }
114
+ }
115
+
116
+ getContext() {
117
+ if (!this._ctx) {
118
+ this._ctx = new CocoaContext2D(
119
+ this._native,
120
+ () => this._ensureSurface(),
121
+ () => {
122
+ this._ensureSurface();
123
+ return this._gen;
124
+ },
125
+ );
126
+ this._ctx._fonts = this.app.fonts;
127
+ this._ctx._onDirty = () => {
128
+ this._dirty = true;
129
+ };
130
+ }
131
+ return this._ctx;
132
+ }
133
+
134
+ /** What was painted, onto the layer — a copy, so the bitmap is free to be
135
+ * painted again at once. */
136
+ present() {
137
+ if (this.destroyed || !this._dirty || !this._surface) return;
138
+ this._dirty = false;
139
+ const native = this._native;
140
+ native.txBegin({ disableActions: true });
141
+ try {
142
+ native.surfaceToLayer(this._surface, this.layer);
143
+ if (!this._presented) {
144
+ this._presented = true;
145
+ if (!this._hidden) native.setLayerProps(this.layer, { hidden: false });
146
+ }
147
+ } finally {
148
+ native.txCommit();
149
+ }
150
+ }
151
+
152
+ destroy() {
153
+ if (this.destroyed) return;
154
+ this.destroyed = true;
155
+ this._release();
156
+ this._ctx = null;
157
+ this._native.removeFromSuperlayer(this.layer);
158
+ }
159
+ }
@@ -142,6 +142,18 @@ function paintsSomething(node) {
142
142
  return Boolean(node.isScroller?.());
143
143
  }
144
144
 
145
+ /**
146
+ * Inside a `<glarea>`: drawn on a pane above the surface (src/gloverlay.js).
147
+ * A layer of its own would sit on the root layer *under* the GL layer, so a
148
+ * promoted node there would vanish behind the surface it is drawn over.
149
+ */
150
+ function insideGlArea(node) {
151
+ for (let n = node.parent; n && !n.isWindow; n = n.parent) {
152
+ if (n.isGlArea) return true;
153
+ }
154
+ return false;
155
+ }
156
+
145
157
  /**
146
158
  * Can this node be a property box on a layer at all — the static half of
147
159
  * the answer, the same whatever the scene around it does: a plain box by
@@ -150,6 +162,7 @@ function paintsSomething(node) {
150
162
  */
151
163
  function promotableNode(node) {
152
164
  if (node.destroyed || !plainBox(node)) return false;
165
+ if (insideGlArea(node)) return false;
153
166
  if (!stylePaintsPlain(node, node._targetStyle ?? node.style)) return false;
154
167
  if (node.isScroller?.()) return false;
155
168
  return !paintsOutline(node);
@@ -0,0 +1,31 @@
1
+ // Whether a connection can take another process's window into its own.
2
+ //
3
+ // `<foreign>` asks before it builds a socket, and `useSupports('embedding')`
4
+ // asks for a component deciding whether to render one. One function for
5
+ // both, because the two have to agree: a hook that said yes over an element
6
+ // that then refused, or the reverse, is the bug this closes (issue #531).
7
+ //
8
+ // A feature test on the connection, not a question about which backend this
9
+ // is — and not "is there an `X`", which is true everywhere: the Cocoa app
10
+ // carries an X stub for the modules with an X escape hatch, and the headless
11
+ // mock carries the same one. Both also have a `createWindow` that takes a
12
+ // parent, which is how `<foreign>` used to get as far as a socket over them.
13
+ // So each part of embedding is asked about by name: a container window to
14
+ // hold the client, `ReparentWindow` to move somebody else's window into it,
15
+ // and the save set, which keeps that window alive if this process dies
16
+ // holding it — the promise docs/embedding.md makes about a window we do not
17
+ // own.
18
+ //
19
+ // A leaf module on purpose, for glbackend.js's reason: it imports nothing of
20
+ // ours, so both `appcontext.js` and the node layer can use it without the
21
+ // two importing each other.
22
+
23
+ /** Can `app` host another client's window inside one of its own? */
24
+ export function canEmbed(app) {
25
+ const X = app?.X;
26
+ return (
27
+ typeof app?.createWindow === 'function' &&
28
+ typeof X?.ReparentWindow === 'function' &&
29
+ typeof X?.ChangeSaveSet === 'function'
30
+ );
31
+ }
package/src/events.js CHANGED
@@ -47,6 +47,10 @@ const WHEEL_BUTTONS = new Set([4, 5, 6, 7]);
47
47
  */
48
48
  export const WHEEL_NOTCH_PX = 48;
49
49
  const RIGHT_BUTTON = 3;
50
+ // A LeaveNotify's detail (core protocol): the pointer went to an ancestor of
51
+ // the window it left, or into a window inside it — see `_leftIntoSurface`.
52
+ const NOTIFY_ANCESTOR = 0;
53
+ const NOTIFY_INFERIOR = 2;
50
54
 
51
55
  /** How many leading entries two node paths share. */
52
56
  function sharedPrefix(a, b) {
@@ -637,8 +641,32 @@ export class EventManager {
637
641
  return node.isWindow ? node.window : node;
638
642
  }
639
643
 
644
+ /**
645
+ * The node a pointer event landed on: a surface first, then the tree,
646
+ * front to back. A `<glarea>` is stacked above every 2D thing in its
647
+ * window, so a point inside one is over it whatever the tree's order says
648
+ * (`GlAreaNode.hitSurface`).
649
+ */
640
650
  _hit(ev) {
641
- return this.node.hitTest(ev.x, ev.y) ?? this.node;
651
+ return (
652
+ this._surfaceAt(ev.x, ev.y) ?? this.node.hitTest(ev.x, ev.y) ?? this.node
653
+ );
654
+ }
655
+
656
+ /**
657
+ * The frontmost of this window's surfaces a point is over, or null. The
658
+ * newest is on top on both backends (`GlAreaNode._create`). An empty list
659
+ * is one length read on the motion path, which is all a window with no
660
+ * `<glarea>` pays for this.
661
+ */
662
+ _surfaceAt(x, y) {
663
+ const surfaces = this.node._surfaces;
664
+ if (!surfaces?.length) return null;
665
+ for (let i = surfaces.length - 1; i >= 0; i--) {
666
+ const hit = surfaces[i].hitSurface(x, y);
667
+ if (hit) return hit;
668
+ }
669
+ return null;
642
670
  }
643
671
 
644
672
  _path(target) {
@@ -742,16 +770,7 @@ export class EventManager {
742
770
  * way somewhere rather than one, and React must be free to interrupt the
743
771
  * render it started for the notch before.
744
772
  */
745
- /**
746
- * @param {object} native ntk's wheel event, in this window's coordinates
747
- * @param {object} [over] the node the wheel happened over, when the caller
748
- * already knows. A `<glarea>` owns its own X window, so the server
749
- * delivers the event *there* and the surface hands it back translated
750
- * (src/glnodes.js) — and a hit test would answer with the box behind it,
751
- * because a window-owning child is not in its parent's paint order.
752
- * Knowing beats guessing; everything after this line is the same.
753
- */
754
- _onWheel(native, over = null) {
773
+ _onWheel(native) {
755
774
  // The first wheel is what says this window wants smooth scrolling. It was
756
775
  // created on core events — an XI2 selection costs four times as many
757
776
  // bytes per *pointer move*, which a window that is never scrolled would
@@ -765,7 +784,7 @@ export class EventManager {
765
784
  // outside gets, and for the same reason (`_pressOutside`).
766
785
  if (this._dismissOutside(native)) return;
767
786
  runWithPriority(ContinuousEventPriority, () => {
768
- const target = over ?? this._hit(native);
787
+ const target = this._hit(native);
769
788
  // Shift turns a vertical wheel sideways — the convention for the mouse
770
789
  // and the touchpad that have no horizontal axis. Read off the delta
771
790
  // rather than off the source: a plain wheel mouse on an XI2 connection
@@ -1043,6 +1062,13 @@ export class EventManager {
1043
1062
  }
1044
1063
 
1045
1064
  _onMouseOut(native) {
1065
+ // The pointer went into one of this window's surfaces, not out of the
1066
+ // window: X reports crossing into a child window as leaving this one,
1067
+ // but the input over a surface still arrives here — it selects none of
1068
+ // its own — and the next motion names it. Answered as a leave, every
1069
+ // ancestor was told the pointer had gone and then that it was back, and
1070
+ // `onMouseOut` that it had left a window it was still in.
1071
+ if (this._leftIntoSurface(native)) return;
1046
1072
  runWithPriority(ContinuousEventPriority, () => {
1047
1073
  this._updateHover([], native);
1048
1074
  // the pointer is somewhere else entirely: whatever it was heading for
@@ -1061,6 +1087,40 @@ export class EventManager {
1061
1087
  });
1062
1088
  }
1063
1089
 
1090
+ /**
1091
+ * Whether a LeaveNotify leaves the pointer in one of this window's
1092
+ * surfaces rather than out of the window.
1093
+ *
1094
+ * A real server says "into a window inside this one" with detail
1095
+ * NotifyInferior, and says it twice over a surface: when the pointer moves
1096
+ * onto it, and when a grab ends with the pointer on it (mode NotifyUngrab)
1097
+ * — which every click and every wheel notch over a surface does, since the
1098
+ * press's implicit grab is this window's. node-x11's in-process server
1099
+ * says it with detail NotifyAncestor and the child named instead, and
1100
+ * sends no crossings for grabs at all.
1101
+ *
1102
+ * Either way the point says which inferior, and only a surface's rect
1103
+ * makes it ours: a nested `<window>` or a `<foreign>` takes its own input,
1104
+ * so leaving for one of those is still a leave. So is a grab taken
1105
+ * elsewhere while the pointer is over a surface — detail NotifyVirtual or
1106
+ * NonlinearVirtual, the surface named as the child it left from — since
1107
+ * the pointer is the grab's now. The Cocoa backend's leave carries no
1108
+ * detail, and is always one.
1109
+ */
1110
+ _leftIntoSurface(native) {
1111
+ if (!native) return false;
1112
+ const into =
1113
+ native.detail === NOTIFY_INFERIOR ||
1114
+ (native.detail === NOTIFY_ANCESTOR && Boolean(native.child));
1115
+ if (!into) return false;
1116
+ const wnd = this.node.window;
1117
+ const { x, y } = native;
1118
+ if (x < 0 || y < 0 || x >= (wnd?.width ?? 0) || y >= (wnd?.height ?? 0)) {
1119
+ return false;
1120
+ }
1121
+ return this._surfaceAt(x, y) !== null;
1122
+ }
1123
+
1064
1124
  /**
1065
1125
  * The chain of the press that is still live, as far along it as the
1066
1126
  * pointer has stayed. `downPath` is where the press landed and does not
@@ -33,12 +33,18 @@
33
33
  import { XEMBED, XEmbedSocket } from 'ntk';
34
34
 
35
35
  import { isFocusable } from './a11y.js';
36
+ import { canEmbed } from './embedding.js';
36
37
  import { lastInputTime } from './inputtime.js';
37
38
  import { Node } from './nodes/node.js';
38
39
  import { pixelFor } from './nodes/window/capabilities.js';
39
40
 
40
41
  const px = (v) => Math.max(1, Math.round(v || 0));
41
42
 
43
+ // Apps already told, by a <foreign> with no `onError`, that they cannot
44
+ // embed. Once per app: the answer belongs to the backend, so the second pane
45
+ // to ask carries no news — the same rule as a display with no ARGB visual.
46
+ const warnedCannotEmbed = new WeakSet();
47
+
42
48
  /**
43
49
  * `<foreign>` — another client's top-level window, laid out as an element.
44
50
  *
@@ -89,6 +95,18 @@ const px = (v) => Math.max(1, Math.round(v || 0));
89
95
  * pointer is *over* the embedded client the client receives keys directly
90
96
  * and no handler of ours runs. Chords work everywhere else in the window,
91
97
  * which is the trade a proxy would make in reverse.
98
+ *
99
+ * ### Where there is no embedding
100
+ *
101
+ * All of this needs a connection that can reparent somebody else's window,
102
+ * and two apps this node meets have none: the Cocoa backend and the headless
103
+ * mock. Both carry an X stub and a `createWindow` that takes a parent, which
104
+ * used to be enough to build a socket over them — on Cocoa around a child GL
105
+ * surface whose `id` is undefined, handed out through `onReady` and spawned
106
+ * `xterm -into undefined` with; on the mock, a throw from inside the commit.
107
+ * So `realize()` asks `canEmbed` first, the question
108
+ * `useSupports('embedding')` answers too, and a no is final: no container, no
109
+ * `onReady`, one `onError`, and an empty box in the layout.
92
110
  */
93
111
  export class ForeignNode extends Node {
94
112
  constructor(props, app) {
@@ -97,8 +115,12 @@ export class ForeignNode extends Node {
97
115
  this.socket = null;
98
116
  /** `{ id, xembed, version }` while a client is in, else null */
99
117
  this.client = null;
100
- /** the error that stopped an embed, if one did */
118
+ /** the error that stopped an embed, if one did — or, where this
119
+ * connection cannot embed at all, the refusal */
101
120
  this.error = null;
121
+ // set once `realize()` finds this connection cannot embed, and final:
122
+ // the backend does not change under a node
123
+ this._refused = false;
102
124
  /** geometry last sent to the X windows */
103
125
  this.rect = null;
104
126
  // an embedded client is a control the user can Tab to, like a
@@ -133,9 +155,10 @@ export class ForeignNode extends Node {
133
155
  * where React can discard the work.
134
156
  */
135
157
  realize() {
136
- if (this.socket || this.destroyed) return;
158
+ if (this.socket || this.destroyed || this._refused) return;
137
159
  const parent = this.root?.window;
138
- if (!parent || typeof this.app?.createWindow !== 'function') return;
160
+ if (!parent) return;
161
+ if (!canEmbed(this.app)) return this._refuse();
139
162
  const rect = this._geometry();
140
163
  this.rect = rect;
141
164
  const socket = new XEmbedSocket(parent, {
@@ -159,6 +182,36 @@ export class ForeignNode extends Node {
159
182
  this._syncMapped();
160
183
  }
161
184
 
185
+ /**
186
+ * This connection cannot embed: say so once, and be an empty box.
187
+ *
188
+ * On a microtask for `onReady`'s reason: this runs in the commit phase, and
189
+ * the ordinary answer to the news is to set state. Nothing else needs
190
+ * undoing — with no socket, every other path through this node (layout,
191
+ * props, focus, keys, teardown) already finds nothing to act on.
192
+ */
193
+ _refuse() {
194
+ this._refused = true;
195
+ 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.",
199
+ );
200
+ this.error = err;
201
+ // The client is the whole reason this node is a Tab stop by default, and
202
+ // one that can never arrive would leave a dead one. `focusable` still
203
+ // wins, as it does on a box.
204
+ this.focusableByDefault = false;
205
+ queueMicrotask(() => {
206
+ if (this.destroyed) return;
207
+ if (this.props.onError) this.props.onError(err);
208
+ else if (this.app && !warnedCannotEmbed.has(this.app)) {
209
+ warnedCannotEmbed.add(this.app);
210
+ console.warn(err.message);
211
+ }
212
+ });
213
+ }
214
+
162
215
  /**
163
216
  * Embed `windowId`, or — with none — wait for something to put a window
164
217
  * inside the container and take that.