react-x11 2.21.2 → 2.22.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.
@@ -14,14 +14,17 @@ import {
14
14
  isIntegerRect,
15
15
  insetRect,
16
16
  innerPixels,
17
+ outerPixels,
17
18
  intersectRects,
18
19
  unionRect,
19
20
  rectArea,
20
21
  cornerSquaresOverlap,
21
22
  rectsOverlap,
23
+ outside,
22
24
  } from './rects.js';
23
25
  import { isPaintedColor } from './boxpaint.js';
24
26
  import { scrollbarTrackRect } from './scrollbars.js';
27
+ import { DEV } from './util.js';
25
28
  import { debugPaint } from './window/debugpaint.js';
26
29
 
27
30
  // --- scroll blitting (issue #138) ---------------------------------------
@@ -201,6 +204,86 @@ function unionIsBox(a, b) {
201
204
  );
202
205
  }
203
206
 
207
+ /** Is `rect` inside one of `pins`, the furniture an element blit pinned —
208
+ * repaired whole after the copy, whatever changed in it (issue #682)? */
209
+ function insidePinned(rect, pins) {
210
+ if (!pins) return false;
211
+ for (const pin of pins) {
212
+ if (rectContains(pin, rect)) return true;
213
+ }
214
+ return false;
215
+ }
216
+
217
+ /** One of `scrollContents`' lists as an array, read once — an iterable may
218
+ * be a generator — null when it was left out, and undefined when it is not
219
+ * a list at all. */
220
+ function asList(value) {
221
+ if (value == null) return null;
222
+ return typeof value[Symbol.iterator] === 'function' ? [...value] : undefined;
223
+ }
224
+
225
+ /** The first entry of `list` that `ok` refuses, the value itself when it is
226
+ * not a list, or undefined when there is nothing wrong with it. */
227
+ function strayIn(value, list, ok) {
228
+ if (list === undefined) return value;
229
+ return list?.find((entry) => entry != null && !ok(entry));
230
+ }
231
+
232
+ const isNode = (value) => typeof value.paintBounds === 'function';
233
+
234
+ const isRect = (value) =>
235
+ Number.isFinite(value.x) &&
236
+ Number.isFinite(value.y) &&
237
+ Number.isFinite(value.width) &&
238
+ Number.isFinite(value.height) &&
239
+ value.width >= 0 &&
240
+ value.height >= 0;
241
+
242
+ /**
243
+ * The furniture one `scrollContents` call pins: the whole pixels each rect
244
+ * touches inside `rect`, rounded outward because repairing a pixel more
245
+ * costs nothing and a pixel less is a sliver of stale furniture the pan
246
+ * drags along. A rect beside `rect` has no pixels the copy moves, and is
247
+ * left out, as is an empty one. Null when nothing is left.
248
+ */
249
+ function pinnedRects(rect, list) {
250
+ let out = null;
251
+ for (const pin of list ?? []) {
252
+ if (pin == null || pin.width === 0 || pin.height === 0) continue;
253
+ const inside = intersectRects(outerPixels(pin), rect);
254
+ if (inside) (out ??= []).push(inside);
255
+ }
256
+ return out;
257
+ }
258
+
259
+ /** A value from a caller, short enough for a warning. An object that will
260
+ * not serialize — a node's tree is a cycle — is named by its keys. */
261
+ function describe(value) {
262
+ if (typeof value?.paintBounds === 'function') {
263
+ return `the node <${value.kind}>`;
264
+ }
265
+ // the likeliest mistake of all, from a React component
266
+ if (typeof value?.current?.paintBounds === 'function') {
267
+ return `a ref to <${value.current.kind}> rather than the node, its .current`;
268
+ }
269
+ if (value === null || typeof value !== 'object') return String(value);
270
+ try {
271
+ const json = JSON.stringify(value);
272
+ return json.length > 120 ? `${json.slice(0, 117)}...` : json;
273
+ } catch {
274
+ return `an object with ${Object.keys(value).join(', ') || 'no keys'}`;
275
+ }
276
+ }
277
+
278
+ const warned = new Set();
279
+ /** A development warning, once per kind of mistake rather than per value:
280
+ * the value is new on every frame of a pan. */
281
+ function warnOnce(key, message) {
282
+ if (!DEV || warned.has(key)) return;
283
+ warned.add(key);
284
+ console.warn(message);
285
+ }
286
+
204
287
  /** Node's half of the scroll blit, installed onto `Node.prototype` by node.js. */
