react-x11 2.11.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +278 -129
  2. package/package.json +12 -4
  3. package/src/Reconciler.js +19 -31
  4. package/src/a11y.js +2 -2
  5. package/src/anchor.js +7 -5
  6. package/src/appcontext.js +59 -30
  7. package/src/bootstrap.js +14 -0
  8. package/src/clientmessage.js +1 -1
  9. package/src/cocoa/app.js +303 -49
  10. package/src/cocoa/bezels.js +175 -30
  11. package/src/cocoa/dnd.js +27 -13
  12. package/src/cocoa/fonts.js +3 -3
  13. package/src/cocoa/glarea.js +24 -5
  14. package/src/cocoa/main.d.ts +8 -0
  15. package/src/cocoa/main.js +43 -0
  16. package/src/cocoa/overlay.js +159 -0
  17. package/src/cocoa/panehost.js +15 -5
  18. package/src/cocoa/presenter.js +13 -9
  19. package/src/cocoa/promotion.js +17 -7
  20. package/src/cocoa/relaunch.js +207 -0
  21. package/src/cocoa/threaded.js +246 -0
  22. package/src/cocoa/window.js +256 -42
  23. package/src/components/Select.js +2 -2
  24. package/src/components/anchor.js +3 -3
  25. package/src/components/native.js +12 -7
  26. package/src/components/theme.js +2 -2
  27. package/src/debug.js +1 -1
  28. package/src/decorations.js +1 -1
  29. package/src/editmenu.js +2 -2
  30. package/src/embedding.js +31 -0
  31. package/src/errors.js +46 -0
  32. package/src/events.js +78 -18
  33. package/src/foreignnodes.js +59 -5
  34. package/src/frames.js +2 -2
  35. package/src/glnodes.js +172 -41
  36. package/src/gloverlay.js +383 -0
  37. package/src/grid.js +1653 -0
  38. package/src/host.d.ts +230 -1
  39. package/src/host.js +11 -3
  40. package/src/imagesource.js +1 -1
  41. package/src/index.d.ts +34 -4
  42. package/src/index.js +9 -1
  43. package/src/layouts.js +721 -0
  44. package/src/node.d.ts +16 -3
  45. package/src/node.js +19 -21
  46. package/src/nodes/animation.js +644 -0
  47. package/src/nodes/box.js +21 -0
  48. package/src/nodes/boxpaint.js +473 -0
  49. package/src/nodes/canvas.js +269 -0
  50. package/src/nodes/cascade.js +600 -0
  51. package/src/nodes/damage.js +183 -0
  52. package/src/nodes/edithistory.js +124 -0
  53. package/src/nodes/editmenupopup.js +260 -0
  54. package/src/nodes/hittest.js +185 -0
  55. package/src/nodes/image.js +266 -0
  56. package/src/nodes/install.js +75 -0
  57. package/src/nodes/invalidate.js +465 -0
  58. package/src/nodes/kinds.js +31 -0
  59. package/src/nodes/layout.js +439 -0
  60. package/src/nodes/layouthost.js +949 -0
  61. package/src/nodes/node.js +868 -0
  62. package/src/nodes/paint.js +466 -0
  63. package/src/nodes/position.js +366 -0
  64. package/src/nodes/preedit.js +127 -0
  65. package/src/nodes/queries.js +330 -0
  66. package/src/nodes/rects.js +102 -0
  67. package/src/nodes/scrollable.js +891 -0
  68. package/src/nodes/scrollbars.js +138 -0
  69. package/src/nodes/scrollblit.js +1034 -0
  70. package/src/nodes/selectable.js +142 -0
  71. package/src/nodes/styling.js +225 -0
  72. package/src/nodes/text.js +649 -0
  73. package/src/nodes/textarea.js +391 -0
  74. package/src/nodes/textinput.js +1146 -0
  75. package/src/nodes/util.js +17 -0
  76. package/src/nodes/window/anchoring.js +161 -0
  77. package/src/nodes/window/capabilities.js +190 -0
  78. package/src/nodes/window/debugpaint.js +83 -0
  79. package/src/nodes/window/droptarget.js +145 -0
  80. package/src/nodes/window/floors.js +577 -0
  81. package/src/nodes/window/flush.js +369 -0
  82. package/src/nodes/window/hints.js +482 -0
  83. package/src/nodes/window/listeners.js +222 -0
  84. package/src/nodes/window/popup.js +71 -0
  85. package/src/nodes/window/size.js +591 -0
  86. package/src/nodes/window/window.js +954 -0
  87. package/src/palette.js +1 -1
  88. package/src/registry.js +7 -3
  89. package/src/styles.js +137 -15
  90. package/src/svgnodes.js +2 -1
  91. package/src/testing/harness.js +2 -2
  92. package/src/textselection.js +5 -3
  93. package/src/trace-registry.js +1 -1
  94. package/src/types/components.d.ts +38 -6
  95. package/src/types/elements.d.ts +26 -14
  96. package/src/types/nodes.d.ts +17 -2
  97. package/src/types/style.d.ts +94 -3
  98. package/src/windowstate.js +1 -1
  99. package/src/yoga.js +1 -1
  100. package/src/nodes.js +0 -13120
