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/src/gloverlay.js CHANGED
@@ -35,30 +35,122 @@
35
35
  // node-x11's in-process server, where this is tested, has none — and a
36
36
  // window as big as the surface would keep a backing pixmap the size of a
37
37
  // map to show a legend in its corner.
38
+ // - **XQuartz: no, and not opaquely either.** Every GL surface there, the
39
+ // direct ones Apple-DRI exports and indirect GLX's alike, is a surface of
40
+ // the macOS window server, composited above everything the X server draws
41
+ // in the window (hw/xquartz/xpr/dri.c makes it `XP_MAPPED_ABOVE`). X's
42
+ // stacking never reaches it: a pane stacked over the surface's window is
43
+ // drawn under the frame, cutting that window down with SHAPE leaves the
44
+ // frame covering all of it, and only a pane over the whole surface shows —
45
+ // by hiding the frame (issue #653). So no pane is made there, and
46
+ // `canOverlay` says so from the first render on (`beginGlOverlay`).
38
47
  //
39
48
  // A pane selects no input, so the pointer over one reaches the owning window
40
49
  // by the same propagation that brings it the pointer over the surface
41
50
  // (src/glnodes.js, `_create`), and lands on the child it is over.
42
51
  //
52
+ // A pane keeps what it holds between frames, so a child that only moved is
53
+ // already painted, one shift away (issue #644). The layout pass reports such
54
+ // a child instead of claiming it (`GlAreaNode._absolutizeChild`), and the
55
+ // frame moves its pixels inside the pane — `scrollRegion`, the verb the
56
+ // scroll blit uses on a window — and repaints only what the move uncovered
57
+ // and what it now overlaps differently (`settleMoves`). A pan over a
58
+ // graph's mounted node bodies is that frame, sixty times a second.
59
+ //
43
60
  // A leaf module, like src/embedding.js: `appcontext.js` asks `canOverlay`
44
- // for `useSupports('glOverlay')`, and it imports nothing of ours.
61
+ // for `useSupports('glOverlay')`, and it imports nothing of ours but the
62
+ // damage model's arithmetic, which imports nothing at all.
45
63
  import { cssColorStraight } from 'ntk';
46
64
 
65
+ import { FULL_DAMAGE, addDamageRect } from './nodes/damage.js';
66
+ import {
67
+ innerPixels,
68
+ intersectRects,
69
+ rectArea,
70
+ rectContains,
71
+ unionRect,
72
+ } from './nodes/rects.js';
73
+
47
74
  // ConfigureWindow's stack mode: directly above the sibling it names
48
75
  const STACK_ABOVE = 0;
49
76
 
77
+ // Past this share of the pixels a move carries, the frame would repaint
78
+ // most of them anyway — around what it claims itself, or around the other
79
+ // children the copy reached — and the copy only adds to the bill.
80
+ const MOVE_BLIT_MAX_REPAINT = 0.75;
81
+
82
+ // The scroll blit's escape hatch (src/nodes/scrollblit.js), read the same
83
+ // way: every path that moves retained pixels instead of repainting them is
84
+ // one variable away from the plain repaint (docs/debugging.md).
85
+ const NO_BLIT = process.env.REACT_X11_NO_SCROLL_BLIT === '1';
86
+
87
+ // Connections on which nothing can be drawn over a GL surface, each with the
88
+ // reason (`beginGlOverlay`), and the probe that finds out, once per app.
89
+ const refusals = new WeakMap();
90
+ const probes = new WeakMap();
91
+
92
+ const XQUARTZ =
93
+ 'this display is XQuartz, where the macOS window server composites every ' +
94
+ 'GL surface above everything the X server draws in its window. Draw the ' +
95
+ 'overlay beside the surface or in GL instead, or run on the Cocoa backend ' +
96
+ '(the default on macOS), which composites it';
97
+
50
98
  /**
51
99
  * Whether the children of a `<glarea>` can be drawn over its surface on this
52
100
  * connection: a backend that composites a pane itself, or one that can make
53
- * the plain child window a pane is on X11. One function for the element and
54
- * for `useSupports('glOverlay')`, which have to agree — the rule `canEmbed`
55
- * follows for `<foreign>`.
101
+ * the plain child window a pane is on X11 — on any server but XQuartz, where
102
+ * nothing can be drawn over a GL surface (`beginGlOverlay`). One function
103
+ * for the element and for `useSupports('glOverlay')`, which have to agree —
104
+ * the rule `canEmbed` follows for `<foreign>`.
56
105
  */
