react-x11 2.21.2 → 2.22.1

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.
@@ -8,20 +8,28 @@
8
8
  // viewport, so the fast path can cost correctness nothing.
9
9
 
10
10
  import { EMPTY_STYLE, resolveBorderWidths } from '../styles.js';
11
- import { DAMAGE_SLOP, addDamageRect } from './damage.js';
11
+ import {
12
+ DAMAGE_SLOP,
13
+ MAX_DAMAGE_RECTS,
14
+ addDamageRect,
15
+ addDamageRects,
16
+ } from './damage.js';
12
17
  import {
13
18
  rectContains,
14
19
  isIntegerRect,
15
20
  insetRect,
16
21
  innerPixels,
22
+ outerPixels,
17
23
  intersectRects,
18
24
  unionRect,
19
25
  rectArea,
20
- cornerSquaresOverlap,
26
+ cornerSquares,
21
27
  rectsOverlap,
28
+ outside,
22
29
  } from './rects.js';
23
30
  import { isPaintedColor } from './boxpaint.js';
24
31
  import { scrollbarTrackRect } from './scrollbars.js';
32
+ import { DEV } from './util.js';
25
33
  import { debugPaint } from './window/debugpaint.js';
26
34
 
27
35
  // --- scroll blitting (issue #138) ---------------------------------------
@@ -58,6 +66,15 @@ const SCROLL_BLIT_MIN_KEEP = 0.5;
58
66
  // Past this the blit is buying a shift and paying for the viewport anyway.
59
67
  const SCROLL_BLIT_MAX_REPAINT = 0.75;
60
68
 
69
+ // The rects an element blit may leave its frame to paint: the strips the
70
+ // shift exposed, on top of a frame's usual allowance. A diagonal pan of a
71
+ // rounded pane with furniture in two corners owes two strips, two pieces of
72
+ // furniture and four corner repairs; held to four rects they merged into
73
+ // boxes a third of the pane, and a graph panned diagonally at 41fps on
74
+ // XQuartz and 52 on macOS where two more rects make it 60 and 78 (issue
75
+ // #691). Eight bought nothing past six.
76
+ const CONTENTS_BLIT_MAX_RECTS = MAX_DAMAGE_RECTS + 2;
77
+
61
78
  // A scroll that must not blit this frame: content inside the viewport
62
79
  // already changed (see the arming check in scrollTo, and the claim-time
63
80
  // cancel in WindowNode.invalidate — react-x11#295). Truthy on purpose, so
@@ -201,6 +218,98 @@ function unionIsBox(a, b) {
201
218
  );
202
219
  }
203
220
 