package/src/errors.js CHANGED
@@ -59,6 +59,35 @@ export function reportHandlerError(node, handler, error) {
59
59
  markFailed();
60
60
  }
61
61
 
62
+ /**
63
+ * A throw from a layout or a placement (docs/extending.md) — code that runs
64
+ * inside a frame, where no error boundary is on the stack either. Reported
65
+ * the way a handler's throw is, `onUncaughtError` included, and the frame
66
+ * carries on with the fallback `consequence` names: one bad algorithm must
67
+ * not stop the window from painting.
68
+ */
69
+ export function reportLayoutError(node, what, error, consequence) {
70
+ const element = node?.kind ? `<${node.kind}>` : '(unknown)';
71
+ const owner = ownerName(node);
72
+ const custom = node?.app && perContainer.get(node.app);
73
+ if (custom) {
74
+ custom(error, {
75
+ componentStack: owner ? `\n in ${owner}` : undefined,
76
+ element,
77
+ handler: what,
78
+ node,
79
+ });
80
+ return;
81
+ }
82
+ console.error(
83
+ `react-x11: ${what} on ${element}${owner ? ` in ${owner}` : ''} threw. ` +
84
+ 'It ran inside a frame rather than a render, so no error boundary ' +
85
+ `could catch it. ${consequence}.`,
86
+ error,
87
+ );
88
+ markFailed();
89
+ }
90
+
62
91
  /**
63
92
  * `REACT_X11_STRICT_TOKENS=1` makes a `$token` the theme does not define
64
93
  * fatal again, for a build that would rather stop than paint something
@@ -104,6 +133,23 @@ export function reportStyleError(
104
133
  markFailed();
105
134
  }
106
135
 
136
+ /**
137
+ * A style that asks for something that is not there — a layout or a
138
+ * placement nobody registered, an option of the wrong type. Reported and
139
+ * carried on from, once per node and message, the way an unknown token is;
140
+ * with no strict switch, because the fallback it names is a real one and
141
+ * not a property silently dropped.
142
+ */
143
+ export function reportStyleProblem(node, message, consequence) {
144
+ const seen = reportedStyleErrors.get(node);
145
+ if (seen?.has(message)) return;
146
+ if (seen) seen.add(message);
147
+ else reportedStyleErrors.set(node, new Set([message]));
148
+ const owner = ownerName(node);
149
+ console.error(`${message}${owner ? ` — in ${owner}` : ''}. ${consequence}.`);
150
+ markFailed();
151
+ }
152
+
107
153
  /** Wrap a call to user code so a throw is reported instead of escaping. */