205
288
  export class NodeScrollBlit {
206
289
  /**
@@ -215,8 +298,10 @@ export class NodeScrollBlit {
215
298
  * away, so the paths that make them take a finer route while this is true.
216
299
  *
217
300
  * `scrollContents` is out: an element blit already tests foreign claims
218
- * against the rect it handed over (issue #309), and its region is not a
219
- * viewport whose children *are* the scrolled content.
301
+ * against the rect it handed over (issue #309), its region is not a
302
+ * viewport whose children *are* the scrolled content, and what changes
303
+ * inside it on a pan frame is furniture the element names up front as
304
+ * pinned (issue #682, `_pinnedHolds`).
220
305
  */
221
306
  _blitLedgerOpen() {
222
307
  const from = this._pendingBlitFrom;
@@ -277,6 +362,18 @@ export class NodeScrollBlit {
277
362
  return true;
278
363
  }
279
364
 
365
+ /**
366
+ * Is `rect` inside furniture this node's pending element blit pinned
367
+ * (issue #682)? A claim there is the element's own and costs the blit
368
+ * nothing: the frame repaints the pinned rect after the copy, and the
369
+ * rect the copy dragged its old pixels to, so the change is covered
370
+ * whichever side of the shift the claim was named on — where the
371
+ * furniture stays, or where the content under it was before it moved.
372
+ */
373
+ _pinnedHolds(rect) {
374
+ return insidePinned(rect, this._pendingBlitContents?.pinned);
375
+ }
376
+
280
377
  /**
281
378
  * How far the blit this viewport has pending moves the pixels it keeps:
282
379
  * the delta `_applyScrollBlits` hands `scrollRegion`, from the offsets
@@ -330,12 +427,24 @@ export class NodeScrollBlit {
330
427
  * moved something — and each of them falls back to repainting the rect,
331
428
  * which is the behaviour without this call at all.
332
429
  *
333
- * All of those are about `rect`, not about this node (issue #309). An
334
- * element with furniture pinned to a corner of its pane — a minimap, zoom
335
- * controls, a HUD strip that has to repaint on a pan frame and whose
336
- * pixels must not ride the blit — carves it out of the region it shifts
337
- * and claims it as ordinary damage. Those claims land beside the rect and
338
- * the frame stays a blit.
430
+ * All of those are about `rect`, not about this node (issue #309): a
431
+ * claim beside the rect leaves the frame a blit, so an element can carve
432
+ * a HUD strip along the pane's edge out of the region it shifts and claim
433
+ * the strip as ordinary damage.
434
+ *
435
+ * Furniture in a corner does better as `pinned` (issue #682): rects inside
436
+ * `rect`, in the same coordinates, that stay put while everything around
437
+ * them moves — a minimap, zoom controls, drawn by the element over its
438
+ * own drawing or laid out over it as nodes. Carved out instead, a corner
439
+ * takes the rows beside it too, since what shifts is one rectangle, and
440
+ * two bottom corners carve a band the pane's full width, repainted every
441
+ * frame. Pinned, the copy drags a stale image of each piece of furniture
442
+ * by (dx, dy), and the frame repaints the pinned rect and that image —
443
+ * two small rects, whether the furniture changed or not, so a minimap's
444
+ * viewport box can follow the pan without claiming a thing. A claim
445
+ * inside a pinned rect is the element's own rather than a reason to
446
+ * decline, and so is a node that lies inside one: its pixels are
447
+ * repaired with the rest of the rect.
339
448
  *
340
449
  * `riders` are the other way round: nodes that are not this one whose
341
450
  * pixels in `rect` are part of what moves — a layer of widgets an element
@@ -352,7 +461,7 @@ export class NodeScrollBlit {
352
461
  * arrives as `paintDamage()`, because most of the gates cannot be decided
353
462
  * until the frame closes; a `false` here is only the ones that can.
354
463
  */
355
- scrollContents(rect, dx, dy, riders) {
464
+ scrollContents(rect, dx, dy, riders, pinned) {
356
465
  const root = this.root;
357
466
  if (
358
467
  !root ||
@@ -367,6 +476,33 @@ export class NodeScrollBlit {
367
476
  // rounding a gesture to whole pixels lands here on most events.
368
477
  if (!dx && !dy) return true;
369
478
  const pending = this._pendingBlitContents;
479
+ // Two lists, one of nodes and one of rects, and the cost of mixing them
480
+ // up is not a slow frame but a wrong one: furniture handed over as a
481
+ // rider is dragged along by the pan and repaired by nobody. So each has
482
+ // to be what it says, or the frame repaints.
483
+ const riderList = asList(riders);
484
+ const pinList = asList(pinned);
485
+ const strayRider = strayIn(riders, riderList, isNode);
486
+ const strayPin = strayIn(pinned, pinList, isRect);
487
+ if (strayRider !== undefined) {
488
+ warnOnce(
489
+ 'riders',
490
+ 'react-x11: scrollContents() takes the nodes a pan carries as a ' +
491
+ `list of riders, and got ${describe(strayRider)}. Furniture that ` +
492
+ 'stays put while the pan moves around it is pinned — a list of ' +
493
+ 'rects, the argument after riders. This frame repaints the region ' +
494
+ 'instead of blitting it.',
495
+ );
496
+ } else if (strayPin !== undefined) {
497
+ warnOnce(
498
+ 'pinned',
499
+ 'react-x11: scrollContents() takes the furniture it pins as a list ' +
500
+ 'of rects — { x, y, width, height }, in window coordinates like ' +
501
+ `the region — and got ${describe(strayPin)}. This frame repaints ` +
502
+ 'the region instead of blitting it, since a blit would drag the ' +
503
+ 'furniture along with the pan.',
504
+ );
505
+ }
370
506
  // The rect has to be the same one all frame: two different regions of
371
507
  // one node shifting by different deltas is not one CopyArea, and the
372
508
  // second claim would coalesce into the first past the point either can
@@ -374,12 +510,14 @@ export class NodeScrollBlit {
374
510
  // offsets this frame — two shifts of the same pixels, and a frame can
375
511
  // only have one.
376
512
  if (
377
- this._pendingBlitFrom != null &&
378
- (!pending ||
379
- pending.rect.x !== rect.x ||
380
- pending.rect.y !== rect.y ||
381
- pending.rect.width !== rect.width ||
382
- pending.rect.height !== rect.height)
513
+ strayRider !== undefined ||
514
+ strayPin !== undefined ||
515
+ (this._pendingBlitFrom != null &&
516
+ (!pending ||
517
+ pending.rect.x !== rect.x ||
518
+ pending.rect.y !== rect.y ||
519
+ pending.rect.width !== rect.width ||
520
+ pending.rect.height !== rect.height))
383
521
  ) {
384
522
  this._pendingBlitFrom = BLIT_POISONED;
385
523
  } else if (this._pendingBlitFrom == null && Array.isArray(root._damage)) {
@@ -394,11 +532,13 @@ export class NodeScrollBlit {
394
532
  // claim it could swallow has to overlap it, and every claim carries
395
533
  // its own slop already — `paintBounds` inflates a node's region by
396
534
  // `DAMAGE_SLOP` on every side, so ink that bleeds into `rect` is
397
- // claimed overlapping it. Furniture *beside* the rect — a minimap in
398
- // a corner the element carved out and repaints itself — is not a
399
- // change to the pixels about to move.
535
+ // claimed overlapping it. Furniture *beside* the rect — a strip the
536
+ // element carved out and repaints itself — is not a change to the
537
+ // pixels about to move, and nor is furniture this call pins inside
538
+ // it (issue #682): the frame repaints that whole, blit or no blit.
539
+ const pins = pinnedRects(rect, pinList);
400
540
  for (const claimed of root._damage) {
401
- if (rectsOverlap(claimed, rect)) {
541
+ if (rectsOverlap(claimed, rect) && !insidePinned(claimed, pins)) {
402
542
  this._pendingBlitFrom = BLIT_POISONED;
403
543
  break;
404
544
  }
@@ -415,13 +555,22 @@ export class NodeScrollBlit {
415
555
  // coalescing scrollTo gets from recording an origin
416
556
  state.dx += dx;
417
557
  state.dy += dy;
418
- if (riders) {
419
- for (const rider of riders) {
558
+ if (strayRider === undefined) {
559
+ for (const rider of riderList ?? []) {
420
560
  if (!rider || rider === this || rider.destroyed) continue;
421
561
  (state.riders ??= new Set()).add(rider);
422
562
  rider._ridesBlitOf = this;
423
563
  }
424
564
  }
565
+ // …and the furniture of every call this frame: a pan that moved its
566
+ // minimap names both places, and the frame repairs both. Kept in whole
567
+ // pixels inside the rect, once each.
568
+ if (strayPin === undefined) {
569
+ for (const pin of pinnedRects(state.rect, pinList) ?? []) {
570
+ const pins = (state.pinned ??= []);
571
+ if (!pins.some((held) => rectContains(held, pin))) pins.push(pin);
572
+ }
573
+ }
425
574
  this._pendingBlitFrom ??= BLIT_CONTENTS;
426
575
  (root._pendingScrolls ??= new Set()).add(this);
427
576
  // ...and the claim about to be recorded is the shift itself, not a
@@ -842,10 +991,15 @@ export class WindowScrollBlit {
842
991
  * the L of exposed strips overlaps the scrollbar rects and the merges
843
992
  * balloon back towards the whole viewport. With no bars the L is two
844
993
  * disjoint rects and stays two.
994
+ *
995
+ * Furniture the element pinned (issue #682) is the one thing inside the
996
+ * region that does not move: the copy drags a stale image of it along,
997
+ * so each pinned rect is repaired where it is and where the image landed,
998
+ * and a node inside one is furniture too rather than a reason to refuse.
845
999
  */
846
1000
  _applyContentsBlit(
847
1001
  node,
848
- { rect: vp, dx, dy, riders },
1002
+ { rect: vp, dx, dy, riders, pinned },
849
1003
  width,
850
1004
  height,
851
1005
  target,
@@ -888,11 +1042,14 @@ export class WindowScrollBlit {
888
1042
  // A scroll container's children *are* the scrolled content, which is
889
1043
  // why _scrollBlitSafe skips that subtree. An element's are not: it
890
1044
  // draws the region in paintContent and its children are laid out on
891
- // top, so one reaching in would have its pixels dragged along.
1045
+ // top, so one reaching in would have its pixels dragged along — unless
1046
+ // it lies inside furniture the element pinned, which is repaired after
1047
+ // the copy whoever drew it.
892
1048
  for (const child of node.children) {
893
1049
  if (child.isWindow || !child.yoga || child.hidden) continue;
894
1050
  if (child.style?.display === 'none') continue;
895
- if (rectsOverlap(child._subtreeBounds(), vp)) return;
1051
+ const reach = child._subtreeBounds();
1052
+ if (rectsOverlap(reach, vp) && !insidePinned(reach, pinned)) return;
896
1053
  }
897
1054
  const keep = this._blitKeptDamage(vp, true, target.damage);
898
1055
  if (!keep) return;
@@ -918,11 +1075,9 @@ export class WindowScrollBlit {
918
1075
  ) {
919
1076
  return;
920
1077
  }
921
- if (!this._scrollBlitSafe(node, shifted, target.top, riders)) return;
922
- // the element's deltas are already how far the pixels moved, the sense
923
- // scrollRegion takes (0 + x rather than x: a caller's -0 would survive
924
- // into request buffers and test comparisons)
925
- if (!target.blit({ ...shifted }, 0 + dx, 0 + dy)) return;
1078
+ if (!this._scrollBlitSafe(node, shifted, target.top, riders, pinned)) {
1079
+ return;
1080
+ }
926
1081
  let rects = keep;
927
1082
  if (bands.top) {
928
1083
  rects = addDamageRect(rects, {
@@ -940,28 +1095,61 @@ export class WindowScrollBlit {
940
1095
  height: bands.bottom,
941
1096
  });
942
1097
  }
943
- vp = shifted;
944
1098
  // The strips the shift exposed, on the sides the pixels came from. The
945
1099
  // horizontal one takes the full width and the vertical one takes what
946
1100
  // is left, so a diagonal shift claims two rects that do not overlap —
947
1101
  // overlapping claims merge into their box, and the box of an L is the
948
1102
  // whole region again.
1103
+ const strips = [];
949
1104
  if (dy !== 0) {
950
- rects = addDamageRect(rects, {
951
- x: vp.x,
952
- y: dy > 0 ? vp.y : vp.y + vp.height + dy,
953
- width: vp.width,
1105
+ strips.push({
1106
+ x: shifted.x,
1107
+ y: dy > 0 ? shifted.y : shifted.y + shifted.height + dy,
1108
+ width: shifted.width,
954
1109
  height: Math.abs(dy),
955
1110
  });
956
1111
  }
957
1112
  if (dx !== 0) {
958
- rects = addDamageRect(rects, {
959
- x: dx > 0 ? vp.x : vp.x + vp.width + dx,
960
- y: dy > 0 ? vp.y + dy : vp.y,
1113
+ strips.push({
1114
+ x: dx > 0 ? shifted.x : shifted.x + shifted.width + dx,
1115
+ y: dy > 0 ? shifted.y + dy : shifted.y,
961
1116
  width: Math.abs(dx),
962
- height: vp.height - Math.abs(dy),
1117
+ height: shifted.height - Math.abs(dy),
963
1118
  });
964
1119
  }
1120
+ for (const strip of strips) rects = addDamageRect(rects, strip);
1121
+ if (pinned) {
1122
+ // Each piece of furniture where it stays, and where the copy put the
1123
+ // image of it it dragged along — one box, since a pan step moves it a
1124
+ // few pixels, and inside what shifted, because the corner bands
1125
+ // repaint whole and the copy wrote nothing outside. Less the strips,
1126
+ // which repaint anyway: furniture flush against the side the pixels
1127
+ // came from would otherwise merge with the strip down that side into
1128
+ // the box of the two, most of the region's height.
1129
+ for (const pin of pinned) {
1130
+ const image = { ...pin, x: pin.x + dx, y: pin.y + dy };
1131
+ const repair = intersectRects(unionRect(pin, image), shifted);
1132
+ if (!repair) continue;
1133
+ let pieces = [repair];
1134
+ for (const strip of strips) {
1135
+ pieces = pieces.flatMap((piece) => outside(piece, strip));
1136
+ }
1137
+ for (const piece of pieces) rects = addDamageRect(rects, piece);
1138
+ }
1139
+ // Furniture can be most of a pane, and the damage cap can merge a
1140
+ // repair with a strip into the box of the two: past this, the blit is
1141
+ // buying a shift and paying for the region anyway.
1142
+ let painted = 0;
1143
+ for (const rect of rects) {
1144
+ const inside = intersectRects(rect, vp);
1145
+ if (inside) painted += rectArea(inside);
1146
+ }
1147
+ if (painted > area * SCROLL_BLIT_MAX_REPAINT) return;
1148
+ }
1149
+ // the element's deltas are already how far the pixels moved, the sense
1150
+ // scrollRegion takes (0 + x rather than x: a caller's -0 would survive
1151
+ // into request buffers and test comparisons)
1152
+ if (!target.blit({ ...shifted }, 0 + dx, 0 + dy)) return;
965
1153
  target.damage = rects;
966
1154
  }
967
1155
 
@@ -1002,7 +1190,7 @@ export class WindowScrollBlit {
1002
1190
  * only its own children — what the window draws under the surface was
1003
1191
  * never in the pane's pixels.
1004
1192
  */
1005
- _scrollBlitSafe(scroller, vp, top = this, riders = null) {
1193
+ _scrollBlitSafe(scroller, vp, top = this, riders = null, pinned = null) {
1006
1194
  // the window itself scrolls: everything inside it *is* the scrolled
1007
1195
  // content, so there is nothing that could be dragged along
1008
1196
  if (scroller === this) return true;
@@ -1063,7 +1251,11 @@ export class WindowScrollBlit {
1063
1251
  // …and a node the blit carries has pixels that move with it
1064
1252
  // (`scrollContents`'s riders)
1065
1253
  if (riders?.has(child)) continue;
1066
- if (!rectsOverlap(child._subtreeBounds(), vp)) continue;
1254
+ const reach = child._subtreeBounds();
1255
+ if (!rectsOverlap(reach, vp)) continue;
1256
+ // …and one inside furniture an element blit pinned is repaired
1257
+ // where it is and where the copy dragged it, like the element's own
1258
+ if (insidePinned(reach, pinned)) continue;
1067
1259
  // A box that paints nothing of its own is its children, and as
1068
1260
  // safe as they are: the box that clips a rider to its element.
1069
1261
  if (child._paintsNothingOwn() && check(child)) continue;
@@ -210,6 +210,8 @@ export class WindowFlush {
210
210
  if (this._claimLayoutMove(rect, node, cap)) layoutMoved = true;
211
211
  };
212
212
  }
213
+ // the moves this walk hands over to be copied are this frame's alone
214
+ this._rigidMoves = null;
213
215
  try {
214
216
  // `_absolutizeChildren`, not the loop it wraps: a `<window
215
217
  // style={{overflow: 'scroll'}}>` is a scroll container like any box,
@@ -279,6 +281,16 @@ export class WindowFlush {
279
281
  this.window.prepareFrame?.(this, layoutRan);
280
282
  // any node this pass laid out may be what an open popup is anchored to
281
283
  if (layoutRan) this._notifyAnchorChange();
284
+ // A subtree that only moved and covers its box opaquely is copied where
285
+ // it went, and the frame paints around the copy — or its move is
286
+ // claimed, where it was and where it is (nodes/moveblit.js). Once every
287
+ // other claim of the frame is in: what the copy leaves to paint depends
288
+ // on what else changed around it.
289
+ const moves = layoutRan ? this._rigidMoves : null;
290
+ this._rigidMoves = null;
291
+ if (moves !== null && this._settleRigidMoves(moves, width, height)) {
292
+ layoutMoved = true;
293
+ }
282
294
  // after layout (the claims above included), before the damage is taken:
283
295
  // a frame that turns out to be a pure scroll blits the surviving band
284
296
  // and narrows its claim to the exposed strip
@@ -18,6 +18,7 @@ import { NO_DAMAGE } from '../damage.js';
18
18
  import { installMethods } from '../install.js';
19
19
  import { WindowInvalidate } from '../invalidate.js';
20
20
  import { WindowLayoutHost } from '../layouthost.js';
21
+ import { WindowMoveBlit } from '../moveblit.js';
21
22
  import { DEVTOOLS_FAKE_DOCUMENT, Node } from '../node.js';
22
23
  import { WindowPaint } from '../paint.js';
23
24
  import { WindowPosition } from '../position.js';
@@ -209,6 +210,12 @@ export class WindowNode extends Scrollable(Node) {
209
210
  // each claim goes to one or both by what it can reach (`_paneReach`).
210
211
  // Null with nothing owed.
211
212
  this._paneDamage = null;
213
+ // The claims the window's list took this frame, each with the node that
214
+ // made it — null once there are too many to be worth keeping — and the
215
+ // subtrees this frame's layout moved that the window may copy rather
216
+ // than repaint, settled once the pass is over (nodes/moveblit.js).
217
+ this._claimLog = [];
218
+ this._rigidMoves = null;
212
219
  this.events = new EventManager(this);
213
220
  // ids of the child windows in the order the *server* stacks them,
214
221
  // bottom to top — see _restackWindowChildren
@@ -979,6 +986,7 @@ installMethods(
979
986
  WindowPosition,
980
987
  WindowInvalidate,
981
988
  WindowScrollBlit,
989
+ WindowMoveBlit,
982
990
  WindowPaint,
983
991
  WindowListeners,
984
992
  WindowHints,