221
+ /** Is `rect` inside one of `pins`, the furniture an element blit pinned —
222
+ * repaired whole after the copy, whatever changed in it (issue #682)? */
223
+ function insidePinned(rect, pins) {
224
+ if (!pins) return false;
225
+ for (const pin of pins) {
226
+ if (rectContains(pin, rect)) return true;
227
+ }
228
+ return false;
229
+ }
230
+
231
+ /** Does every corner square of a rounded `box` that reaches into `vp` lie
232
+ * inside one of `pins` — repaired after the copy rather than dragged along
233
+ * by it (issue #691)? True when none reaches in. */
234
+ function cornersPinned(box, radius, vp, pins) {
235
+ for (const square of cornerSquares(box, radius)) {
236
+ if (!rectsOverlap(square, vp)) continue;
237
+ const inside = intersectRects(outerPixels(square), vp);
238
+ if (inside && !insidePinned(inside, pins)) return false;
239
+ }
240
+ return true;
241
+ }
242
+
243
+ /** One of `scrollContents`' lists as an array, read once — an iterable may
244
+ * be a generator — null when it was left out, and undefined when it is not
245
+ * a list at all. */
246
+ function asList(value) {
247
+ if (value == null) return null;
248
+ return typeof value[Symbol.iterator] === 'function' ? [...value] : undefined;
249
+ }
250
+
251
+ /** The first entry of `list` that `ok` refuses, the value itself when it is
252
+ * not a list, or undefined when there is nothing wrong with it. */
253
+ function strayIn(value, list, ok) {
254
+ if (list === undefined) return value;
255
+ return list?.find((entry) => entry != null && !ok(entry));
256
+ }
257
+
258
+ const isNode = (value) => typeof value.paintBounds === 'function';
259
+
260
+ const isRect = (value) =>
261
+ Number.isFinite(value.x) &&
262
+ Number.isFinite(value.y) &&
263
+ Number.isFinite(value.width) &&
264
+ Number.isFinite(value.height) &&
265
+ value.width >= 0 &&
266
+ value.height >= 0;
267
+
268
+ /**
269
+ * The furniture one `scrollContents` call pins: the whole pixels each rect
270
+ * touches inside `rect`, rounded outward because repairing a pixel more
271
+ * costs nothing and a pixel less is a sliver of stale furniture the pan
272
+ * drags along. A rect beside `rect` has no pixels the copy moves, and is
273
+ * left out, as is an empty one. Null when nothing is left.
274
+ */
275
+ function pinnedRects(rect, list) {
276
+ let out = null;
277
+ for (const pin of list ?? []) {
278
+ if (pin == null || pin.width === 0 || pin.height === 0) continue;
279
+ const inside = intersectRects(outerPixels(pin), rect);
280
+ if (inside) (out ??= []).push(inside);
281
+ }
282
+ return out;
283
+ }
284
+
285
+ /** A value from a caller, short enough for a warning. An object that will
286
+ * not serialize — a node's tree is a cycle — is named by its keys. */
287
+ function describe(value) {
288
+ if (typeof value?.paintBounds === 'function') {
289
+ return `the node <${value.kind}>`;
290
+ }
291
+ // the likeliest mistake of all, from a React component
292
+ if (typeof value?.current?.paintBounds === 'function') {
293
+ return `a ref to <${value.current.kind}> rather than the node, its .current`;
294
+ }
295
+ if (value === null || typeof value !== 'object') return String(value);
296
+ try {
297
+ const json = JSON.stringify(value);
298
+ return json.length > 120 ? `${json.slice(0, 117)}...` : json;
299
+ } catch {
300
+ return `an object with ${Object.keys(value).join(', ') || 'no keys'}`;
301
+ }
302
+ }
303
+
304
+ const warned = new Set();
305
+ /** A development warning, once per kind of mistake rather than per value:
306
+ * the value is new on every frame of a pan. */
307
+ function warnOnce(key, message) {
308
+ if (!DEV || warned.has(key)) return;
309
+ warned.add(key);
310
+ console.warn(message);
311
+ }
312
+
204
313
  /** Node's half of the scroll blit, installed onto `Node.prototype` by node.js. */