108
154
  export function callHandler(node, handler, fn, ev) {
109
155
  try {
package/src/events.js CHANGED
@@ -42,11 +42,15 @@ const WHEEL_BUTTONS = new Set([4, 5, 6, 7]);
42
42
  * every registered element that answers the wheel), so the conversion happens
43
43
  * here, once, and `deltaX`/`deltaY` mean pixels wherever they are read.
44
44
  *
45
- * The same step an arrow key takes (`SCROLL_KEY_STEP`, nodes.js), so a notch
45
+ * The same step an arrow key takes (`SCROLL_KEY_STEP`, nodes/scrollable.js), so a notch
46
46
  * and an arrow press move a list by the same amount.
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) {
@@ -301,7 +305,7 @@ export function discrete(fn) {
301
305
  * One function, because there is more than one way to ask for it and they
302
306
  * must not drift: an AT's `DoAction("activate")` (atspi.js) and the
303
307
  * keyboard's Space/Enter on a focused control (`Node.defaultKeyDown`,
304
- * nodes.js) both land here, so a control that acts on the *press* — `Select`
308
+ * nodes/node.js) both land here, so a control that acts on the *press* — `Select`
305
309
  * and `MenuBar` drop their menus on mousedown, the way real menus do — is
306
310
  * reached by either, and neither can be the one input route a widget forgot.
307
311
  *
