react-x11 2.18.2 → 2.18.4

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.18.2",
3
+ "version": "2.18.4",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
package/src/Reconciler.js CHANGED
@@ -67,6 +67,7 @@ import { watchAppearance } from './appearance.js';
67
67
  import { setDesktopIntegration } from './desktopintegration.js';
68
68
  import { ForeignNode } from './foreignnodes.js';
69
69
  import { GlAreaNode } from './glnodes.js';
70
+ import { beginGlOverlay } from './gloverlay.js';
70
71
  import {
71
72
  createRegisteredNode,
72
73
  elementDefinition,
@@ -954,11 +955,17 @@ export async function createRoot(options = {}) {
954
955
  // extension; the scale ladder; the monitor layout and the work-area
955
956
  // property), so they run concurrently: each is internally a chain of one
956
957
  // to three round trips, and awaiting them in sequence made every app pay
957
- // the sum where the slowest chain is the true floor.
958
+ // the sum where the slowest chain is the true floor. The fourth is a
959
+ // single extension query, too short to set that floor: whether this is
960
+ // XQuartz, the X server nothing can be drawn over a GL surface on, which
961
+ // decides whether a `<glarea>`'s children get panes and what
962
+ // `useSupports('glOverlay')` answers on the first render
963
+ // (src/gloverlay.js).
958
964
  await Promise.all([
959
965
  beginCompositing(app),
960
966
  beginScale(app, rest.scale),
961
967
  beginScreens(app),
968
+ beginGlOverlay(app),
962
969
  ]);
963
970
 
964
971
  // How fast a caret blinks, how long a double click has, how far a press
package/src/appcontext.js CHANGED
@@ -60,9 +60,10 @@ export function useApp() {
60
60
  }
61
61
 
62
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.
63
+ // backend — and 'glOverlay' of the X server too, which `createRoot` asks
64
+ // before the first render (src/gloverlay.js) — decided before anything
65
+ // renders and never changing after, so their subscription has nothing to
66
+ // deliver and their snapshot is a property test.
66
67
  const NEVER_CHANGES = () => () => {};
67
68
 
68
69
  // What `useSupports` watches and reads, per feature. `read` answers a
@@ -169,7 +170,10 @@ const FEATURES = {
169
170
  * GL surface on this connection — laid out in its box, painted on panes over
170
171
  * the surface, hit before it. Both backends draw them; what differs is
171
172
  * translucency, composited by Core Animation on the Cocoa backend and opaque
172
- * on X11 (docs/elements.md says exactly how). It is the question to ask
173
+ * on X11 (docs/elements.md says exactly how). The exception is XQuartz,
174
+ * where it is false: the macOS window server composites every GL surface
175
+ * above everything the X server draws, so nothing can be drawn over one, and
176
+ * the children are laid out but never drawn. It is the question to ask
173
177
  * before handing a surface its HUD rather than drawing that some other way:
174
178
  *
175
179
  * ```jsx
@@ -177,7 +181,8 @@ const FEATURES = {
177
181
  * <glarea onDraw={drawMap}>{overlay && <Legend />}</glarea>
178
182
  * ```
179
183
  *
180
- * A property of the backend too, and it never changes.
184
+ * A property of the backend and the display, settled before the first
185
+ * render, and it never changes.
181
186
  */
182
187
  export function useSupports(feature) {
183
188
  const app = useApp();
@@ -11,9 +11,10 @@
11
11
  // so where two surfaces overlap, both overlays are above both frames.
12
12
  //
13
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.
14
+ // `map`, `unmap`, `getContext`, `scrollRegion`, `destroy` — plus `present`,
15
+ // which puts what was painted on the layer: ntk blits an X window's backing
16
+ // store on its own, where a layer's contents are a copy the bitmap has to be
17
+ // pushed to.
17
18
  import { BackendContext2D } from '../backend/context2d.js';
18
19
  import { withoutActions } from './quiet.js';
19
20
 
@@ -135,6 +136,42 @@ export class CocoaOverlayPane {
135
136
  return this._ctx;
136
137
  }
137
138
 
139
+ /**
140
+ * Move the pixels of `rect` (pane coordinates, device px) by (dx, dy)
141
+ * inside the bitmap — ntk `Window.scrollRegion`'s contract, which the
142
+ * overlay speaks to either backend: the band that survives the shift
143
+ * inside `rect` moves, the rest of it is left as it was, and false means
144
+ * nothing moved, so the caller repaints instead. A child that only moved
145
+ * is one of these rather than a repaint (src/gloverlay.js, issue #644).
146
+ *
147
+ * The bitmap is the pane's own and the layer holds a copy of it, so the
148
+ * move lands on screen with the frame's `present`, after the strips it
149
+ * uncovered are painted — never halfway.
150
+ */
151
+ scrollRegion(rect, dx, dy) {
152
+ if (this.destroyed || !this._surface) return false;
153
+ if (!Number.isInteger(dx) || !Number.isInteger(dy)) return false;
154
+ // a bitmap of another size is one the next paint replaces, whole
155
+ const size = this._surfaceSize;
156
+ if (
157
+ size.width !== Math.max(1, this.rect.width) ||
158
+ size.height !== Math.max(1, this.rect.height)
159
+ ) {
160
+ return false;
161
+ }
162
+ const moved = this._native.scrollSurface(
163
+ this._surface,
164
+ Math.round(rect.x),
165
+ Math.round(rect.y),
166
+ Math.round(rect.width),
167
+ Math.round(rect.height),
168
+ dx,
169
+ dy,
170
+ );
171
+ if (moved) this._dirty = true;
172
+ return Boolean(moved);
173
+ }
174
+
138
175
  /** What was painted, onto the layer — a copy, so the bitmap is free to be
139
176
  * painted again at once. */
140
177
  present() {
package/src/glnodes.js CHANGED
@@ -10,8 +10,10 @@ 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 { GlOverlay, canOverlay } from './gloverlay.js';
13
+ import { GlOverlay, canOverlay, overlayRefusal } from './gloverlay.js';
14
+ import { layoutDiff } from './nodes/damage.js';
14
15
  import { Node } from './nodes/node.js';
16
+ import { DEV } from './nodes/util.js';
15
17
  import { FramePacer, resolveFrameRate } from './pacing.js';
16
18
 
17
19
  // One visual query per (app, spec): GetFBConfigs is a round trip and every
@@ -36,6 +38,27 @@ function recordGlxFailure(app, err) {
36
38
  if (app && err && !glxFailures.has(app)) glxFailures.set(app, err);
37
39
  }
38
40
 
41
+ // Apps already told that a <glarea>'s children are not drawn on their
42
+ // display. Once per app, as for a <foreign> that cannot embed: the answer
43
+ // belongs to the server, so the next surface to find out carries no news.
44
+ const warnedNoOverlay = new WeakSet();
45
+
46
+ /**
47
+ * Say, once per app and in development only, that the children of a
48
+ * `<glarea>` are not drawn here. They lay out and render like anyone's and
49
+ * nothing fails, so without this a HUD that never shows looks like a bug in
50
+ * the HUD.
51
+ */
52
+ function warnNoOverlay(app) {
53
+ if (!DEV || !app || warnedNoOverlay.has(app)) return;
54
+ warnedNoOverlay.add(app);
55
+ const why = overlayRefusal(app);
56
+ console.warn(
57
+ 'react-x11: the children of a <glarea> are not drawn on this connection, ' +
58
+ `and useSupports('glOverlay') is false${why ? ` — ${why}` : ''}.`,
59
+ );
60
+ }
61
+
39
62
  /**
40
63
  * The visual and depth to create the GL child window with.
41
64
  *
@@ -120,7 +143,9 @@ const px = (v) => Math.max(1, Math.round(v || 0));
120
143
  * The X child window is stacked above everything drawn in the parent, so the
121
144
  * parent's 2D content cannot overlap it — but this node's own children do:
122
145
  * they are laid out in its box like a `<box>`'s and drawn above the surface,
123
- * on panes of their own (src/gloverlay.js).
146
+ * on panes of their own (src/gloverlay.js). Everywhere but XQuartz, where
147
+ * nothing can be drawn over a GL surface: there they are laid out and not
148
+ * drawn (`canOverlay`).
124
149
  *
125
150
  * Pointer input over the surface is the tree's, on both backends: a press,
126
151
  * a drag or a wheel over it is a synthetic event at the child under the
@@ -320,11 +345,102 @@ export class GlAreaNode extends Node {
320
345
  };
321
346
  }
322
347
 
348
+ /**
349
+ * `Node.absolutize`, with the children walked one at a time where a pane
350
+ * holds them — the frame's layout diff running (a bounded frame), no
351
+ * scroll shift riding over this node, no layout algorithm of its own
352
+ * placing them, and this node's own rect where it was, so the panes are
353
+ * too. A child that moved and changed nothing else is then reported to
354
+ * the overlay instead of claimed (`_absolutizeChild`); anywhere else the
355
+ * walk is the plain one.
356
+ */
323
357
  absolutize(originX, originY) {
324
- super.absolutize(originX, originY);
358
+ const overlay = this._overlay;
359
+ if (
360
+ !overlay?.panes.length ||
361
+ !layoutDiff.sink ||
362
+ layoutDiff.shift ||
363
+ this._host !== null ||
364
+ !this.yoga
365
+ ) {
366
+ super.absolutize(originX, originY);
367
+ this._syncGeometry();
368
+ return;
369
+ }
370
+ this._placed = true;
371
+ const was = this.abs;
372
+ this._assignAbs(
373
+ originX + this.yoga.getComputedLeft(),
374
+ originY + this.yoga.getComputedTop(),
375
+ this.yoga.getComputedWidth(),
376
+ this.yoga.getComputedHeight(),
377
+ );
378
+ if (this.props.onLayout) this._reportLayout();
379
+ // `_assignAbs` keeps the object when nothing changed
380
+ const still = this.abs === was;
381
+ const { x, y } = this.abs;
382
+ for (const child of this.children) {
383
+ if (child.isWindow) continue;
384
+ if (still) this._absolutizeChild(child, x, y, overlay);
385
+ else child.absolutize(x, y);
386
+ }
325
387
  this._syncGeometry();
326
388
  }
327
389
 
390
+ /**
391
+ * One child, laid out from (originX, originY). When all that changed is
392
+ * where it sits — the same size, somewhere else, a plain box drawn where
393
+ * layout put it — it is walked with the layout diff's `shift` set to the
394
+ * move (issue #644): every descendant that lands where it was plus the
395
+ * move claims nothing, exactly as under a scroll blit (issue #398), and
396
+ * one that lands anywhere else claims both ends. The child itself is one
397
+ * of the first kind, so the move claims nothing at all here; the overlay
398
+ * is told of it instead, with the reach its pixels had, and settles it
399
+ * after the panes are placed (`GlOverlay.settleMoves`) — moved on the
400
+ * pane, or claimed where it was and where it is.
401
+ *
402
+ * `layoutDiff.ride` marks the walk as this kind, for the one node that
403
+ * drops the diff under a shift: a scroll pane laid out again claims its
404
+ * own box there, since the move claimed nothing for it
405
+ * (nodes/scrollable.js).
406
+ */
407
+ _absolutizeChild(child, originX, originY, overlay) {
408
+ const y = child.yoga;
409
+ const old = child.abs;
410
+ if (
411
+ !y ||
412
+ child.hidden ||
413
+ child.style?.display === 'none' ||
414
+ child.isScroller?.() ||
415
+ !(old.width > 0 && old.height > 0) ||
416
+ this.root?._placedNodes?.has(child)
417
+ ) {
418
+ child.absolutize(originX, originY);
419
+ return;
420
+ }
421
+ const dx = originX + y.getComputedLeft() - old.x;
422
+ const dy = originY + y.getComputedTop() - old.y;
423
+ if (
424
+ (dx === 0 && dy === 0) ||
425
+ y.getComputedWidth() !== old.width ||
426
+ y.getComputedHeight() !== old.height
427
+ ) {
428
+ child.absolutize(originX, originY);
429
+ return;
430
+ }
431
+ // before it moves: every pixel of it a pane holds
432
+ const reach = child.paintBounds();
433
+ layoutDiff.shift = { x: dx, y: dy };
434
+ layoutDiff.ride = true;
435
+ try {
436
+ child.absolutize(originX, originY);
437
+ } finally {
438
+ layoutDiff.shift = null;
439
+ layoutDiff.ride = false;
440
+ }
441
+ overlay.noteMove(child, reach, dx, dy);
442
+ }
443
+
328
444
  // the scroll fast path moves `abs` without coming through absolutize
329
445
  // (issue #405), and the real X window has to follow it all the same
330
446
  _shiftAbs(dx, dy) {
@@ -537,18 +653,28 @@ export class GlAreaNode extends Node {
537
653
 
538
654
  /**
539
655
  * The owning window's frame, after layout (`WindowNode._syncOverlays`):
540
- * panes for where the children are now. True when a pane was made,
541
- * resized or dropped — a paint the frame then owes.
656
+ * panes for where the children are now, and the moves the layout pass
657
+ * reported settled against them — moved on a pane, or claimed. True when
658
+ * a pane was made, resized or dropped, or a move claimed something — a
659
+ * paint the frame then owes.
542
660
  */
543
661
  _syncOverlay() {
544
662
  if (!this._overlay) {
545
- if (this.children.length === 0 || !canOverlay(this.app)) {
663
+ // Where nothing can be drawn over the surface the children get no
664
+ // panes at all, rather than panes nobody sees: on XQuartz a pane over
665
+ // the whole surface is what hides the GL frame (src/gloverlay.js). So
666
+ // they are laid out and never drawn, and the pointer over them is the
667
+ // surface's.
668
+ const refused = this.children.length !== 0 && !canOverlay(this.app);
669
+ if (refused) warnNoOverlay(this.app);
670
+ if (this.children.length === 0 || refused) {
546
671
  this.root?._overlaid?.delete(this);
547
672
  return false;
548
673
  }
549
674
  this._overlay = new GlOverlay(this);
550
675
  }
551
- const changed = this._overlay.sync();
676
+ let changed = this._overlay.sync();
677
+ if (this._overlay.settleMoves(this.root)) changed = true;
552
678
  if (this._overlay.panes.length) this._joinSurfaces();
553
679
  if (this.children.length === 0 && this._overlay.panes.length === 0) {
554
680
  this._overlay = null;
package/src/globalmenu.js CHANGED
@@ -108,6 +108,13 @@ const NO_AUTO_START = 2;
108
108
  */
109
109
  const menuPathFor = (xid) => `/com/react_x11/menus/${xid}`;
110
110
 
111
+ /**
112
+ * Per window, the moment it is free: settles once every exporter that has
113
+ * published on it so far has stopped. The next one to publish there waits for
114
+ * it — see `GlobalMenuExport.takeTurn()`.
115
+ */
116
+ const vacancies = new Map();
117
+
111
118
  /**
112
119
  * The feature's off switch.
113
120
  *
@@ -167,6 +174,20 @@ export class GlobalMenuExport extends DbusMenuExport {
167
174
  this.path = null;
168
175
  this.xid = null;
169
176
  this.window = null;
177
+ /** The windows this exporter has queued for, by xid. Each is held until
178
+ * `stop()`, so publishing on one again does not queue behind itself —
179
+ * see `takeTurn()`. */
180
+ this.claimed = new Set();
181
+ /** Settles when `stop()` is called, `stopped` as a promise, so a publish
182
+ * still waiting for its window stops waiting. */
183
+ this.halted = new Promise((resolve) => {
184
+ this.halt = resolve;
185
+ });
186
+ /** Settles once `stop()` has finished, which is what the next exporter on
187
+ * a window this one claimed waits for — see `takeTurn()`. */
188
+ this.gone = new Promise((resolve) => {
189
+ this.markGone = resolve;
190
+ });
170
191
  }
171
192
 
172
193
  // ------------------------------------------------------------------ setup
@@ -281,6 +302,21 @@ export class GlobalMenuExport extends DbusMenuExport {
281
302
  // call" case rather than the "too early" one.
282
303
  if (!xid) return;
283
304
 
305
+ // One menu per window at a time — see `takeTurn()`.
306
+ const before = this.takeTurn(xid);
307
+ if (before) {
308
+ // Given up if this one is stopped meanwhile. That frees the window no
309
+ // sooner: whoever is next in line waits for `before` as well.
310
+ await Promise.race([before, this.halted]);
311
+ // How long that took is up to the menu ahead, and for a second bar in
312
+ // the window it is that bar's whole mount. So what `_sync()` decided
313
+ // before it is stale, and it decides again. A panel that went meanwhile
314
+ // would otherwise get a RegisterWindow sent to nobody, and the error
315
+ // ends `start()` in a teardown: a menu that never reaches the next
316
+ // panel either.
317
+ return this._sync();
318
+ }
319
+
284
320
  const dbus = await loadTransport();
285
321
  const path = menuPathFor(xid);
286
322
  if (!dbus.isValidObjectPath(path)) return;
@@ -320,6 +356,49 @@ export class GlobalMenuExport extends DbusMenuExport {
320
356
  this.onChange(true);
321
357
  }
322
358
 
359
+ /**
360
+ * Queue for a window, the first time this exporter publishes on it, and
361
+ * hand back what to wait for: the moment every exporter that published
362
+ * there before this one has stopped, or `null` when there is nothing to
363
+ * wait for.
364
+ *
365
+ * **One menu per window at a time.** Everything a menu holds is per window:
366
+ * the object at `menuPathFor(xid)`, the registrar's entry for the xid, and
367
+ * the two X properties. A `<MenuBar>` remounted in its window, or one with
368
+ * `globalMenu` switched off and on, puts a new exporter on all three while
369
+ * the old one is still leaving — its teardown asks `ListNames` and waits
370
+ * for `UnregisterWindow` before it unexports. That unexport is
371
+ * dbus-native's `registration.remove()`, which removes whatever is at the
372
+ * path when it runs, whoever exported it. So the new menu lost its object
373
+ * while the registrar and the properties still named it, and its bar,
374
+ * told `exported`, drew nothing: no menu anywhere. The old one's
375
+ * unregistration and property deletes race the new one's registration and
376
+ * property writes the same way. They happened to land first, which is
377
+ * harmless, but nothing made them, so the whole publish waits and not only
378
+ * the export. A second bar in a window that already has one waits too: it
379
+ * stays drawn in the window until the first one goes, rather than
380
+ * displacing it.
381
+ *
382
+ * Every one before it, not only the last: a menu stopped while it was
383
+ * still waiting is gone at once, and the one it was waiting for may not
384
+ * be.
385
+ *
386
+ * A window is held until this exporter stops, not only while it is
387
+ * published, so a panel that restarts gets the menu back from the same
388
+ * exporter without it queueing behind itself.
389
+ */
390
+ takeTurn(xid) {
391
+ if (this.claimed.has(xid)) return null;
392
+ this.claimed.add(xid);
393
+ const before = vacancies.get(xid);
394
+ const vacated = Promise.all([before, this.gone]);
395
+ vacancies.set(xid, vacated);
396
+ void vacated.then(() => {
397
+ if (vacancies.get(xid) === vacated) vacancies.delete(xid);
398
+ });
399
+ return before ?? null;
400
+ }
401
+
323
402
  /**
324
403
  * Plasma reads these instead of asking the registrar.
325
404
  *
@@ -390,15 +469,24 @@ export class GlobalMenuExport extends DbusMenuExport {
390
469
  async stop() {
391
470
  if (this.stopped) return;
392
471
  // Set first, so a publish already in flight sees it and undoes itself at
393
- // its next checkpoint rather than finishing.
472
+ // its next checkpoint rather than finishing, and one still waiting for
473
+ // its window stops waiting.
394
474
  this.stopped = true;
395
- // Then wait for it. `teardown()` reads `registration`, `xid` and `window`
396
- // across several awaits of its own, so running it *beside* a publish lets
397
- // each undo the other's work: the classic end state is `exported === true`
398
- // with the object unexported and the window unregistered — no bar in the
399
- // window, nothing in the panel, and no event left that could recover it.
400
- await this.syncing?.catch(() => {});
401
- await this.teardown();
475
+ this.halt();
476
+ try {
477
+ // Then wait for it. `teardown()` reads `registration`, `xid` and
478
+ // `window` across several awaits of its own, so running it *beside* a
479
+ // publish lets each undo the other's work: the classic end state is
480
+ // `exported === true` with the object unexported and the window
481
+ // unregistered — no bar in the window, nothing in the panel, and no
482
+ // event left that could recover it.
483
+ await this.syncing?.catch(() => {});
484
+ await this.teardown();
485
+ } finally {
486
+ // Whatever happened, the window is free: the next menu on it would
487
+ // otherwise wait for good, and never reach the panel.
488
+ this.markGone();
489
+ }
402
490
  }
403
491
 
404
492
  async teardown() {
@@ -427,6 +515,14 @@ export class GlobalMenuExport extends DbusMenuExport {
427
515
  }
428
516
  }
429
517
 
518
+ /** Test seam, not public: forget which windows are held. A previous test's
519
+ * menus are on a bus it has closed, so there is nothing to wait for, and one
520
+ * a failed test never stopped would otherwise hold its window for the rest
521
+ * of the run — every mock app numbers its first window alike. */
522
+ export function _resetGlobalMenuState() {
523
+ vacancies.clear();
524
+ }
525
+
430
526
  /**
431
527
  * Is this window's menu bar being drawn by the desktop?
432
528
  *