205
314
  export class NodeScrollBlit {
206
315
  /**
@@ -215,8 +324,10 @@ export class NodeScrollBlit {
215
324
  * away, so the paths that make them take a finer route while this is true.
216
325
  *
217
326
  * `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.
327
+ * against the rect it handed over (issue #309), its region is not a
328
+ * viewport whose children *are* the scrolled content, and what changes
329
+ * inside it on a pan frame is furniture the element names up front as
330
+ * pinned (issue #682, `_pinnedHolds`).
220
331
  */
221
332
  _blitLedgerOpen() {
222
333
  const from = this._pendingBlitFrom;
@@ -277,6 +388,18 @@ export class NodeScrollBlit {
277
388
  return true;
278
389
  }
279
390
 
391
+ /**
392
+ * Is `rect` inside furniture this node's pending element blit pinned
393
+ * (issue #682)? A claim there is the element's own and costs the blit
394
+ * nothing: the frame repaints the pinned rect after the copy, and the
395
+ * rect the copy dragged its old pixels to, so the change is covered
396
+ * whichever side of the shift the claim was named on — where the
397
+ * furniture stays, or where the content under it was before it moved.
398
+ */
399
+ _pinnedHolds(rect) {
400
+ return insidePinned(rect, this._pendingBlitContents?.pinned);
401
+ }
402
+
280
403
  /**
281
404
  * How far the blit this viewport has pending moves the pixels it keeps:
282
405
  * the delta `_applyScrollBlits` hands `scrollRegion`, from the offsets
@@ -330,12 +453,24 @@ export class NodeScrollBlit {
330
453
  * moved something — and each of them falls back to repainting the rect,
331
454
  * which is the behaviour without this call at all.
332
455
  *
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.
456
+ * All of those are about `rect`, not about this node (issue #309): a
457
+ * claim beside the rect leaves the frame a blit, so an element can carve
458
+ * a HUD strip along the pane's edge out of the region it shifts and claim
459
+ * the strip as ordinary damage.
460
+ *
461
+ * Furniture in a corner does better as `pinned` (issue #682): rects inside
462
+ * `rect`, in the same coordinates, that stay put while everything around
463
+ * them moves — a minimap, zoom controls, drawn by the element over its
464
+ * own drawing or laid out over it as nodes. Carved out instead, a corner
465
+ * takes the rows beside it too, since what shifts is one rectangle, and
466
+ * two bottom corners carve a band the pane's full width, repainted every
467
+ * frame. Pinned, the copy drags a stale image of each piece of furniture
468
+ * by (dx, dy), and the frame repaints the pinned rect and that image —
469
+ * two small rects, whether the furniture changed or not, so a minimap's
470
+ * viewport box can follow the pan without claiming a thing. A claim
471
+ * inside a pinned rect is the element's own rather than a reason to
472
+ * decline, and so is a node that lies inside one: its pixels are
473
+ * repaired with the rest of the rect.
339
474
  *
340
475
  * `riders` are the other way round: nodes that are not this one whose
341
476
  * pixels in `rect` are part of what moves — a layer of widgets an element
@@ -352,7 +487,7 @@ export class NodeScrollBlit {
352
487
  * arrives as `paintDamage()`, because most of the gates cannot be decided
353
488
  * until the frame closes; a `false` here is only the ones that can.
354
489
  */
355
- scrollContents(rect, dx, dy, riders) {
490
+ scrollContents(rect, dx, dy, riders, pinned) {
356
491
  const root = this.root;
357
492
  if (
358
493
  !root ||
@@ -367,6 +502,33 @@ export class NodeScrollBlit {
367
502
  // rounding a gesture to whole pixels lands here on most events.
368
503
  if (!dx && !dy) return true;
369
504
  const pending = this._pendingBlitContents;
505
+ // Two lists, one of nodes and one of rects, and the cost of mixing them
506
+ // up is not a slow frame but a wrong one: furniture handed over as a
507
+ // rider is dragged along by the pan and repaired by nobody. So each has
508
+ // to be what it says, or the frame repaints.
509
+ const riderList = asList(riders);
510
+ const pinList = asList(pinned);
511
+ const strayRider = strayIn(riders, riderList, isNode);
512
+ const strayPin = strayIn(pinned, pinList, isRect);
513
+ if (strayRider !== undefined) {
514
+ warnOnce(
515
+ 'riders',
516
+ 'react-x11: scrollContents() takes the nodes a pan carries as a ' +
517
+ `list of riders, and got ${describe(strayRider)}. Furniture that ` +
518
+ 'stays put while the pan moves around it is pinned — a list of ' +
519
+ 'rects, the argument after riders. This frame repaints the region ' +
520
+ 'instead of blitting it.',
521
+ );
522
+ } else if (strayPin !== undefined) {
523
+ warnOnce(
524
+ 'pinned',
525
+ 'react-x11: scrollContents() takes the furniture it pins as a list ' +
526
+ 'of rects — { x, y, width, height }, in window coordinates like ' +
527
+ `the region — and got ${describe(strayPin)}. This frame repaints ` +
528
+ 'the region instead of blitting it, since a blit would drag the ' +
529
+ 'furniture along with the pan.',
530
+ );
531
+ }
370
532
  // The rect has to be the same one all frame: two different regions of
371
533
  // one node shifting by different deltas is not one CopyArea, and the
372
534
  // second claim would coalesce into the first past the point either can
@@ -374,12 +536,14 @@ export class NodeScrollBlit {
374
536
  // offsets this frame — two shifts of the same pixels, and a frame can
375
537
  // only have one.
376
538
  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)
539
+ strayRider !== undefined ||
540
+ strayPin !== undefined ||
541
+ (this._pendingBlitFrom != null &&
542
+ (!pending ||
543
+ pending.rect.x !== rect.x ||
544
+ pending.rect.y !== rect.y ||
545
+ pending.rect.width !== rect.width ||
546
+ pending.rect.height !== rect.height))
383
547
  ) {
384
548
  this._pendingBlitFrom = BLIT_POISONED;
385
549
  } else if (this._pendingBlitFrom == null && Array.isArray(root._damage)) {
@@ -394,11 +558,13 @@ export class NodeScrollBlit {
394
558
  // claim it could swallow has to overlap it, and every claim carries
395
559
  // its own slop already — `paintBounds` inflates a node's region by
396
560
  // `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.
561
+ // claimed overlapping it. Furniture *beside* the rect — a strip the
562
+ // element carved out and repaints itself — is not a change to the
563
+ // pixels about to move, and nor is furniture this call pins inside
564
+ // it (issue #682): the frame repaints that whole, blit or no blit.
565
+ const pins = pinnedRects(rect, pinList);
400
566
  for (const claimed of root._damage) {
401
- if (rectsOverlap(claimed, rect)) {
567
+ if (rectsOverlap(claimed, rect) && !insidePinned(claimed, pins)) {
402
568
  this._pendingBlitFrom = BLIT_POISONED;
403
569
  break;
404
570
  }
@@ -415,13 +581,22 @@ export class NodeScrollBlit {
415
581
  // coalescing scrollTo gets from recording an origin
416
582
  state.dx += dx;
417
583
  state.dy += dy;
418
- if (riders) {
419
- for (const rider of riders) {
584
+ if (strayRider === undefined) {
585
+ for (const rider of riderList ?? []) {
420
586
  if (!rider || rider === this || rider.destroyed) continue;
421
587
  (state.riders ??= new Set()).add(rider);
422
588
  rider._ridesBlitOf = this;
423
589
  }
424
590
  }
591
+ // …and the furniture of every call this frame: a pan that moved its
592
+ // minimap names both places, and the frame repairs both. Kept in whole
593
+ // pixels inside the rect, once each.
594
+ if (strayPin === undefined) {
595
+ for (const pin of pinnedRects(state.rect, pinList) ?? []) {
596
+ const pins = (state.pinned ??= []);
597
+ if (!pins.some((held) => rectContains(held, pin))) pins.push(pin);
598
+ }
599
+ }
425
600
  this._pendingBlitFrom ??= BLIT_CONTENTS;
426
601
  (root._pendingScrolls ??= new Set()).add(this);
427
602
  // ...and the claim about to be recorded is the shift itself, not a
@@ -842,10 +1017,15 @@ export class WindowScrollBlit {
842
1017
  * the L of exposed strips overlaps the scrollbar rects and the merges
843
1018
  * balloon back towards the whole viewport. With no bars the L is two
844
1019
  * disjoint rects and stays two.
1020
+ *
1021
+ * Furniture the element pinned (issue #682) is the one thing inside the
1022
+ * region that does not move: the copy drags a stale image of it along,
1023
+ * so each pinned rect is repaired where it is and where the image landed,
1024
+ * and a node inside one is furniture too rather than a reason to refuse.
845
1025
  */
846
1026
  _applyContentsBlit(
847
1027
  node,
848
- { rect: vp, dx, dy, riders },
1028
+ { rect: vp, dx, dy, riders, pinned },
849
1029
  width,
850
1030
  height,
851
1031
  target,
@@ -888,66 +1068,39 @@ export class WindowScrollBlit {
888
1068
  // A scroll container's children *are* the scrolled content, which is
889
1069
  // why _scrollBlitSafe skips that subtree. An element's are not: it
890
1070
  // draws the region in paintContent and its children are laid out on
891
- // top, so one reaching in would have its pixels dragged along.
1071
+ // top, so one reaching in would have its pixels dragged along — unless
1072
+ // it lies inside furniture the element pinned, which is repaired after
1073
+ // the copy whoever drew it.
892
1074
  for (const child of node.children) {
893
1075
  if (child.isWindow || !child.yoga || child.hidden) continue;
894
1076
  if (child.style?.display === 'none') continue;
895
- if (rectsOverlap(child._subtreeBounds(), vp)) return;
1077
+ const reach = child._subtreeBounds();
1078
+ if (rectsOverlap(reach, vp) && !insidePinned(reach, pinned)) return;
896
1079
  }
897
1080
  const keep = this._blitKeptDamage(vp, true, target.damage);
898
1081
  if (!keep) return;
899
- // An ancestor's rounded corners reach into the top and bottom rows of
900
- // the region (a graph pane inside a rounded card is the common shape):
901
- // those rows do not translate, so they leave the blit and get repainted
902
- // as bands — the same carve the element does for its own furniture —
903
- // and the band that shifts is what is left between them.
904
- const bands = this._cornerBands(node, vp, target.top);
905
- const shifted =
906
- bands.top || bands.bottom
907
- ? {
908
- x: vp.x,
909
- y: vp.y + bands.top,
910
- width: vp.width,
911
- height: vp.height - bands.top - bands.bottom,
912
- }
913
- : vp;
914
- if (shifted.height <= 0 || Math.abs(dy) >= shifted.height) return;
915
- if (
916
- (shifted.width - Math.abs(dx)) * (shifted.height - Math.abs(dy)) <
917
- area * SCROLL_BLIT_MIN_KEEP
918
- ) {
919
- return;
920
- }
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;
926
- let rects = keep;
927
- if (bands.top) {
928
- rects = addDamageRect(rects, {
929
- x: vp.x,
930
- y: vp.y,
931
- width: vp.width,
932
- height: bands.top,
933
- });
934
- }
935
- if (bands.bottom) {
936
- rects = addDamageRect(rects, {
937
- x: vp.x,
938
- y: shifted.y + shifted.height,
939
- width: vp.width,
940
- height: bands.bottom,
941
- });
942
- }
943
- vp = shifted;
1082
+ // An ancestor's rounded corners reach into the region (a graph pane
1083
+ // inside a rounded card is the common shape). Their arcs do not
1084
+ // translate, but they live in four small squares and nowhere else, so
1085
+ // they are pinned like the element's own furniture (issue #691): the
1086
+ // whole region shifts, and each square is repaired where it is and where
1087
+ // the copy dragged its image. Carved out as the rows they reach into,
1088
+ // they took the region's full width at the top and at the bottom —
1089
+ // every card and edge in those rows repainted through the rounded clip
1090
+ // on every frame — and next to furniture in two corners, one rect more
1091
+ // than the damage cap has room for.
1092
+ const corners = this._cornerPins(node, vp, target.top);
1093
+ const pins =
1094
+ corners && pinned ? [...pinned, ...corners] : (corners ?? pinned);
1095
+ if (!this._scrollBlitSafe(node, vp, target.top, riders, pins)) return;
944
1096
  // The strips the shift exposed, on the sides the pixels came from. The
945
1097
  // horizontal one takes the full width and the vertical one takes what
946
1098
  // is left, so a diagonal shift claims two rects that do not overlap —
947
1099
  // overlapping claims merge into their box, and the box of an L is the
948
1100
  // whole region again.
1101
+ const strips = [];
949
1102
  if (dy !== 0) {
950
- rects = addDamageRect(rects, {
1103
+ strips.push({
951
1104
  x: vp.x,
952
1105
  y: dy > 0 ? vp.y : vp.y + vp.height + dy,
953
1106
  width: vp.width,
@@ -955,38 +1108,73 @@ export class WindowScrollBlit {
955
1108
  });
956
1109
  }
957
1110
  if (dx !== 0) {
958
- rects = addDamageRect(rects, {
1111
+ strips.push({
959
1112
  x: dx > 0 ? vp.x : vp.x + vp.width + dx,
960
1113
  y: dy > 0 ? vp.y + dy : vp.y,
961
1114
  width: Math.abs(dx),
962
1115
  height: vp.height - Math.abs(dy),
963
1116
  });
964
1117
  }
1118
+ const pieces = [...strips];
1119
+ if (pins) {
1120
+ // Each piece of furniture where it stays, and where the copy put the
1121
+ // image of it it dragged along — one box, since a pan step moves it a
1122
+ // few pixels, and inside the region, since the copy wrote nothing
1123
+ // outside. Less the strips, which repaint anyway: furniture flush
1124
+ // against the side the pixels came from would otherwise merge with
1125
+ // the strip down that side into the box of the two, most of the
1126
+ // region's height.
1127
+ for (const pin of pins) {
1128
+ const image = { ...pin, x: pin.x + dx, y: pin.y + dy };
1129
+ const repair = intersectRects(unionRect(pin, image), vp);
1130
+ if (!repair) continue;
1131
+ let cut = [repair];
1132
+ for (const strip of strips) {
1133
+ cut = cut.flatMap((piece) => outside(piece, strip));
1134
+ }
1135
+ pieces.push(...cut);
1136
+ }
1137
+ }
1138
+ // in one call, so the small pieces find their neighbours before the
1139
+ // cap makes anything merge (`addDamageRects`)
1140
+ const rects = addDamageRects(keep, pieces, CONTENTS_BLIT_MAX_RECTS);
1141
+ if (pins) {
1142
+ // Furniture can be most of a pane, and the damage cap can merge a
1143
+ // repair with a strip into the box of the two: past this, the blit is
1144
+ // buying a shift and paying for the region anyway.
1145
+ let painted = 0;
1146
+ for (const rect of rects) {
1147
+ const inside = intersectRects(rect, vp);
1148
+ if (inside) painted += rectArea(inside);
1149
+ }
1150
+ if (painted > area * SCROLL_BLIT_MAX_REPAINT) return;
1151
+ }
1152
+ // the element's deltas are already how far the pixels moved, the sense
1153
+ // scrollRegion takes (0 + x rather than x: a caller's -0 would survive
1154
+ // into request buffers and test comparisons)
1155
+ if (!target.blit({ ...vp }, 0 + dx, 0 + dy)) return;
965
1156
  target.damage = rects;
966
1157
  }
967
1158
 
968
1159
  /**
969
- * How many rows at the top and at the bottom of `vp` an ancestor's rounded
970
- * corners reach into — the rows an element blit has to leave behind and
971
- * repaint, so that what shifts stays clear of every corner square
972
- * (`_scrollBlitSafe`). Whole pixels, and zero when no corner reaches in.
1160
+ * The squares of `vp` an ancestor's rounded corners reach into — the only
1161
+ * pixels of a rounded outline a translation cannot keep — in whole pixels,
1162
+ * for an element blit to pin (issue #691). Null when no corner reaches in.
973
1163
  * Ancestors below `upTo`, the node the blit's surface belongs to
974
1164
  * (`_blitTarget`): a pane is a rectangle no corner above it clips.
975
1165
  */
976
- _cornerBands(node, vp, upTo = this) {
977
- let top = 0;
978
- let bottom = 0;
1166
+ _cornerPins(node, vp, upTo = this) {
1167
+ let out = null;
979
1168
  for (let n = node.parent; n && n !== upTo; n = n.parent) {
980
1169
  const radius = n.style?.borderRadius ?? 0;
981
1170
  if (!(radius > 0) || !n.abs) continue;
982
- if (!cornerSquaresOverlap(n.abs, radius, vp)) continue;
983
- top = Math.max(top, Math.ceil(n.abs.y + radius - vp.y));
984
- bottom = Math.max(
985
- bottom,
986
- Math.ceil(vp.y + vp.height - (n.abs.y + n.abs.height - radius)),
987
- );
1171
+ for (const square of cornerSquares(n.abs, radius)) {
1172
+ if (!rectsOverlap(square, vp)) continue;
1173
+ const inside = intersectRects(outerPixels(square), vp);
1174
+ if (inside) (out ??= []).push(inside);
1175
+ }
988
1176
  }
989
- return { top: Math.max(0, top), bottom: Math.max(0, bottom) };
1177
+ return out;
990
1178
  }
991
1179
 
992
1180
  /**
@@ -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;
@@ -1041,10 +1229,11 @@ export class WindowScrollBlit {
1041
1229
  // radius-sized squares at the corners, and the straight run of
1042
1230
  // the edge between them is the border ring already excluded
1043
1231
  // above. A viewport that reaches the edge but stays clear of the
1044
- // squares — an element that carved the corner rows into bands it
1045
- // repaints (`_cornerBands`) — is translation-safe.
1232
+ // squares is translation-safe, and so is an element blit that
1233
+ // pins the squares it reaches into (`_cornerPins`) — repaired
1234
+ // where they are and where the copy dragged them.
1046
1235
  const radius = child.style?.borderRadius ?? 0;
1047
- if (radius > 0 && cornerSquaresOverlap(child.abs, radius, vp)) {
1236
+ if (radius > 0 && !cornersPinned(child.abs, radius, vp, pinned)) {
1048
1237
  return false;
1049
1238
  }
1050
1239
  if (typeof child._scrollbars === 'function') {
@@ -1063,7 +1252,11 @@ export class WindowScrollBlit {
1063
1252
  // …and a node the blit carries has pixels that move with it
1064
1253
  // (`scrollContents`'s riders)
1065
1254
  if (riders?.has(child)) continue;
1066
- if (!rectsOverlap(child._subtreeBounds(), vp)) continue;
1255
+ const reach = child._subtreeBounds();
1256
+ if (!rectsOverlap(reach, vp)) continue;
1257
+ // …and one inside furniture an element blit pinned is repaired
1258
+ // where it is and where the copy dragged it, like the element's own
1259
+ if (insidePinned(reach, pinned)) continue;
1067
1260
  // A box that paints nothing of its own is its children, and as
1068
1261
  // safe as they are: the box that clips a rider to its element.
1069
1262
  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,