@@ -621,7 +625,7 @@ export class EventManager {
621
625
  if (!changed) return;
622
626
  // …and the things that keep a window of their own open on the strength
623
627
  // of this one having focus — a menu, a dropdown — which the focused node
624
- // keeping its focus would otherwise never tell (`WindowNode`, nodes.js)
628
+ // keeping its focus would otherwise never tell (`WindowNode`, nodes/window/window.js)
625
629
  this.node._notifyWindowFocus?.(focused);
626
630
  a11yHooks.windowFocus?.(this.node, focused);
627
631
  runWithPriority(DiscreteEventPriority, () => {
@@ -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,20 +770,11 @@ 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
758
- // pay for nothing — and takes the selection here, once (nodes.js,
777
+ // pay for nothing — and takes the selection here, once (nodes/window/window.js,
759
778
  // `upgradeToXI2`). Ahead of the dismiss check, because a scroll this
760
779
  // window heard is a scroll this window heard whatever it does with it.
761
780
  this.node.upgradeToXI2?.();
@@ -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
@@ -802,7 +821,7 @@ export class EventManager {
802
821
  // **The default action moves whole pixels and keeps the change.** A
803
822
  // scroll offset that is not an integer costs the scroll blit — the
804
823
  // server-side copy that makes a scroll cheap can only shift by whole
805
- // pixels, so `_applyScrollBlits` (nodes.js) declines a fractional one
824
+ // pixels, so `_applyScrollBlits` (nodes/scrollblit.js) declines a fractional one
806
825
  // and repaints the viewport instead. A touchpad reporting a third of a
807
826
  // notch would therefore turn every frame of the smoothest gesture the
808
827
  // renderer has into a full repaint, which is the opposite of the
@@ -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
@@ -1695,7 +1755,7 @@ export class EventManager {
1695
1755
  * focusable, and still on screen.
1696
1756
  *
1697
1757
  * Three callers ask it — a focus scope closing, an edit menu closing
1698
- * (`closeEditMenu`, nodes.js) and a subtree coming out of hiding — and the
1758
+ * (`closeEditMenu`, nodes/editmenupopup.js) and a subtree coming out of hiding — and the
1699
1759
  * third is why the question includes visibility. Whatever a modal was
1700
1760
  * opened from may have suspended while it was up, and handing the keyboard
1701
1761
  * back to it would put keys on an invisible control by the other route.
@@ -15,7 +15,7 @@
15
15
  // render that is then thrown away has moved somebody else's window for
16
16
  // real. So this node holds no socket until the owning `WindowNode`
17
17
  // realizes it in the commit phase, exactly as `<glarea>` is realized
18
- // (`_realizeChildWindows`, nodes.js).
18
+ // (`_realizeChildWindows`, nodes/window/window.js).
19
19
  //
20
20
  // 2. **Where the rect comes from.** Yoga, like any other child. Every
21
21
  // change is a ConfigureWindow on the client plus the synthetic
@@ -33,11 +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
- import { Node, pixelFor } from './nodes.js';
38
+ import { Node } from './nodes/node.js';
39
+ import { pixelFor } from './nodes/window/capabilities.js';
38
40
 
39
41
  const px = (v) => Math.max(1, Math.round(v || 0));
40
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
+
41
48
  /**
42
49
  * `<foreign>` — another client's top-level window, laid out as an element.
43
50
  *
@@ -88,6 +95,18 @@ const px = (v) => Math.max(1, Math.round(v || 0));
88
95
  * pointer is *over* the embedded client the client receives keys directly
89
96
  * and no handler of ours runs. Chords work everywhere else in the window,
90
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.
91
110
  */
92
111
  export class ForeignNode extends Node {
93
112
  constructor(props, app) {
@@ -96,8 +115,12 @@ export class ForeignNode extends Node {
96
115
  this.socket = null;
97
116
  /** `{ id, xembed, version }` while a client is in, else null */
98
117
  this.client = null;
99
- /** 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 */
100
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;
101
124
  /** geometry last sent to the X windows */
102
125
  this.rect = null;
103
126
  // an embedded client is a control the user can Tab to, like a
@@ -132,9 +155,10 @@ export class ForeignNode extends Node {
132
155
  * where React can discard the work.
133
156
  */
134
157
  realize() {
135
- if (this.socket || this.destroyed) return;
158
+ if (this.socket || this.destroyed || this._refused) return;
136
159
  const parent = this.root?.window;
137
- if (!parent || typeof this.app?.createWindow !== 'function') return;
160
+ if (!parent) return;
161
+ if (!canEmbed(this.app)) return this._refuse();
138
162
  const rect = this._geometry();
139
163
  this.rect = rect;
140
164
  const socket = new XEmbedSocket(parent, {
@@ -158,6 +182,36 @@ export class ForeignNode extends Node {
158
182
  this._syncMapped();
159
183
  }
160
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
+
161
215
  /**
162
216
  * Embed `windowId`, or — with none — wait for something to put a window
163
217
  * inside the container and take that.
package/src/frames.js CHANGED
@@ -1,9 +1,9 @@
1
1
  // Which windows owe a frame.
2
2
  //
3
- // Shared between the paint root (nodes.js — it records the debt in
3
+ // Shared between the paint root (`WindowNode` — it records the debt in
4
4
  // `invalidate()` and pays it in `flush()`) and the event dispatcher
5
5
  // (events.js — it pays early, for discrete input). Its own module because
6
- // nodes.js already imports events.js, and the dispatcher cannot import the
6
+ // src/nodes/ already imports events.js, and the dispatcher cannot import the
7
7
  // node back without a cycle.
8
8
  //
9
9
  // The set answers "which windows have unpainted damage" in O(1) at event
package/src/glnodes.js CHANGED
@@ -10,7 +10,8 @@ import { cssColorStraight } from 'ntk';
10
10
  // re-exported so the GL element layer stays one import for consumers
11
11
  export { directGLFailure, hasDirectGL } from './glbackend.js';
12
12
 
13
- import { Node } from './nodes.js';
13
+ import { GlOverlay, canOverlay } from './gloverlay.js';
14
+ import { Node } from './nodes/node.js';
14
15
  import { FramePacer, resolveFrameRate } from './pacing.js';
15
16
 
16
17
  // One visual query per (app, spec): GetFBConfigs is a round trip and every
@@ -105,13 +106,22 @@ const px = (v) => Math.max(1, Math.round(v || 0));
105
106
  * vocabulary as `<window frameRate>`. Defaults to the owning window's.
106
107
  * - `glx` — a `chooseGLXConfig` spec, e.g. `{ DEPTH_SIZE: 24 }`.
107
108
  *
108
- * The X child window is stacked above everything drawn in the parent, so 2D
109
- * content cannot overlap it — put HUD content in a sibling `<popup>`.
109
+ * The X child window is stacked above everything drawn in the parent, so the
110
+ * parent's 2D content cannot overlap it — but this node's own children do:
111
+ * they are laid out in its box like a `<box>`'s and drawn above the surface,
112
+ * on panes of their own (src/gloverlay.js).
113
+ *
114
+ * Pointer input over the surface is the tree's, on both backends: a press,
115
+ * a drag or a wheel over it is a synthetic event at the child under the
116
+ * pointer, or at this node, bubbling to its ancestors like anyone else's
117
+ * (`hitSurface` says how).
110
118
  */
111
119
  export class GlAreaNode extends Node {
112
120
  constructor(props, app) {
113
121
  super('glarea', props, app);
114
122
  this.window = null;
123
+ // the panes the children are drawn on, while there are children
124
+ this._overlay = null;
115
125
  this.gl = null;
116
126
  this.rect = null; // geometry last sent to the X window
117
127
  this._realizing = false;
@@ -150,11 +160,21 @@ export class GlAreaNode extends Node {
150
160
 
151
161
  _setRoot(root) {
152
162
  super._setRoot(root);
163
+ // Children mounted along with this node — React builds a subtree before
164
+ // it attaches it — are drawn from the first frame of the window it joins.
165
+ if (this.children.length) root?._overlaid?.add(this);
153
166
  // the owning window may already exist (a <glarea> mounted into a live
154
167
  // tree); otherwise WindowNode.realize picks the subtree up
155
168
  if (root?.window) this.realize();
156
169
  }
157
170
 
171
+ insertBefore(child, beforeChild) {
172
+ super.insertBefore(child, beforeChild);
173
+ // 2D content above the surface: the owning window's next frame gives it
174
+ // a pane (`_syncOverlay`), and the child-list claim asks for that frame
175
+ this.root?._overlaid?.add(this);
176
+ }
177
+
158
178
  /** Create the GL child window. Async: the visual comes from the server. */
159
179
  realize() {
160
180
  if (this.window || this.destroyed || this._realizing) return;
@@ -199,6 +219,9 @@ export class GlAreaNode extends Node {
199
219
  recordGlxFailure(this.app, err);
200
220
  this.gl = null;
201
221
  if (this.window) {
222
+ // the children's panes stay, over whatever the fallback draws, and
223
+ // stay hittable; with none, nothing of this node covers the rect
224
+ if (!this._overlay) this._leaveSurfaces();
202
225
  this.window.destroy?.();
203
226
  this.window = null;
204
227
  this.rect = null;
@@ -222,26 +245,32 @@ export class GlAreaNode extends Node {
222
245
  // GL draws into the window itself: no 2d backing pixmap, and the
223
246
  // frame clock is ours to drive
224
247
  backingStore: false,
225
- // The wheel, and only the wheel. A GL surface owns a real X window, so
226
- // the pointer events over it are delivered *there* rather than to the
227
- // window the rest of the tree is hit-tested in which is why nothing
228
- // over a `<glarea>` reached an application before. Selecting it here
229
- // and handing it back to the owning window's manager (`_onWheel`
230
- // below) is the whole of it: from there the event is an ordinary
231
- // synthetic `Wheel` at this node, so it bubbles, `preventDefault()`
232
- // takes it back for a scene that zooms instead, and the default action
233
- // scrolls the nearest container the way it does anywhere else.
248
+ // No pointer input is selected here, and that is the whole of how the
249
+ // pointer over the surface reaches the tree. X reports a device event
250
+ // to the first window up the hierarchy that selected it, so a press,
251
+ // a motion or a wheel over this window arrives at the owning window
252
+ // instead in its coordinates and under its implicit grab, so a drag
253
+ // that leaves the surface keeps coming and its event manager takes
254
+ // it from there like any other (`hitSurface` names this node).
234
255
  //
235
- // Selected unconditionally rather than when a handler is declared: the
236
- // default action is what a reader expects from a wheel over a page,
237
- // and an element that wants it back has `preventDefault()`. One
238
- // ButtonPress per notch is not a cost worth a conditional.
239
- onWheel: (ev) => this._onWheel(ev),
256
+ // Selecting one here takes it away from the tree, which is how the
257
+ // wheel alone used to be handled: the surface selected ButtonPress to
258
+ // hear it and handed it back, and so every *press* on the surface
259
+ // ended here too, with the drag and the release that should have
260
+ // followed it. A listener on `node.window` does the same — ntk selects
261
+ // what a window is listened to for — which is what `forwardsPointer`
262
+ // exists to tell an element built on this one.
240
263
  });
241
264
  this.window = wnd;
242
265
  this.rect = rect;
243
266
  this.config = config;
244
267
  wnd._reactX11Node = this;
268
+ // Above everything 2D in the owning window on both backends, and above
269
+ // every surface made before this one: X stacks a new child window over
270
+ // its siblings, and Core Animation a layer added later over one at the
271
+ // same zPosition. The window's hit test reads the list in that order
272
+ // (`EventManager._surfaceAt`).
273
+ this._joinSurfaces();
245
274
  this.gl = wnd.getContext('opengl', config);
246
275
  // a buffer freed by the display is a frame that can be drawn again
247
276
  if (typeof this.gl?.onFrameAvailable !== 'undefined') {
@@ -256,6 +285,9 @@ export class GlAreaNode extends Node {
256
285
  });
257
286
  wnd.on?.('expose', () => this.requestFrame());
258
287
  wnd.map?.();
288
+ // made on top of its siblings — over the panes of children that were
289
+ // laid out and painted before the visual query answered
290
+ this._overlay?.restack();
259
291
  this.requestFrame();
260
292
  }
261
293
 
@@ -385,31 +417,117 @@ export class GlAreaNode extends Node {
385
417
  }
386
418
 
387
419
  /**
388
- * A wheel over the surface, handed to the window the tree lives in.
420
+ * `true`: pointer input over this surface is the tree's on this backend
421
+ * dispatched through the owning window's event manager, at this node, and
422
+ * bubbling from it like anyone else's.
389
423
  *
390
- * ntk reports the position inside *this* window; the manager hit-tests in
391
- * the owning window's space, so the node's own origin goes back on. Both
392
- * are device pixels the scale is applied at the far end, where a handler
393
- * reads `ev.x` (src/events.js).
424
+ * It is here to be asked by an element built on `<glarea>` that listens on
425
+ * `node.window` for the pointer, which it had to do before core delivered
426
+ * it. That listener has to go wherever this is true: on X11 it selects the
427
+ * event on the surface's own window, and X then delivers it *there*
428
+ * instead of to the tree (see `_create`) — every press, and the wheel with
429
+ * them, since both are ButtonPress.
394
430
  *
395
- * Smooth deltas are not part of this yet: XI2 is selected on the window
396
- * the manager owns, not on this child, so a touchpad's fractions arrive
397
- * here as whole notches from buttons 4-7.
431
+ * A getter on the class rather than a flag on each instance, so it can be
432
+ * read without rendering anything: `GlAreaNode.prototype.forwardsPointer`,
433
+ * from `react-x11/node`.
398
434
  */
399
- _onWheel(native) {
400
- const events = this.root?.events;
401
- if (!events || this.destroyed) return;
402
- events._onWheel(
403
- {
404
- ...native,
405
- x: (native.x ?? 0) + this.abs.x,
406
- y: (native.y ?? 0) + this.abs.y,
407
- },
408
- // named rather than hit-tested: a window-owning child is not in its
409
- // parent's paint order, so the hit test would answer with the box
410
- // behind this surface
411
- this,
412
- );
435
+ get forwardsPointer() {
436
+ return true;
437
+ }
438
+
439
+ /**
440
+ * What a point in the owning window's space lands on, if it is over this
441
+ * surface: the child under it where there is one — the children are drawn
442
+ * above the surface (src/gloverlay.js) and otherwise this node.
443
+ *
444
+ * A point that is over the surface is over it whatever the tree's own
445
+ * order says: X stacks the child window above the parent's drawing, and
446
+ * the Cocoa backend puts the layer at a zPosition over both presenters. So
447
+ * the window asks its surfaces before it hit-tests its tree
448
+ * (`EventManager._hit`), and a point inside lands here rather than on the
449
+ * box behind — which is all a tree walk can find, a window-owning node not
450
+ * being in its parent's paint order. It is the same answer X gives: the
451
+ * event it propagates to the owning window names this window, or a pane
452
+ * over it, as the child the pointer is in.
453
+ *
454
+ * The rect is the surface's own, in whole pixels, since the server decides
455
+ * by those. With no GL surface — not made yet, or given up after
456
+ * `onError` — only the children answer, and a point between them is the
457
+ * tree's. Hidden, or `pointerEvents: 'none'` here or above, lets the
458
+ * pointer through to what the tree has behind, as it does for any node.
459
+ */
460
+ hitSurface(x, y) {
461
+ const panes = this._overlay?.panes.length ?? 0;
462
+ if (!this.window && panes === 0) return null;
463
+ const rect = this.rect ?? this._geometry();
464
+ if (
465
+ x < rect.x ||
466
+ y < rect.y ||
467
+ x >= rect.x + rect.width ||
468
+ y >= rect.y + rect.height
469
+ ) {
470
+ return null;
471
+ }
472
+ for (let n = this; n; n = n.parent) {
473
+ if (n.destroyed || n.hidden) return null;
474
+ if (n.style?.display === 'none' || n.style?.pointerEvents === 'none') {
475
+ return null;
476
+ }
477
+ if (n.isWindow) break;
478
+ }
479
+ if (panes !== 0) {
480
+ // front to back, as the tree's own hit test walks a box's children
481
+ const order = this.paintOrder();
482
+ for (let i = order.length - 1; i >= 0; i--) {
483
+ const hit = order[i].hitTest(x, y);
484
+ if (hit) return hit;
485
+ }
486
+ }
487
+ return this.window ? this : null;
488
+ }
489
+
490
+ /** Into the owning window's hit test, once: a GL window or a pane covers
491
+ * the rect now (`EventManager._surfaceAt`). */
492
+ _joinSurfaces() {
493
+ const surfaces = this.root?._surfaces;
494
+ if (surfaces && !surfaces.includes(this)) surfaces.push(this);
495
+ }
496
+
497
+ /** Out of the owning window's hit test: nothing covers the rect any more,
498
+ * and what the tree has behind it answers again. */
499
+ _leaveSurfaces() {
500
+ const surfaces = this.root?._surfaces;
501
+ const at = surfaces ? surfaces.indexOf(this) : -1;
502
+ if (at !== -1) surfaces.splice(at, 1);
503
+ }
504
+
505
+ /**
506
+ * The owning window's frame, after layout (`WindowNode._syncOverlays`):
507
+ * panes for where the children are now. True when a pane was made,
508
+ * resized or dropped — a paint the frame then owes.
509
+ */
510
+ _syncOverlay() {
511
+ if (!this._overlay) {
512
+ if (this.children.length === 0 || !canOverlay(this.app)) {
513
+ this.root?._overlaid?.delete(this);
514
+ return false;
515
+ }
516
+ this._overlay = new GlOverlay(this);
517
+ }
518
+ const changed = this._overlay.sync();
519
+ if (this._overlay.panes.length) this._joinSurfaces();
520
+ if (this.children.length === 0 && this._overlay.panes.length === 0) {
521
+ this._overlay = null;
522
+ this.root?._overlaid?.delete(this);
523
+ if (!this.window) this._leaveSurfaces();
524
+ }
525
+ return changed;
526
+ }
527
+
528
+ /** …and the paint it owes them, with the frame's damage. */
529
+ _paintOverlay(damage) {
530
+ this._overlay?.paint(damage);
413
531
  }
414
532
 
415
533
  applyProps(newProps, oldProps) {
@@ -424,14 +542,27 @@ export class GlAreaNode extends Node {
424
542
  super.setHidden(hidden);
425
543
  if (hidden) this.window?.unmap?.();
426
544
  else this.window?.map?.();
545
+ this._overlay?.setHidden(hidden);
427
546
  }
428
547
 
429
- // the child window covers this rect: nothing to paint into the parent's
430
- // 2d context, and no drawn children are allowed under it
548
+ // The surface covers this rect: nothing of this node is painted into the
549
+ // parent's 2d context, and its children are painted above the surface on
550
+ // panes of their own (src/gloverlay.js) rather than in the window's walk.
431
551
  paint() {}
432
552
 
553
+ // …which is also why they are cut to its box: a pane never reaches past
554
+ // the surface, and the hit test and the damage model have to agree with
555
+ // the panes about where the children can be
556
+ clipsChildren() {
557
+ return true;
558
+ }
559
+
433
560
  destroySubtree() {
434
561
  if (this.destroyed) return;
562
+ this._leaveSurfaces();
563
+ this.root?._overlaid?.delete(this);
564
+ this._overlay?.destroy();
565
+ this._overlay = null;
435
566
  super.destroySubtree();
436
567
  this._pacer.cancel();
437
568
  this.gl?.destroy?.();