57
106
  export function canOverlay(app) {
58
- return (
59
- typeof app?.createOverlayPane === 'function' ||
60
- typeof app?.createWindow === 'function'
61
- );
107
+ if (typeof app?.createOverlayPane === 'function') return true;
108
+ return typeof app?.createWindow === 'function' && !refusals.has(app);
109
+ }
110
+
111
+ /** Why nothing is drawn over a GL surface on this connection, for the
112
+ * warning a `<glarea>` with children gives there — or null. */
113
+ export function overlayRefusal(app) {
114
+ return (app && refusals.get(app)) ?? null;
115
+ }
116
+
117
+ /**
118
+ * Is this an X server where nothing can be drawn over a GL surface? Asked
119
+ * in `createRoot`, with the other startup probes and before the first
120
+ * render, so that `canOverlay` answers the same from the first frame on —
121
+ * a component choosing how to draw by it would otherwise build one way and
122
+ * tear that down a frame later.
123
+ *
124
+ * The server is XQuartz, and the question is its Apple-DRI extension, which
125
+ * no other server has: every GL surface XQuartz makes comes from the
126
+ * machinery that extension exports, direct and indirect alike. Asked of the
127
+ * server rather than of `app.glCapabilities()`, because the default policy
128
+ * never probes direct rendering and XQuartz's indirect GLX hides the panes
129
+ * all the same. One QueryExtension; where ntk's direct-rendering probe has
130
+ * asked already, node-x11 answers it from the reply it kept.
131
+ */
132
+ export function beginGlOverlay(app) {
133
+ if (!app) return Promise.resolve();
134
+ let pending = probes.get(app);
135
+ if (pending) return pending;
136
+ const X = app.X;
137
+ pending =
138
+ typeof app.createOverlayPane === 'function' ||
139
+ typeof X?.QueryExtension !== 'function'
140
+ ? Promise.resolve()
141
+ : new Promise((resolve) => {
142
+ try {
143
+ X.QueryExtension('Apple-DRI', (err, reply) => {
144
+ if (!err && reply?.present) refusals.set(app, XQUARTZ);
145
+ resolve();
146
+ });
147
+ } catch {
148
+ // a connection that cannot ask is one this cannot find out about
149
+ resolve();
150
+ }
151
+ });
152
+ probes.set(app, pending);
153
+ return pending;
62
154
  }
63
155
 
64
156
  /** A rect grown out to whole device pixels. */
@@ -73,16 +165,6 @@ function whole(r) {
73
165
  };
74
166
  }
75
167
 
76
- function intersect(a, b) {
77
- const x = Math.max(a.x, b.x);
78
- const y = Math.max(a.y, b.y);
79
- const right = Math.min(a.x + a.width, b.x + b.width);
80
- const bottom = Math.min(a.y + a.height, b.y + b.height);
81
- return right > x && bottom > y
82
- ? { x, y, width: right - x, height: bottom - y }
83
- : null;
84
- }
85
-
86
168
  const overlaps = (a, b) =>
87
169
  a.x < b.x + b.width &&
88
170
  b.x < a.x + a.width &&
