react-x11 2.21.1 → 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.
@@ -0,0 +1,610 @@
1
+ // The move blit (issue #681). A subtree that only moved — the same size,
2
+ // somewhere else, nothing inside it changed — is already painted, one shift
3
+ // away. Where something in it covers its box with opaque pixels, what was
4
+ // under it cannot show through, so its pixels are copied from where they
5
+ // were to where they went (`Window.scrollRegion`, the verb the scroll blit
6
+ // moves a viewport's band with), and the frame paints only what the copy
7
+ // cannot supply: what the move uncovered, the parts of the subtree the cover
8
+ // does not reach, and whatever is drawn over it. A card of widgets dragged
9
+ // across a pane is that frame, once a step.
10
+ //
11
+ // The layout pass finds the move (`_beginRigidMove`, nodes/layout.js) and
12
+ // hands it here instead of claiming it, and the frame settles it once every
13
+ // other claim is in (`_settleRigidMoves`, from flush). Every gate falls back
14
+ // to the two claims the move would have made — where its pixels were and
15
+ // where they went — so the fast path can cost correctness nothing: the worst
16
+ // mistake it can make is not firing.
17
+
18
+ import { cssColorStraight } from 'ntk/color';
19
+ import { EMPTY_STYLE, resolveBorderWidths } from '../styles.js';
20
+ import { isPaintedColor } from './boxpaint.js';
21
+ import { DAMAGE_SLOP, FULL_DAMAGE, addDamageRect } from './damage.js';
22
+ import { NodePaint } from './paint.js';
23
+ import {
24
+ innerPixels,
25
+ insetRect,
26
+ intersectRects,
27
+ isIntegerRect,
28
+ outerPixels,
29
+ outside,
30
+ rectArea,
31
+ rectContains,
32
+ rectsOverlap,
33
+ shiftRect,
34
+ unionRect,
35
+ } from './rects.js';
36
+ import { scrollbarTrackRect } from './scrollbars.js';
37
+ import { debugPaint } from './window/debugpaint.js';
38
+
39
+ // Below this many pixels the copy is not worth its bookkeeping: the frame
40
+ // still repaints a strip along each side of it, a pass apiece, where a small
41
+ // box repainted whole is a pass or two.
42
+ const MOVE_BLIT_MIN_AREA = 32 * 32;
43
+
44
+ // Past this share of the copy, the frame would repaint most of the pixels it
45
+ // copied — around whatever else changed over the subtree — and the copy only
46
+ // adds to the bill. The `<glarea>` pane's copy stops at the same share
47
+ // (src/gloverlay.js).
48
+ const MOVE_BLIT_MAX_REPAINT = 0.75;
49
+
50
+ // The verb moves the band of the rect it is handed, and the window presents
51
+ // all of that rect: handed the box around both ends of a move, a jump across
52
+ // the window presents everything between them. Past this multiple of the
53
+ // copy, the two ends are repainted where they are instead.
54
+ const MOVE_BLIT_MAX_SPAN = 2;
55
+
56
+ // How many claims a frame logs with their nodes (`_logClaim`). A drag step
57
+ // makes a few — the element that draws the card a body sits on, a hover —
58
+ // and a frame past this is a commit the copy would not pay for anyway. Such
59
+ // a frame is judged from its damage list alone, every rect of it as if drawn
60
+ // over the subtree (`_claimsBySource`).
61
+ const CLAIM_LOG_MAX = 32;
62
+
63
+ // The scroll blit's escape hatch (nodes/scrollblit.js), read the same way:
64
+ // every path that moves retained pixels instead of repainting them is one
65
+ // variable away from the plain repaint (docs/debugging.md).
66
+ const NO_BLIT = process.env.REACT_X11_NO_SCROLL_BLIT === '1';
67
+
68
+ // Where a claim's node paints against the subtree being copied
69
+ // (`_claimSide`).
70
+ const BELOW = 0;
71
+ const INSIDE = 1;
72
+ const ABOVE = 2;
73
+
74
+ /** A colour nothing shows through. */
75
+ function isOpaqueColor(color) {
76
+ if (!isPaintedColor(color)) return false;
77
+ const rgba = cssColorStraight(color);
78
+ return rgba !== null && rgba[3] >= 1;
79
+ }
80
+
81
+ /** The squares at the corners of a box that a radius gives up, on the
82
+ * whole pixels they touch: where a rounded fill or clip is not opaque. */
83
+ function cornerSquares(box, radius) {
84
+ const r = Math.min(radius, box.width / 2, box.height / 2);
85
+ if (!(r > 0)) return [];
86
+ const right = box.x + box.width - r;
87
+ const bottom = box.y + box.height - r;
88
+ return [
89
+ { x: box.x, y: box.y, width: r, height: r },
90
+ { x: right, y: box.y, width: r, height: r },
91
+ { x: box.x, y: bottom, width: r, height: r },
92
+ { x: right, y: bottom, width: r, height: r },
93
+ ].map(outerPixels);
94
+ }
95
+
96
+ /**
97
+ * The whole pixels a node that clips lets its children draw in: its box,
98
+ * less its radius all round when it is rounded — the corner squares are
99
+ * the pixels a rounded clip gives up — as `WindowNode._coverFor` has it.
100
+ * Null when nothing is left.
101
+ */
102
+ function clipBoxOf(node) {
103
+ const radius = node.style?.borderRadius ?? 0;
104
+ return innerPixels(radius > 0 ? insetRect(node.abs, radius) : node.abs);
105
+ }
106
+
107
+ /**
108
+ * Whether what `node` draws over its children is all core's to know the
109
+ * reach of: its border, its outline and its scrollbars. An element that
110
+ * overrides `paint` draws after `super.paint` — over its children, where
111
+ * only it knows.
112
+ */
113
+ function drawsKnownOverChildren(node) {
114
+ if (node.paint === NodePaint.prototype.paint) return true;
115
+ let proto = Object.getPrototypeOf(node);
116
+ while (proto && !Object.hasOwn(proto, 'paint')) {
117
+ proto = Object.getPrototypeOf(proto);
118
+ }
119
+ // the Scrollable mixin's: `super.paint`, then the scrollbars
120
+ return proto !== null && Object.hasOwn(proto, '_paintScrollbars');
121
+ }
122
+
123
+ function sameRects(a, b) {
124
+ if (a.length !== b.length) return false;
125
+ for (let i = 0; i < a.length; i++) {
126
+ if (
127
+ a[i].x !== b[i].x ||
128
+ a[i].y !== b[i].y ||
129
+ a[i].width !== b[i].width ||
130
+ a[i].height !== b[i].height
131
+ ) {
132
+ return false;
133
+ }
134
+ }
135
+ return true;
136
+ }
137
+
138
+ /** Node's half of the move blit, installed onto `Node.prototype` by node.js. */
139
+ export class NodeMoveBlit {
140
+ /**
141
+ * Where this subtree covers its box with opaque pixels on every paint, as
142
+ * `{ rect, holes }` — window coordinates, whole pixels — or null when it
143
+ * promises nothing. `rect` is the box, and `holes` the squares in it that
144
+ * a radius gives up, where what is under the box shows through its
145
+ * antialiased corners. Everywhere else in `rect` whatever was painted
146
+ * before the subtree is hidden, which is what makes a copy of its pixels
147
+ * exact there (`WindowNode._blitRigidMove`).
148
+ *
149
+ * Three things cover: a background colour nothing shows through; an
150
+ * element's own promise (`opaqueRect()`); and, for a node that makes
151
+ * neither, its one drawn child's cover, cut to the box when it clips — the
152
+ * wrappers a card is positioned and scaled by. A node with several
153
+ * children answers null rather than the biggest of them: this is asked on
154
+ * every step of a drag, and a walk of the subtree is the cost the copy
155
+ * exists to save.
156
+ *
157
+ * Nothing in a group composited at an alpha below one is opaque, so an
158
+ * `opacity` on the way down answers null; so does a node on a layer of its
159
+ * own, whose pixels are not the bitmap's (src/cocoa/promotion.js).
160
+ */
161
+ _opaqueCover() {
162
+ let clip = null;
163
+ const holes = [];
164
+ for (let node = this; ;) {
165
+ if (node.hidden || node._promoted) return null;
166
+ const style = node.style ?? EMPTY_STYLE;
167
+ if (style.display === 'none') return null;
168
+ if (style.opacity !== undefined && !(style.opacity >= 1)) return null;
169
+ const radius = style.borderRadius ?? 0;
170
+ let own = null;
171
+ if (isOpaqueColor(style.backgroundColor)) {
172
+ own = node.abs;
173
+ holes.push(...cornerSquares(node.abs, radius));
174
+ } else {
175
+ own = node.opaqueRect();
176
+ }
177
+ if (own) {
178
+ const cut = clip ? intersectRects(own, clip) : own;
179
+ const rect = cut && innerPixels(cut);
180
+ if (!rect) return null;
181
+ return {
182
+ rect,
183
+ holes: holes
184
+ .map((hole) => intersectRects(hole, rect))
185
+ .filter(Boolean),
186
+ };
187
+ }
188
+ if (node.clipsChildren()) {
189
+ // a rounded clip gives up its corners the way a rounded fill does
190
+ const box = innerPixels(node.abs);
191
+ clip = box && (clip ? intersectRects(clip, box) : box);
192
+ if (!clip) return null;
193
+ holes.push(...cornerSquares(node.abs, radius));
194
+ }
195
+ const order = node.paintOrder();
196
+ if (order.length !== 1) return null;
197
+ node = order[0];
198
+ }
199
+ }
200
+
201
+ /**
202
+ * The box this subtree's pixels are all inside, on whole pixels, with
203
+ * nothing to spare — or null when that cannot be promised, and the damage
204
+ * model's reach, a pixel of slop all round, is what has to be claimed.
205
+ *
206
+ * A claim is grown by a pixel because a fractional edge and a glyph's
207
+ * antialiasing put ink just outside a box. A box on whole pixels with no
208
+ * shadow and no outline inks nothing outside itself, and neither do its
209
+ * children when it clips them or none comes within a pixel of its edge.
210
+ * For a copy that pixel is not free: around a card it is four strips a
211
+ * pixel wide, a paint pass apiece, where the copy's own frame is the
212
+ * strip the move uncovered.
213
+ */
214
+ _inkBox() {
215
+ const box = this.abs;
216
+ if (!isIntegerRect(box)) return null;
217
+ if (this._outlineExtent() > 0 || this._shadowExtent() > 0) return null;
218
+ if (!this.clipsChildren() && !inksWithin(this, box)) return null;
219
+ return box;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Whether everything `node`'s children draw stays inside `box`, whole
225
+ * pixels. A child's reach (`_subtreeBounds`, its shadow and outline in it)
226
+ * has to be inside the box; one that comes within a pixel of its edge has
227
+ * to be a box on whole pixels, whose fills and borders end where it does —
228
+ * text puts antialiased ink a pixel past its glyphs — and so, unless it
229
+ * clips them, do its own children: the wrapper a card's content is laid out
230
+ * in fills the card to its edge and draws nothing there.
231
+ */
232
+ function inksWithin(node, box) {
233
+ const inner = insetRect(box, DAMAGE_SLOP);
234
+ for (const child of node.children) {
235
+ if (child.isWindow || !child.yoga || child.hidden) continue;
236
+ if (child.style?.display === 'none') continue;
237
+ const reach = child._subtreeBounds();
238
+ if (!rectContains(box, reach)) return false;
239
+ if (rectContains(inner, reach)) continue;
240
+ if (child.kind !== 'box' || !isIntegerRect(child.abs)) return false;
241
+ if (!child.clipsChildren() && !inksWithin(child, box)) return false;
242
+ }
243
+ return true;
244
+ }
245
+
246
+ /** WindowNode's half of the move blit, installed onto `WindowNode.prototype` by window/window.js. */
247
+ export class WindowMoveBlit {
248
+ /**
249
+ * One rect this frame's list took (`invalidate`, `_claimLayoutMove`,
250
+ * `_placeNodes`), and the node whose claim it was: what tells a copy of a
251
+ * subtree a claim under it, which the copy covers, from one over it,
252
+ * which it has to repaint (`_claimSide`). A frame past `CLAIM_LOG_MAX`
253
+ * claims stops keeping them, and `_takeDamage` starts the next frame's.
254
+ */
255
+ _logClaim(rect, source) {
256
+ const log = this._claimLog;
257
+ if (log === null) return;
258
+ if (log.length >= CLAIM_LOG_MAX) {
259
+ this._claimLog = null;
260
+ return;
261
+ }
262
+ log.push({
263
+ // a caller's rect can be a live one — a `contentBox()`, a pan's region
264
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
265
+ source,
266
+ });
267
+ }
268
+
269
+ /**
270
+ * Whether the move of `node` by `shift` could be copied rather than
271
+ * repainted, as far as the layout pass can tell before it walks the
272
+ * subtree (`_beginRigidMove`). A frame still bounded, with no scroll blit
273
+ * armed — two copies in one frame would each carry pixels the other made
274
+ * stale, which is why a scroll blit declines a frame the layout moved —
275
+ * and nothing drawn over the whole window that a copy would drag along. A
276
+ * whole-pixel shift, and a backing the window can move pixels in. And a
277
+ * subtree whose pixels are the window's to move: not inside a `<glarea>`,
278
+ * whose panes hold them (src/gloverlay.js), nor holding one with panes;
279
+ * not on a layer of its own or inside one; not inside a group faded
280
+ * through a surface of its own. Everything that needs the frame's other
281
+ * claims waits for the pass to end (`_settleRigidMoves`).
282
+ */
283
+ _mayBlitMove(node, shift) {
284
+ if (NO_BLIT || this._damage === FULL_DAMAGE) return false;
285
+ if (this._pendingScrolls?.size) return false;
286
+ if (!Number.isInteger(shift.x) || !Number.isInteger(shift.y)) return false;
287
+ if (typeof this.window?.scrollRegion !== 'function') return false;
288
+ if (
289
+ debugPaint ||
290
+ process.env.REACT_X11_DEBUG_LAYOUT ||
291
+ this._highlight ||
292
+ this._traceUpdates
293
+ ) {
294
+ return false;
295
+ }
296
+ if (node._promoted) return false;
297
+ for (let n = node.parent; n && n !== this; n = n.parent) {
298
+ if (n.isGlArea || n._promoted) return false;
299
+ const opacity = n.style?.opacity;
300
+ if (opacity !== undefined && !(opacity >= 1)) return false;
301
+ }
302
+ for (const area of this._overlaid) {
303
+ for (let n = area; n && n !== this; n = n.parent) {
304
+ if (n === node) return false;
305
+ }
306
+ }
307
+ return true;
308
+ }
309
+
310
+ /** A move `_mayBlitMove` let through, queued for `_settleRigidMoves`. */
311
+ _deferRigidMove(node, was, shift) {
312
+ (this._rigidMoves ??= []).push({ node, was, shift });
313
+ }
314
+
315
+ /**
316
+ * The moves this frame's layout pass queued, once every other claim of
317
+ * the frame is in. The one whose copy is biggest is copied where that is
318
+ * safe and pays (`_blitRigidMove`); every other one, and that one when
319
+ * not, is claimed as the pass would have claimed it. True when the
320
+ * window's list took one of those claims — a frame that is not a pure
321
+ * scroll (`_applyScrollBlits`).
322
+ */
323
+ _settleRigidMoves(moves, width, height) {
324
+ let best = null;
325
+ let most = 0;
326
+ // a scroll armed while the pass ran is the one copy the frame gets
327
+ if (Array.isArray(this._damage) && !this._pendingScrolls?.size) {
328
+ for (const move of moves) {
329
+ const region = this._moveBlitRegion(move, width, height);
330
+ const size = region ? rectArea(region.dest) : 0;
331
+ if (size > most) {
332
+ best = { move, region };
333
+ most = size;
334
+ }
335
+ }
336
+ }
337
+ let took = false;
338
+ // the others first: the copy has to account for what they claim
339
+ for (const move of moves) {
340
+ if (move !== best?.move && this._claimMove(move)) took = true;
341
+ }
342
+ if (best && !this._blitRigidMove(best.move, best.region)) {
343
+ if (this._claimMove(best.move)) took = true;
344
+ }
345
+ return took;
346
+ }
347
+
348
+ /**
349
+ * A move claimed the plain way, as `Node._claimRigidMove` claims one no
350
+ * copy is waiting on: where the subtree's pixels were and where they went,
351
+ * each cut to what its clipping ancestors let reach the surface.
352
+ */
353
+ _claimMove({ node, was, shift }) {
354
+ const cap = this._damageRectCap();
355
+ let took = false;
356
+ for (const end of [was, shiftRect(was, shift.x, shift.y)]) {
357
+ const clipped = node._clippedByAncestors(end);
358
+ if (clipped && this._claimLayoutMove(clipped, node, cap)) took = true;
359
+ }
360
+ return took;
361
+ }
362
+
363
+ /**
364
+ * Where a move's copy would go, or null when there is none to make.
365
+ * `dest` is the part of the subtree's opaque cover (`_opaqueCover`) that
366
+ * its clipping ancestors let through where it is and, moved back, where
367
+ * it was; `src` is `dest` moved back. A clip cuts a whole pixel whole only
368
+ * on a whole pixel and away from a rounded corner, so each clip is taken
369
+ * that way (`clipBoxOf`), and the window's edge is one more.
370
+ *
371
+ * A box with nothing drawn inside it is left to its repaint: that is one
372
+ * fill, where the copy is a CopyArea and a strip on each side.
373
+ */
374
+ _moveBlitRegion({ node, shift }, width, height) {
375
+ if (node.destroyed || node.root !== this || node.hidden) return null;
376
+ if (node.kind === 'box' && node.paintOrder().length === 0) return null;
377
+ const cover = node._opaqueCover();
378
+ if (!cover) return null;
379
+ let clip = { x: 0, y: 0, width, height };
380
+ for (let n = node.parent; n && n !== this && clip; n = n.parent) {
381
+ if (!n.clipsChildren()) continue;
382
+ const box = clipBoxOf(n);
383
+ clip = box && intersectRects(clip, box);
384
+ }
385
+ if (!clip) return null;
386
+ const here = intersectRects(cover.rect, clip);
387
+ const dest =
388
+ here && intersectRects(here, shiftRect(clip, shift.x, shift.y));
389
+ if (!dest || rectArea(dest) < MOVE_BLIT_MIN_AREA) return null;
390
+ const src = shiftRect(dest, -shift.x, -shift.y);
391
+ if (rectArea(unionRect(src, dest)) > rectArea(dest) * MOVE_BLIT_MAX_SPAN) {
392
+ return null;
393
+ }
394
+ const holes = [];
395
+ for (const hole of cover.holes) {
396
+ const inside = intersectRects(hole, dest);
397
+ if (inside) holes.push(inside);
398
+ }
399
+ return { dest, src, holes };
400
+ }
401
+
402
+ /**
403
+ * Copy a moved subtree's pixels where they went, and narrow what the
404
+ * frame owes the move to what the copy cannot supply. True when it did;
405
+ * false leaves the frame's damage as it was, for `_claimMove`.
406
+ *
407
+ * The copy is of what the subtree showed where it covers, moved as far as
408
+ * it moved (`dest`). Nothing under it shows through there, so the copy is
409
+ * right wherever its own drawing is all that is there — and the frame
410
+ * repaints the rest:
411
+ *
412
+ * - what the move uncovered, and the parts of the subtree the copy did
413
+ * not land on — its rounded corners, a shadow, a child reaching out of
414
+ * it: where its pixels were and where they went, less `dest`;
415
+ * - what is drawn over it: inside `dest` where it is, and where the copy
416
+ * carried what it covered (`_drawnOverMove`);
417
+ * - the frame's other claims, by whose they are (`_claimSide`). One from
418
+ * under the subtree is repainted outside `dest` alone, since nothing of
419
+ * it shows inside: the element that draws the card a mounted body sits
420
+ * on claims the box the card moved through, and most of that box is the
421
+ * body's. One from inside the subtree is repainted where it stands and
422
+ * moved with the copy, which covers it whichever side of the layout
423
+ * pass it was named on. One from over it, or from anywhere the log
424
+ * cannot say, is repainted where it stands and where the copy carried
425
+ * what it covered.
426
+ *
427
+ * Past `MOVE_BLIT_MAX_REPAINT` of `dest` those repaint most of what the
428
+ * copy saves, and the plain claims have the frame.
429
+ */
430
+ _blitRigidMove({ node, was, shift }, { src, dest, holes }) {
431
+ const dx = shift.x;
432
+ const dy = shift.y;
433
+ const target = this._blitTarget(node);
434
+ if (target?.top !== this) return false;
435
+ const over = this._drawnOverMove(node, src, dest, dx, dy);
436
+ if (!over) return false;
437
+ // each ancestor of the subtree, with its child on the way down to it
438
+ const chain = new Map();
439
+ for (let n = node.parent, child = node; n; child = n, n = n.parent) {
440
+ chain.set(n, child);
441
+ if (n === this) break;
442
+ }
443
+ // Assembled uncapped: a cap merge would pick two pieces either side of
444
+ // the copy and put it back. Pieces cut around the same `dest` nest —
445
+ // bands above and below it full width, pieces beside it its rows only —
446
+ // so what overlaps coalesces without reaching into it.
447
+ let rects = [];
448
+ const add = (rect) => {
449
+ if (rect) rects = addDamageRect(rects, rect, Infinity);
450
+ };
451
+ for (const { rect, source } of this._claimsBySource()) {
452
+ const side = this._claimSide(source, node, chain);
453
+ if (side === BELOW) {
454
+ for (const piece of outside(rect, dest)) add(piece);
455
+ } else if (side === INSIDE) {
456
+ add(rect);
457
+ add(shiftRect(rect, dx, dy));
458
+ } else {
459
+ add(rect);
460
+ add(intersectRects(shiftRect(rect, dx, dy), dest));
461
+ }
462
+ }
463
+ // where the subtree's pixels were and where they went: its box when it
464
+ // inks nothing outside it, which leaves the strips the move uncovered
465
+ const ink = node._inkBox();
466
+ const ends = ink
467
+ ? [shiftRect(ink, -dx, -dy), ink]
468
+ : [was, shiftRect(was, dx, dy)];
469
+ for (const end of ends) {
470
+ const clipped = node._clippedByAncestors(end);
471
+ if (!clipped) continue;
472
+ for (const piece of outside(clipped, dest)) add(piece);
473
+ }
474
+ for (const rect of over) add(rect);
475
+ // …and where the copy is not opaque: a rounded corner shows what was
476
+ // under it where it was, and has to show what is under it here
477
+ for (const hole of holes) add(hole);
478
+ // Each rect is a paint pass, and the frame's cap on them is the window's
479
+ // price for one (`_damageRectCap`). Past it the cap merges pieces either
480
+ // side of the copy into the box around them, and that box reaches into
481
+ // the copied content and repaints the widgets along its edge — on X11,
482
+ // four passes, a rounded card's four corners and the strip it uncovered
483
+ // cost more requests and more bytes than the card they spare. The plain
484
+ // claims have that frame.
485
+ if (rects.length > this._damageRectCap()) return false;
486
+ // the list is disjoint, so the sum is the area it covers
487
+ let repainted = 0;
488
+ for (const rect of rects) {
489
+ const inside = intersectRects(rect, dest);
490
+ if (inside) repainted += rectArea(inside);
491
+ }
492
+ if (repainted > rectArea(dest) * MOVE_BLIT_MAX_REPAINT) return false;
493
+ // the verb moves the band that survives inside the rect it is handed,
494
+ // so handed where the pixels are and where they go, that band is `dest`
495
+ if (!target.blit(unionRect(src, dest), dx, dy)) return false;
496
+ this._damage = rects;
497
+ // what the list holds now is no longer the claims it was built from
498
+ this._claimLog = null;
499
+ return true;
500
+ }
501
+
502
+ /**
503
+ * This frame's claims, each with the node that made it: the log, when it
504
+ * holds exactly what the list does. Replayed through the same merge it has
505
+ * to come out as the list itself — anything else means a writer of the
506
+ * list that did not log, and a log that says nothing reliable. Otherwise
507
+ * the list's own rects with no node, which `_claimSide` reads as drawn
508
+ * over the subtree.
509
+ */
510
+ _claimsBySource() {
511
+ const log = this._claimLog;
512
+ const damage = this._damage;
513
+ if (log !== null) {
514
+ const cap = this._damageRectCap();
515
+ let replay = [];
516
+ for (const { rect } of log) replay = addDamageRect(replay, rect, cap);
517
+ if (sameRects(replay, damage)) return log;
518
+ }
519
+ return damage.map((rect) => ({ rect, source: null }));
520
+ }
521
+
522
+ /**
523
+ * Where a claim's node paints against the subtree being copied — inside
524
+ * it, under it or over it — found at the nearest ancestor the two share,
525
+ * by which of its children on the way down comes first in its paint
526
+ * order. An ancestor's own claim is over: its border, its outline and its
527
+ * scrollbars are drawn after its children. So is a claim with no node,
528
+ * the window's, or one whose node has left the tree.
529
+ */
530
+ _claimSide(source, node, chain) {
531
+ if (
532
+ !source ||
533
+ source === this ||
534
+ source.destroyed ||
535
+ source.root !== this
536
+ ) {
537
+ return ABOVE;
538
+ }
539
+ for (let n = source, branch = null; n; branch = n, n = n.parent) {
540
+ if (n === node) return INSIDE;
541
+ const mine = chain.get(n);
542
+ if (mine === undefined) continue;
543
+ if (branch === null) return ABOVE;
544
+ const order = n.paintOrder();
545
+ const at = order.indexOf(mine);
546
+ const theirs = order.indexOf(branch);
547
+ return at !== -1 && theirs !== -1 && theirs < at ? BELOW : ABOVE;
548
+ }
549
+ return ABOVE;
550
+ }
551
+
552
+ /**
553
+ * What is drawn over a moved subtree, as the rects inside `dest` a copy
554
+ * leaves wrong: where each thing is, and where the copy carried what it
555
+ * covered of `src`. The later siblings in paint order — the subtree's,
556
+ * and each ancestor's up to the window — and each ancestor's own drawing
557
+ * over its children: the border, drawn inside its box and over them, as
558
+ * far in as a rounded one bends; the outline, outside the box, which a
559
+ * child reaches only past an ancestor that does not clip; the scrollbars.
560
+ *
561
+ * Null when an ancestor draws anything else over its children — an
562
+ * element that overrides `paint` — whose reach nothing here knows.
563
+ */
564
+ _drawnOverMove(node, src, dest, dx, dy) {
565
+ const out = [];
566
+ const reach = unionRect(src, dest);
567
+ const over = (rect) => {
568
+ if (!rect || !rectsOverlap(rect, reach)) return;
569
+ const box = outerPixels(rect);
570
+ const here = intersectRects(box, dest);
571
+ if (here) out.push(here);
572
+ const carried = intersectRects(shiftRect(box, dx, dy), dest);
573
+ if (carried) out.push(carried);
574
+ };
575
+ for (let n = node.parent, child = node; n; child = n, n = n.parent) {
576
+ if (n !== this) {
577
+ if (!drawsKnownOverChildren(n)) return null;
578
+ const style = n.style ?? EMPTY_STYLE;
579
+ const bw = resolveBorderWidths(style, n.direction);
580
+ const ring = Math.max(bw.top, bw.right, bw.bottom, bw.left);
581
+ if (ring > 0) {
582
+ const inner = insetRect(
583
+ n.abs,
584
+ Math.max(ring, style.borderRadius ?? 0) + DAMAGE_SLOP,
585
+ );
586
+ const outer = insetRect(n.abs, -DAMAGE_SLOP);
587
+ for (const piece of outside(outer, inner)) over(piece);
588
+ }
589
+ const outline = n._outlineExtent();
590
+ if (outline > 0 && !n.clipsChildren()) {
591
+ const around = insetRect(n.abs, -(outline + DAMAGE_SLOP));
592
+ for (const piece of outside(around, n.abs)) over(piece);
593
+ }
594
+ }
595
+ if (typeof n._scrollbars === 'function') {
596
+ for (const bar of n._scrollbars()) {
597
+ over(insetRect(scrollbarTrackRect(bar), -DAMAGE_SLOP));
598
+ }
599
+ }
600
+ const order = n.paintOrder();
601
+ const at = order.indexOf(child);
602
+ for (let i = at === -1 ? 0 : at + 1; i < order.length; i++) {
603
+ // on a layer of its own: nothing of it is in the bitmap
604
+ if (!order[i]._promoted) over(order[i].paintBounds());
605
+ }
606
+ if (n === this) break;
607
+ }
608
+ return out;
609
+ }
610
+ }
package/src/nodes/node.js CHANGED
@@ -38,6 +38,7 @@ import { NodeInvalidate } from './invalidate.js';
38
38
  import { CUSTOM_SELF_DAMAGED, THEME_SCOPE } from './kinds.js';
39
39
  import { NodeLayout } from './layout.js';
40
40
  import { NodeLayoutHost } from './layouthost.js';
41
+ import { NodeMoveBlit } from './moveblit.js';
41
42
  import { NodePaint } from './paint.js';
42
43
  import { NodePosition } from './position.js';
43
44
  import { NodeQueries } from './queries.js';
@@ -620,21 +621,18 @@ export class Node {
620
621
  typeof style.height === 'number'
621
622
  ? 'position'
622
623
  : 'props';
623
- // A child of a `<glarea>` that only moved (issue #644): its pixels are
624
- // on a pane, one shift away, and the layout pass reports the move to
625
- // the overlay rather than claiming it (src/glnodes.js). A claim of the
626
- // subtree here, before and after, would be the repaint the overlay
627
- // exists to skip — and the pass claims both ends of the move itself
628
- // whenever the overlay cannot move the pixels.
629
- //
630
- // …and so is a node an element's pan carries (`scrollContents`'s
631
- // riders): its pixels move with the region the frame blits, and the
632
- // pass claims what they leave outside it (src/nodes/layout.js).
633
- if (
634
- (this.parent?.isGlArea || this._ridesBlitOf !== null) &&
635
- moved &&
636
- !this.paintChanged(newProps, prev)
637
- ) {
624
+ // A node that only moved claims nothing here: the layout pass claims
625
+ // both ends of the move itself — where the subtree's pixels were and
626
+ // where they went, once (`_beginRigidMove`), or node by node through
627
+ // the diff when the move turns out to be more than a move — and a
628
+ // claim of the subtree here, before and after, would be the repaint
629
+ // that three copies exist to skip. A child of a `<glarea>` is moved
630
+ // on its pane (issue #644, src/glnodes.js); a node an element's pan
631
+ // carries rides the region the frame blits (`scrollContents`'s
632
+ // riders, src/nodes/layout.js); and anything else that covers its
633
+ // box opaquely is copied in the window (issue #681,
634
+ // src/nodes/moveblit.js). Each claims what it cannot copy.
635
+ if (moved && !this.paintChanged(newProps, prev)) {
638
636
  this.invalidate(true, NO_DAMAGE, reason);
639
637
  } else {
640
638
  this._invalidateLayout(reason);
@@ -920,6 +918,7 @@ installMethods(
920
918
  NodeHitTest,
921
919
  NodeInvalidate,
922
920
  NodeScrollBlit,
921
+ NodeMoveBlit,
923
922
  NodePaint,
924
923
  NodeBoxPaint,
925
924
  NodeSelectable,