@@ -103,6 +185,48 @@ function around(a, b) {
103
185
  const sameRect = (a, b) =>
104
186
  a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
105
187
 
188
+ const shifted = (r, dx, dy) => ({
189
+ x: r.x + dx,
190
+ y: r.y + dy,
191
+ width: r.width,
192
+ height: r.height,
193
+ });
194
+
195
+ /** `a` with `b` taken out of it: up to four rects — the full-width bands
196
+ * above and below `b`, and the pieces either side of it between them. */
197
+ function subtractRect(a, b) {
198
+ const cut = intersectRects(a, b);
199
+ if (!cut) return [a];
200
+ const out = [];
201
+ const right = a.x + a.width;
202
+ const bottom = a.y + a.height;
203
+ const cutRight = cut.x + cut.width;
204
+ const cutBottom = cut.y + cut.height;
205
+ if (cut.y > a.y) {
206
+ out.push({ x: a.x, y: a.y, width: a.width, height: cut.y - a.y });
207
+ }
208
+ if (cutBottom < bottom) {
209
+ out.push({
210
+ x: a.x,
211
+ y: cutBottom,
212
+ width: a.width,
213
+ height: bottom - cutBottom,
214
+ });
215
+ }
216
+ if (cut.x > a.x) {
217
+ out.push({ x: a.x, y: cut.y, width: cut.x - a.x, height: cut.height });
218
+ }
219
+ if (cutRight < right) {
220
+ out.push({
221
+ x: cutRight,
222
+ y: cut.y,
223
+ width: right - cutRight,
224
+ height: cut.height,
225
+ });
226
+ }
227
+ return out;
228
+ }
229
+
106
230
  /** On screen: nothing from here to the window hidden or `display: 'none'`. */
107
231
  function shown(node) {
108
232
  for (let n = node; n; n = n.parent) {
@@ -125,7 +249,7 @@ function shown(node) {
125
249
  export function overlayRegions(area, surface) {
126
250
  const rects = [];
127
251
  for (const child of area.paintOrder()) {
128
- const reach = intersect(whole(child._subtreeBounds()), surface);
252
+ const reach = intersectRects(whole(child._subtreeBounds()), surface);
129
253
  if (reach) rects.push(reach);
130
254
  }
131
255
  for (let merged = true; merged;) {
@@ -172,6 +296,13 @@ class Pane {
172
296
  this.transparent = transparent;
173
297
  this.full = true;
174
298
  this.ctx = null;
299
+ // placed this frame: what the pane holds is where the children were in
300
+ // the pane's own corner, not in the window, so nothing in it is a known
301
+ // shift away from where it goes
302
+ this.placed = false;
303
+ // moved pixels this frame, which has to reach the screen even when no
304
+ // pass follows it (`GlOverlay.paint`)
305
+ this.blitted = false;
175
306
  }
176
307
 
177
308
  context() {
@@ -188,7 +319,12 @@ class Pane {
188
319
  if (rect.width !== this.rect.width || rect.height !== this.rect.height) {
189
320
  this.full = true;
190
321
  }
322
+ // pixels a scroll blit moved this frame (nodes/scrollblit.js, which runs
323
+ // before `sync`) were moved in the pane's own corner, and the pane has
324
+ // since gone somewhere else
325
+ if (this.blitted) this.full = true;
191
326
  this.rect = rect;
327
+ this.placed = true;
192
328
  if (typeof this.wnd.setState === 'function') this.wnd.setState(rect);
193
329
  else {
194
330
  this.wnd.move?.(rect.x, rect.y);
@@ -196,6 +332,37 @@ class Pane {
196
332
  }
197
333
  }
198
334
 
335
+ /** Can the pixels of `rect`, in window coordinates, move inside this
336
+ * pane? Not when it is owed a paint whole or was placed this frame, and
337
+ * not where the backend has no verb for it. */
338
+ canBlit(rect) {
339
+ return (
340
+ !this.full &&
341
+ !this.placed &&
342
+ typeof this.wnd.scrollRegion === 'function' &&
343
+ rectContains(this.rect, rect)
344
+ );
345
+ }
346
+
347
+ /**
348
+ * Move the pixels of `rect` — window coordinates — by (dx, dy) inside the
349
+ * pane, with the window verb's contract (ntk `Window.scrollRegion`, and
350
+ * the Cocoa pane's own): the band that survives inside `rect` moves, the
351
+ * rest of it is left as it was. False when the backend could not, which
352
+ * leaves the pane exactly as it was.
353
+ */
354
+ blit(rect, dx, dy) {
355
+ const local = {
356
+ x: rect.x - this.rect.x,
357
+ y: rect.y - this.rect.y,
358
+ width: rect.width,
359
+ height: rect.height,
360
+ };
361
+ if (!this.wnd.scrollRegion(local, dx, dy)) return false;
362
+ this.blitted = true;
363
+ return true;
364
+ }
365
+
199
366
  show(on) {
200
367
  if (on) this.wnd.map?.();
201
368
  else this.wnd.unmap?.();
@@ -215,6 +382,10 @@ export class GlOverlay {
215
382
  // one pane over the whole surface, where the backend composites it
216
383
  this.composited = typeof this.app?.createOverlayPane === 'function';
217
384
  this.panes = [];
385
+ // the children this frame's layout moved and nothing else, reported
386
+ // rather than claimed (`GlAreaNode._absolutizeChild`) and settled after
387
+ // the panes are (`settleMoves`)
388
+ this.moves = [];
218
389
  }
219
390
 
220
391
  /**
@@ -229,6 +400,7 @@ export class GlOverlay {
229
400
  sync() {
230
401
  const area = this.area;
231
402
  const abs = area.abs;
403
+ for (const pane of this.panes) pane.placed = false;
232
404
  let rects = [];
233
405
  if (shown(area) && abs.width > 0 && abs.height > 0) {
234
406
  // the surface's own rect, rounded the way its window's is
@@ -279,7 +451,7 @@ export class GlOverlay {
279
451
  pane.full = true;
280
452
  const root = this.area.root;
281
453
  if (root && !root.destroyed)
282
- root.invalidate(false, { ...pane.rect }, 'expose');
454
+ root.invalidate(false, { ...pane.rect }, 'expose', this.area);
283
455
  }
284
456
 
285
457
  /**
@@ -305,6 +477,145 @@ export class GlOverlay {
305
477
  }
306
478
  }
307
479
 
480
+ /**
481
+ * A child the layout pass moved and nothing else — every descendant
482
+ * landed where it was plus (dx, dy) or claimed where it did not — and
483
+ * claimed nothing for (`GlAreaNode._absolutizeChild`). `reach` is its
484
+ * `paintBounds()` from before the move: every pixel of it the panes hold.
485
+ */
486
+ noteMove(node, reach, dx, dy) {
487
+ this.moves.push({ node, reach, dx, dy });
488
+ }
489
+
490
+ /**
491
+ * The frame's moves, settled after `sync` has put the panes where the
492
+ * children are and before the frame takes its damage
493
+ * (`WindowNode._syncOverlays`). The one covering the most of the surface
494
+ * moves its pixels on its pane where that is safe and saves the repaint
495
+ * (`_blitMove`); every other one, and that one when not, is claimed where
496
+ * it was and where it is — the pixels the layout diff would have claimed
497
+ * for it and every descendant. True when there was anything to settle.
498
+ *
499
+ * The claims go to the panes' damage alone (`WindowNode._paneDamage`):
500
+ * the window paints none of a surface's children, and its pass under the
501
+ * surface is one nobody sees.
502
+ */
503
+ settleMoves(root) {
504
+ const moves = this.moves;
505
+ if (moves.length === 0) return false;
506
+ this.moves = [];
507
+ // the panes repaint whole this frame: nothing is owed
508
+ if (!root || root.destroyed || root._paneDamage === FULL_DAMAGE) {
509
+ return true;
510
+ }
511
+ const surface = this.area._geometry();
512
+ let best = null;
513
+ let most = 0;
514
+ for (const move of moves) {
515
+ const shown = intersectRects(whole(move.reach), surface);
516
+ const size = shown ? rectArea(shown) : 0;
517
+ if (size > most) {
518
+ best = move;
519
+ most = size;
520
+ }
521
+ }
522
+ // the others first: the pixels they claim are ones the copy may carry,
523
+ // and `_blitMove` repairs whatever the frame claims by then
524
+ for (const move of moves) if (move !== best) this._claimMove(root, move);
525
+ if (best && !this._blitMove(root, best)) this._claimMove(root, best);
526
+ return true;
527
+ }
528
+
529
+ /** Where a move's child was and where it is, claimed the ordinary way —
530
+ * cut to the surface's box, which clips the children (`clipsChildren`). */
531
+ _claimMove(root, { node, reach }) {
532
+ const box = this.area.abs;
533
+ for (const rect of [reach, node.paintBounds()]) {
534
+ const claim = intersectRects(rect, box);
535
+ if (claim) root._addPaneDamage(claim);
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Move a child's pixels on its pane, and narrow what the frame owes the
541
+ * move to what the copy cannot supply. True when it did; false leaves
542
+ * the pane and the frame's damage as they were, for `_claimMove`.
543
+ *
544
+ * The copy is of what the child showed, moved as far as the child moved
545
+ * and kept where it is still on show (`dest`). That is right wherever
546
+ * the child's own drawing is the only thing that changed — and the frame
547
+ * repaints the rest:
548
+ *
549
+ * - what the child uncovered, and whatever of its new reach the copy did
550
+ * not land on: its reach before and after, less `dest`;
551
+ * - the other children: the pixels of theirs the copy carried off with
552
+ * the child's, and theirs that the copy covered — over the child or
553
+ * under it, their own pixels are not a shift away;
554
+ * - what the panes are owed already, where the copy would carry it: a
555
+ * claim is often a rect named before layout ran, and when it was
556
+ * inside the child its content went with the copy. Repainted shifted
557
+ * as well as where it stands, which is right whichever it was. A
558
+ * claim that cannot reach the panes is not in their list
559
+ * (`WindowNode._paneReach`) — a graph pane under the surface that
560
+ * claims itself whole on every step of the pan this copy is.
561
+ *
562
+ * Past `MOVE_BLIT_MAX_REPAINT` of `dest` those repaint most of what the
563
+ * copy saves, and the plain claims have the frame.
564
+ */
565
+ _blitMove(root, { node, reach, dx, dy }) {
566
+ if (NO_BLIT || !Number.isInteger(dx) || !Number.isInteger(dy)) {
567
+ return false;
568
+ }
569
+ const area = this.area;
570
+ // a rounded surface clips its children to an arc, and the arc stays put
571
+ if ((area.style?.borderRadius ?? 0) > 0) return false;
572
+ // the whole pixels of the surface's box: a fractional edge is an
573
+ // antialiased clip, and that does not move either
574
+ const box = innerPixels(area.abs);
575
+ if (!box) return false;
576
+ const was = intersectRects(whole(reach), box);
577
+ const now = intersectRects(whole(node.paintBounds()), box);
578
+ // nothing of it was on show, so there is nothing to carry
579
+ if (!was) return false;
580
+ const pane = this.panes.find(
581
+ (p) => p.canBlit(was) && (!now || p.canBlit(now)),
582
+ );
583
+ if (!pane) return false;
584
+ const view = intersectRects(pane.rect, box);
585
+ const dest = intersectRects(shifted(was, dx, dy), view);
586
+ if (!dest) return false;
587
+ const cap = root._damageRectCap();
588
+ const claimed = root._paneDamage ?? [];
589
+ let rects = claimed;
590
+ const add = (rect) => {
591
+ if (rect) rects = addDamageRect(rects, rect, cap);
592
+ };
593
+ for (const claim of claimed)
594
+ add(intersectRects(shifted(claim, dx, dy), dest));
595
+ for (const piece of subtractRect(was, dest)) add(piece);
596
+ if (now) for (const piece of subtractRect(now, dest)) add(piece);
597
+ for (const other of area.paintOrder()) {
598
+ if (other === node) continue;
599
+ const theirs = whole(other.paintBounds());
600
+ add(intersectRects(theirs, dest));
601
+ add(intersectRects(shifted(theirs, dx, dy), dest));
602
+ }
603
+ // the list is disjoint, so the sum is the area it covers
604
+ let repainted = 0;
605
+ for (const rect of rects) {
606
+ const inside = intersectRects(rect, dest);
607
+ if (inside) repainted += rectArea(inside);
608
+ }
609
+ if (repainted > rectArea(dest) * MOVE_BLIT_MAX_REPAINT) return false;
610
+ // the verb moves the band that survives inside the rect it is handed,
611
+ // so handed where the pixels are and where they go, that band is `dest`
612
+ if (!pane.blit(unionRect(shifted(dest, -dx, -dy), dest), dx, dy)) {
613
+ return false;
614
+ }
615
+ root._paneDamage = rects;
616
+ return true;
617
+ }
618
+
308
619
  /**
309
620
  * Paint what the frame owes: every pane whole after it was made, resized
310
621
  * or lost its pixels, and otherwise the frame's damage cut to each pane —
@@ -323,10 +634,17 @@ export class GlOverlay {
323
634
  } else {
324
635
  passes = [];
325
636
  for (const rect of damage) {
326
- const hit = intersect(rect, pane.rect);
637
+ const hit = intersectRects(rect, pane.rect);
327
638
  if (hit) passes.push(hit);
328
639
  }
329
- if (passes.length === 0) continue;
640
+ }
641
+ const blitted = pane.blitted;
642
+ pane.blitted = false;
643
+ pane.placed = false;
644
+ if (passes.length === 0) {
645
+ // pixels moved and nothing else owed: they still go out
646
+ if (blitted) pane.wnd.present?.();
647
+ continue;
330
648
  }
331
649
  pane.full = false;
332
650
  this._paintPane(pane, passes);
package/src/index.d.ts CHANGED
@@ -216,7 +216,10 @@ export function useClipboard(): Clipboard;
216
216
  * before it. Both backends draw them; translucency is the difference,
217
217
  * composited by Core Animation on Cocoa and opaque on X11, where what a
218
218
  * child leaves unpainted shows the surface's `clearColor` (docs/elements.md).
219
- * Ask it before handing a surface its HUD. A property of the backend.
219
+ * False on XQuartz, whose GL surfaces the macOS window server composites
220
+ * above everything the X server draws: nothing can be drawn over one there.
221
+ * Ask it before handing a surface its HUD. A property of the backend and the
222
+ * display, settled before the first render.
220
223
  */
221
224
  export type SupportsFeature =
222
225
  'transparency' | 'shaders' | 'nativeControls' | 'embedding' | 'glOverlay';
@@ -215,6 +215,7 @@ export class NodeAnimation {
215
215
  false,
216
216
  insetRect(this.paintBounds(), -shrank),
217
217
  'shadow',
218
+ this,
218
219
  );
219
220
  }
220
221
  }
@@ -235,6 +236,7 @@ export class NodeAnimation {
235
236
  false,
236
237
  insetRect(this.paintBounds(), -shrank),
237
238
  'outline',
239
+ this,
238
240
  );
239
241
  }
240
242
  }
@@ -49,6 +49,14 @@ export const layoutDiff = {
49
49
  // diff reports only the children that landed somewhere else. Null everywhere
50
50
  // else, and restored through `finally` like the sink beside it.
51
51
  shift: null,
52
+
53
+ // True when `shift` is a `<glarea>` child's own move rather than a scroll
54
+ // (src/glnodes.js, `_absolutizeChild`, issue #644): the overlay moves the
55
+ // child's pixels on its pane, and a node that lands where it was plus the
56
+ // shift is carried by that. A node that turns the diff off under a shift —
57
+ // a scroll pane laid out again, whose box would have claimed its children
58
+ // — claims its own box instead, since the move claimed nothing for it.
59
+ ride: false,
52
60
  };
53
61
 
54
62
  // What an invalidate() may name as its reason — a small closed set, so the