react-x11 2.11.0 → 2.12.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.
- package/README.md +278 -129
- package/package.json +10 -3
- package/src/Reconciler.js +15 -17
- package/src/a11y.js +2 -2
- package/src/anchor.js +7 -5
- package/src/bootstrap.js +14 -0
- package/src/clientmessage.js +1 -1
- package/src/cocoa/app.js +292 -49
- package/src/cocoa/bezels.js +175 -30
- package/src/cocoa/dnd.js +27 -13
- package/src/cocoa/fonts.js +3 -3
- package/src/cocoa/glarea.js +20 -3
- package/src/cocoa/main.d.ts +8 -0
- package/src/cocoa/main.js +43 -0
- package/src/cocoa/panehost.js +15 -5
- package/src/cocoa/presenter.js +13 -9
- package/src/cocoa/promotion.js +4 -7
- package/src/cocoa/relaunch.js +207 -0
- package/src/cocoa/threaded.js +246 -0
- package/src/cocoa/window.js +256 -42
- package/src/components/Select.js +2 -2
- package/src/components/anchor.js +3 -3
- package/src/components/native.js +12 -7
- package/src/components/theme.js +2 -2
- package/src/debug.js +1 -1
- package/src/decorations.js +1 -1
- package/src/editmenu.js +2 -2
- package/src/errors.js +46 -0
- package/src/events.js +6 -6
- package/src/foreignnodes.js +3 -2
- package/src/frames.js +2 -2
- package/src/glnodes.js +1 -1
- package/src/grid.js +1653 -0
- package/src/host.d.ts +230 -0
- package/src/host.js +11 -3
- package/src/imagesource.js +1 -1
- package/src/index.d.ts +21 -4
- package/src/index.js +9 -1
- package/src/layouts.js +721 -0
- package/src/node.d.ts +4 -2
- package/src/node.js +19 -21
- package/src/nodes/animation.js +644 -0
- package/src/nodes/box.js +21 -0
- package/src/nodes/boxpaint.js +473 -0
- package/src/nodes/canvas.js +269 -0
- package/src/nodes/cascade.js +600 -0
- package/src/nodes/damage.js +183 -0
- package/src/nodes/edithistory.js +124 -0
- package/src/nodes/editmenupopup.js +260 -0
- package/src/nodes/hittest.js +185 -0
- package/src/nodes/image.js +266 -0
- package/src/nodes/install.js +75 -0
- package/src/nodes/invalidate.js +465 -0
- package/src/nodes/kinds.js +31 -0
- package/src/nodes/layout.js +439 -0
- package/src/nodes/layouthost.js +949 -0
- package/src/nodes/node.js +868 -0
- package/src/nodes/paint.js +466 -0
- package/src/nodes/position.js +366 -0
- package/src/nodes/preedit.js +127 -0
- package/src/nodes/queries.js +330 -0
- package/src/nodes/rects.js +102 -0
- package/src/nodes/scrollable.js +891 -0
- package/src/nodes/scrollbars.js +138 -0
- package/src/nodes/scrollblit.js +1034 -0
- package/src/nodes/selectable.js +142 -0
- package/src/nodes/styling.js +225 -0
- package/src/nodes/text.js +649 -0
- package/src/nodes/textarea.js +391 -0
- package/src/nodes/textinput.js +1146 -0
- package/src/nodes/util.js +17 -0
- package/src/nodes/window/anchoring.js +161 -0
- package/src/nodes/window/capabilities.js +190 -0
- package/src/nodes/window/debugpaint.js +83 -0
- package/src/nodes/window/droptarget.js +145 -0
- package/src/nodes/window/floors.js +577 -0
- package/src/nodes/window/flush.js +334 -0
- package/src/nodes/window/hints.js +482 -0
- package/src/nodes/window/listeners.js +222 -0
- package/src/nodes/window/popup.js +71 -0
- package/src/nodes/window/size.js +591 -0
- package/src/nodes/window/window.js +945 -0
- package/src/palette.js +1 -1
- package/src/registry.js +7 -3
- package/src/styles.js +137 -15
- package/src/svgnodes.js +2 -1
- package/src/testing/harness.js +2 -2
- package/src/textselection.js +5 -3
- package/src/trace-registry.js +1 -1
- package/src/types/components.d.ts +38 -6
- package/src/types/elements.d.ts +11 -1
- package/src/types/nodes.d.ts +17 -2
- package/src/types/style.d.ts +94 -3
- package/src/windowstate.js +1 -1
- package/src/yoga.js +1 -1
- package/src/nodes.js +0 -13120
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
// The scroll blit (issues #138, #398, #533). A pure-scroll frame does not
|
|
2
|
+
// repaint the viewport: the band that stays visible is already in the
|
|
3
|
+
// window's backing pixmap, one CopyArea from its new position, so the frame
|
|
4
|
+
// repaints only the strip the scroll exposed and the scrollbars. Here are the
|
|
5
|
+
// gates that decide when that is safe and worth it, the ledger a scroll
|
|
6
|
+
// container keeps while its blit is armed, and WindowNode's half, which
|
|
7
|
+
// applies the blits at flush. Every failure falls back to repainting the
|
|
8
|
+
// viewport, so the fast path can cost correctness nothing.
|
|
9
|
+
|
|
10
|
+
import { EMPTY_STYLE, resolveBorderWidths } from '../styles.js';
|
|
11
|
+
import { DAMAGE_SLOP, addDamageRect } from './damage.js';
|
|
12
|
+
import {
|
|
13
|
+
rectContains,
|
|
14
|
+
isIntegerRect,
|
|
15
|
+
insetRect,
|
|
16
|
+
innerPixels,
|
|
17
|
+
intersectRects,
|
|
18
|
+
unionRect,
|
|
19
|
+
rectArea,
|
|
20
|
+
cornerSquaresOverlap,
|
|
21
|
+
rectsOverlap,
|
|
22
|
+
} from './rects.js';
|
|
23
|
+
import { scrollbarTrackRect } from './scrollbars.js';
|
|
24
|
+
import { debugPaint } from './window/debugpaint.js';
|
|
25
|
+
|
|
26
|
+
// --- scroll blitting (issue #138) ---------------------------------------
|
|
27
|
+
//
|
|
28
|
+
// A pure-scroll frame does not have to repaint the viewport: the band that
|
|
29
|
+
// stays visible is already in ntk's backing pixmap, one CopyArea away from
|
|
30
|
+
// its new position (ntk >= 4.3, Window.scrollRegion). The frame then only
|
|
31
|
+
// repaints the strip the scroll exposed, plus the scrollbar tracks. The
|
|
32
|
+
// gates below decide when that is safe *and* worth it; every failure falls
|
|
33
|
+
// back to today's full-viewport repaint, so the fast path can cost
|
|
34
|
+
// correctness nothing — the worst mistake it can make is not firing.
|
|
35
|
+
|
|
36
|
+
// Below this viewport area a repaint is one cheap pass and the blit's
|
|
37
|
+
// bookkeeping (a safety walk over the tree, extra damage rects, an extra
|
|
38
|
+
// request) costs more than it saves.
|
|
39
|
+
const SCROLL_BLIT_MIN_AREA = 48 * 1024;
|
|
40
|
+
|
|
41
|
+
// Escape hatch, read once like the other switches: for measuring the blit
|
|
42
|
+
// against the plain path on the same build, and as first aid if a scroll
|
|
43
|
+
// ever misrenders in the field. `=== '1'` like REACT_X11_NO_PAINT_CACHE —
|
|
44
|
+
// a truthy check would read NO_SCROLL_BLIT=0 as "disable the blit", and a
|
|
45
|
+
// stale export like that is exactly the kind of thing a cross-machine
|
|
46
|
+
// performance comparison trips over.
|
|
47
|
+
const NO_SCROLL_BLIT = process.env.REACT_X11_NO_SCROLL_BLIT === '1';
|
|
48
|
+
|
|
49
|
+
// If less than this fraction of the viewport survives the shift, the
|
|
50
|
+
// exposed strip is most of a repaint anyway.
|
|
51
|
+
const SCROLL_BLIT_MIN_KEEP = 0.5;
|
|
52
|
+
|
|
53
|
+
// …and how much of it the frame may end up repainting anyway. The strip and
|
|
54
|
+
// the scrollbar repair sit under this by a wide margin; what can push past
|
|
55
|
+
// it is a ledger repair (issue #398) that the damage cap had to merge with
|
|
56
|
+
// the scrollbar column, whose box then reaches back across the viewport.
|
|
57
|
+
// Past this the blit is buying a shift and paying for the viewport anyway.
|
|
58
|
+
const SCROLL_BLIT_MAX_REPAINT = 0.75;
|
|
59
|
+
|
|
60
|
+
// A scroll that must not blit this frame: content inside the viewport
|
|
61
|
+
// already changed (see the arming check in scrollTo, and the claim-time
|
|
62
|
+
// cancel in WindowNode.invalidate — react-x11#295). Truthy on purpose, so
|
|
63
|
+
// scrollTo's `??=` cannot re-arm over it, and reset by _applyScrollBlits'
|
|
64
|
+
// up-front clear like any real origin, so it lives exactly one frame.
|
|
65
|
+
export const BLIT_POISONED = Object.freeze({ poisoned: true });
|
|
66
|
+
|
|
67
|
+
// A frame armed by `Node.scrollContents` rather than by a scroll offset
|
|
68
|
+
// (issue #303): there is no origin to record, because the element handed
|
|
69
|
+
// over the shift itself. Truthy for the same reason as the poison — so the
|
|
70
|
+
// `??=` in both arming paths reads "already armed" — with the rect and the
|
|
71
|
+
// net delta in `_pendingBlitContents` beside it.
|
|
72
|
+
const BLIT_CONTENTS = Object.freeze({ contents: true });
|
|
73
|
+
|
|
74
|
+
// How much of what changed inside a blitting viewport the ledger will carry
|
|
75
|
+
// before the frame gives up and repaints the viewport instead (issue #398).
|
|
76
|
+
// A virtualized list's scroll frame changes a handful of regions — the two
|
|
77
|
+
// spacers and the entering rows — and past that the blit plus a scatter of
|
|
78
|
+
// repaints stops being cheaper than the one pass it replaced. The area is
|
|
79
|
+
// what the regions add to the strip the frame repaints anyway, a pixel two
|
|
80
|
+
// of them share counted once.
|
|
81
|
+
const BLIT_MAX_CLAIMS = 8;
|
|
82
|
+
const BLIT_MAX_CLAIM_AREA = 0.25;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The band a scrollbar's thumb travels in, as the line along the track's
|
|
86
|
+
* inner edge and the viewport edge beyond it: `bottom` for a horizontal
|
|
87
|
+
* bar, `right` for a vertical one, `left` for the vertical bar of an RTL
|
|
88
|
+
* viewport. A whole pixel, just outside the track's slop, so the parts a
|
|
89
|
+
* rect is cut into still meet on a pixel boundary once they are snapped.
|
|
90
|
+
*/
|
|
91
|
+
function scrollbarBand(bar, vp) {
|
|
92
|
+
const track = scrollbarTrackRect(bar);
|
|
93
|
+
if (bar.axis === 'x') {
|
|
94
|
+
return track.y + track.height / 2 > vp.y + vp.height / 2
|
|
95
|
+
? { edge: 'bottom', at: Math.floor(track.y) }
|
|
96
|
+
: { edge: 'top', at: Math.ceil(track.y + track.height) };
|
|
97
|
+
}
|
|
98
|
+
return track.x + track.width / 2 > vp.x + vp.width / 2
|
|
99
|
+
? { edge: 'right', at: Math.floor(track.x) }
|
|
100
|
+
: { edge: 'left', at: Math.ceil(track.x + track.width) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Rects sharing a whole edge joined into the one rect they make, until no
|
|
105
|
+
* two do. Exact — a join is the union of the two, so the list stays
|
|
106
|
+
* disjoint — and it only saves a pass: a rect cut at a band's line whose
|
|
107
|
+
* parts met nothing on either side comes back whole.
|
|
108
|
+
*/
|
|
109
|
+
function joinAlongEdges(rects) {
|
|
110
|
+
const out = [...rects];
|
|
111
|
+
for (let joined = true; joined;) {
|
|
112
|
+
joined = false;
|
|
113
|
+
for (let i = 0; i < out.length && !joined; i++) {
|
|
114
|
+
for (let j = i + 1; j < out.length && !joined; j++) {
|
|
115
|
+
const a = out[i];
|
|
116
|
+
const b = out[j];
|
|
117
|
+
let union = null;
|
|
118
|
+
if (
|
|
119
|
+
a.x === b.x &&
|
|
120
|
+
a.width === b.width &&
|
|
121
|
+
(a.y + a.height === b.y || b.y + b.height === a.y)
|
|
122
|
+
) {
|
|
123
|
+
const y = Math.min(a.y, b.y);
|
|
124
|
+
union = { x: a.x, y, width: a.width, height: a.height + b.height };
|
|
125
|
+
} else if (
|
|
126
|
+
a.y === b.y &&
|
|
127
|
+
a.height === b.height &&
|
|
128
|
+
(a.x + a.width === b.x || b.x + b.width === a.x)
|
|
129
|
+
) {
|
|
130
|
+
const x = Math.min(a.x, b.x);
|
|
131
|
+
union = { x, y: a.y, width: a.width + b.width, height: a.height };
|
|
132
|
+
}
|
|
133
|
+
if (!union) continue;
|
|
134
|
+
out.splice(j, 1);
|
|
135
|
+
out[i] = union;
|
|
136
|
+
joined = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `rect` cut along a band's line: the part on the band's side of it and
|
|
144
|
+
* the rest, either of them null where the line misses the rect. */
|
|
145
|
+
function splitAtBand(rect, band) {
|
|
146
|
+
const vertical = band.edge === 'left' || band.edge === 'right';
|
|
147
|
+
const start = vertical ? rect.x : rect.y;
|
|
148
|
+
const end = start + (vertical ? rect.width : rect.height);
|
|
149
|
+
const at = Math.min(Math.max(band.at, start), end);
|
|
150
|
+
const piece = (from, to) => {
|
|
151
|
+
if (to <= from) return null;
|
|
152
|
+
return vertical
|
|
153
|
+
? { x: from, y: rect.y, width: to - from, height: rect.height }
|
|
154
|
+
: { x: rect.x, y: from, width: rect.width, height: to - from };
|
|
155
|
+
};
|
|
156
|
+
const before = piece(start, at);
|
|
157
|
+
const after = piece(at, end);
|
|
158
|
+
return band.edge === 'right' || band.edge === 'bottom'
|
|
159
|
+
? { inside: after, outside: before }
|
|
160
|
+
: { inside: before, outside: after };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The area a list of rects covers, a pixel under several of them counted
|
|
165
|
+
* once. Exact, column by column: between each pair of neighbouring x
|
|
166
|
+
* edges, the rects spanning that column merge their y extents. Asked about
|
|
167
|
+
* a handful of rects at a time.
|
|
168
|
+
*/
|
|
169
|
+
function unionArea(rects) {
|
|
170
|
+
const xs = rects.flatMap((r) => [r.x, r.x + r.width]).sort((a, b) => a - b);
|
|
171
|
+
let total = 0;
|
|
172
|
+
for (let i = 1; i < xs.length; i++) {
|
|
173
|
+
const left = xs[i - 1];
|
|
174
|
+
const right = xs[i];
|
|
175
|
+
if (right <= left) continue;
|
|
176
|
+
const spans = rects
|
|
177
|
+
.filter((r) => r.x <= left && r.x + r.width >= right)
|
|
178
|
+
.sort((a, b) => a.y - b.y);
|
|
179
|
+
let covered = 0;
|
|
180
|
+
let reach = -Infinity;
|
|
181
|
+
for (const r of spans) {
|
|
182
|
+
const from = Math.max(r.y, reach);
|
|
183
|
+
const to = r.y + r.height;
|
|
184
|
+
if (to > from) covered += to - from;
|
|
185
|
+
reach = Math.max(reach, to);
|
|
186
|
+
}
|
|
187
|
+
total += covered * (right - left);
|
|
188
|
+
}
|
|
189
|
+
return total;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Do two overlapping rects cover exactly the box around them — one inside
|
|
193
|
+
* the other, or the two spanning the same extent along one axis? */
|
|
194
|
+
function unionIsBox(a, b) {
|
|
195
|
+
return (
|
|
196
|
+
rectContains(a, b) ||
|
|
197
|
+
rectContains(b, a) ||
|
|
198
|
+
(a.x === b.x && a.width === b.width) ||
|
|
199
|
+
(a.y === b.y && a.height === b.height)
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Node's half of the scroll blit, installed onto `Node.prototype` by node.js. */
|
|
204
|
+
export class NodeScrollBlit {
|
|
205
|
+
/**
|
|
206
|
+
* Is this node a scroll container that has a blit armed and still clean
|
|
207
|
+
* this frame (issue #398)?
|
|
208
|
+
*
|
|
209
|
+
* While it is, the window keeps a *ledger* of the regions that actually
|
|
210
|
+
* changed inside the viewport instead of cancelling the blit at the first
|
|
211
|
+
* sign of one. The coarse claims this node would otherwise make — its own
|
|
212
|
+
* box, which is all `paintBounds()` can say for a node that clips — would
|
|
213
|
+
* cover the whole band the blit is about to move and throw that ledger
|
|
214
|
+
* away, so the paths that make them take a finer route while this is true.
|
|
215
|
+
*
|
|
216
|
+
* `scrollContents` is out: an element blit already tests foreign claims
|
|
217
|
+
* against the rect it handed over (issue #309), and its region is not a
|
|
218
|
+
* viewport whose children *are* the scrolled content.
|
|
219
|
+
*/
|
|
220
|
+
_blitLedgerOpen() {
|
|
221
|
+
const from = this._pendingBlitFrom;
|
|
222
|
+
return (
|
|
223
|
+
from != null &&
|
|
224
|
+
from !== BLIT_POISONED &&
|
|
225
|
+
!this._pendingBlitContents &&
|
|
226
|
+
this._blitLedger != null
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Write one changed region into this viewport's ledger, in the coordinates
|
|
232
|
+
* it was named in. Returns false when the frame is better off repainting
|
|
233
|
+
* the viewport — too many regions to be worth the bookkeeping, or one big
|
|
234
|
+
* enough that there is nothing left for the blit to keep — which the
|
|
235
|
+
* caller turns into the poison the gate used to apply unconditionally.
|
|
236
|
+
*
|
|
237
|
+
* Which side of the frame's layout pass the rect came from decides
|
|
238
|
+
* whether it moves with the blit: a claim made during the commit names
|
|
239
|
+
* where the content sits *now*, and the blit is about to shift it, so
|
|
240
|
+
* `_applyScrollBlits` shifts the rect too. A claim raised once layout has
|
|
241
|
+
* run — the diff's, the reflow queue's — already names where it landed.
|
|
242
|
+
* Read off the window rather than passed in, so a claim from application
|
|
243
|
+
* code reached during the layout pass is filed on the right side of it.
|
|
244
|
+
*
|
|
245
|
+
* A region the ledger already holds is not another one. A claim that
|
|
246
|
+
* overlaps an entry and covers exactly the box around the two of them is
|
|
247
|
+
* folded into it: the same pixels, one entry. A held sticky node claims
|
|
248
|
+
* one rect twice — where it was, and where it is a delta along — and
|
|
249
|
+
* every sticky node held inside it claims inside those, so kept apart, a
|
|
250
|
+
* pane holding a few of them ran out of entries on regions it had already
|
|
251
|
+
* counted. Only between claims on the same side of the layout pass: the
|
|
252
|
+
* blit moves one kind and not the other.
|
|
253
|
+
*/
|
|
254
|
+
_recordBlitClaim(rect) {
|
|
255
|
+
const ledger = this._blitLedger;
|
|
256
|
+
if (!ledger) return false;
|
|
257
|
+
const inside = intersectRects(rect, this.abs);
|
|
258
|
+
// beside the band the blit moves: those pixels are painted the ordinary
|
|
259
|
+
// way, out of the frame's own damage
|
|
260
|
+
if (!inside) return true;
|
|
261
|
+
const pre = !this.root?._laidOut;
|
|
262
|
+
let claim = { ...inside, pre };
|
|
263
|
+
for (let i = ledger.length - 1; i >= 0; i--) {
|
|
264
|
+
const entry = ledger[i];
|
|
265
|
+
if (entry.pre !== pre || !rectsOverlap(entry, claim)) continue;
|
|
266
|
+
if (!unionIsBox(entry, claim)) continue;
|
|
267
|
+
claim = { ...unionRect(entry, claim), pre };
|
|
268
|
+
ledger.splice(i, 1);
|
|
269
|
+
// grown, it can fold an entry already passed over
|
|
270
|
+
i = ledger.length;
|
|
271
|
+
}
|
|
272
|
+
// …and a claim that covers the viewport leaves the blit nothing to keep
|
|
273
|
+
if (rectContains(claim, this.abs)) return false;
|
|
274
|
+
if (ledger.length >= BLIT_MAX_CLAIMS) return false;
|
|
275
|
+
ledger.push(claim);
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* How far the blit this viewport has pending moves the pixels it keeps:
|
|
281
|
+
* the delta `_applyScrollBlits` hands `scrollRegion`, from the offsets
|
|
282
|
+
* captured when the blit was armed to the ones in force now. `from` is
|
|
283
|
+
* that origin, for a caller that has already taken it off the node.
|
|
284
|
+
*
|
|
285
|
+
* It is the content's own move, and along x that is not always against
|
|
286
|
+
* the offset. `scrollX` is a distance from the start edge, which is the
|
|
287
|
+
* right-hand one under `direction: 'rtl'`, so there `_absolutizeChildren`
|
|
288
|
+
* carries the children right as it grows — and the band the blit keeps,
|
|
289
|
+
* the strip it exposes, the thumb it drags along and the claims in the
|
|
290
|
+
* ledger all go the way the children went.
|
|
291
|
+
*/
|
|
292
|
+
_blitShift(from = this._pendingBlitFrom) {
|
|
293
|
+
const dx = this.scrollX - from.x;
|
|
294
|
+
// 0 - x rather than -x: negating +0 yields -0, which survives into
|
|
295
|
+
// request buffers and test comparisons
|
|
296
|
+
return {
|
|
297
|
+
x: this.direction === 'rtl' ? 0 + dx : 0 - dx,
|
|
298
|
+
y: 0 - (this.scrollY - from.y),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* "The pixels in `rect` moved by (dx, dy); the rest of it is new" — the
|
|
304
|
+
* public form of the dance `<box overflow="scroll">` has been doing since
|
|
305
|
+
* issue #138, for an element with a viewport of its own (issue #303).
|
|
306
|
+
*
|
|
307
|
+
* A pan is a scroll in every way but the bookkeeping: it translates every
|
|
308
|
+
* pixel of the pane, so an element that can only say "everything changed"
|
|
309
|
+
* repaints the lot, sixty times a second, for a frame whose content is
|
|
310
|
+
* already on screen one shift away. This claims `rect` — the conservative
|
|
311
|
+
* answer, and the one that stands if anything below declines — and arms
|
|
312
|
+
* the frame to blit instead: at frame time core asks ntk to move the
|
|
313
|
+
* surviving band inside the backing store (`Window.scrollRegion`) and
|
|
314
|
+
* **narrows this claim to the band the shift exposed**, which is what
|
|
315
|
+
* `paintDamage()` then hands the paint. So the element draws the strip and
|
|
316
|
+
* nothing else, without ever asking whether the blit happened.
|
|
317
|
+
*
|
|
318
|
+
* `dx`/`dy` are **how far the pixels moved**, the sense `Surface.copyWithin`
|
|
319
|
+
* and `Window.scrollRegion` use rather than a scroll offset's — panning a
|
|
320
|
+
* graph right by 10 is `dx: 10`, and the exposed band is down the left
|
|
321
|
+
* edge. Whole pixels, both of them, and `rect` in window coordinates
|
|
322
|
+
* (`abs`, `contentBox()`, an event's `x`/`y`) and inside this node.
|
|
323
|
+
*
|
|
324
|
+
* The element promises one thing in return: that inside `rect` the frame
|
|
325
|
+
* really is that translation and nothing else. Every other way it could be
|
|
326
|
+
* false is core's to check — a claim from anywhere else reaching into the
|
|
327
|
+
* rect, a sibling drawing over it, a child of this node laid out on top of
|
|
328
|
+
* it, an ancestor's border ring or rounded corner, a layout pass that
|
|
329
|
+
* moved something — and each of them falls back to repainting the rect,
|
|
330
|
+
* which is the behaviour without this call at all.
|
|
331
|
+
*
|
|
332
|
+
* All of those are about `rect`, not about this node (issue #309). An
|
|
333
|
+
* element with furniture pinned to a corner of its pane — a minimap, zoom
|
|
334
|
+
* controls, a HUD strip that has to repaint on a pan frame and whose
|
|
335
|
+
* pixels must not ride the blit — carves it out of the region it shifts
|
|
336
|
+
* and claims it as ordinary damage. Those claims land beside the rect and
|
|
337
|
+
* the frame stays a blit.
|
|
338
|
+
*
|
|
339
|
+
* Returns whether the frame is still a blit candidate. The real answer
|
|
340
|
+
* arrives as `paintDamage()`, because most of the gates cannot be decided
|
|
341
|
+
* until the frame closes; a `false` here is only the ones that can.
|
|
342
|
+
*/
|
|
343
|
+
scrollContents(rect, dx, dy) {
|
|
344
|
+
const root = this.root;
|
|
345
|
+
if (
|
|
346
|
+
!root ||
|
|
347
|
+
this.destroyed ||
|
|
348
|
+
!rect ||
|
|
349
|
+
!(rect.width > 0) ||
|
|
350
|
+
!(rect.height > 0)
|
|
351
|
+
) {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
// Nothing moved, so there is nothing to claim either — an element
|
|
355
|
+
// rounding a gesture to whole pixels lands here on most events.
|
|
356
|
+
if (!dx && !dy) return true;
|
|
357
|
+
const pending = this._pendingBlitContents;
|
|
358
|
+
// The rect has to be the same one all frame: two different regions of
|
|
359
|
+
// one node shifting by different deltas is not one CopyArea, and the
|
|
360
|
+
// second claim would coalesce into the first past the point either can
|
|
361
|
+
// be told apart. Same for a Scrollable element that also scrolls its
|
|
362
|
+
// offsets this frame — two shifts of the same pixels, and a frame can
|
|
363
|
+
// only have one.
|
|
364
|
+
if (
|
|
365
|
+
this._pendingBlitFrom != null &&
|
|
366
|
+
(!pending ||
|
|
367
|
+
pending.rect.x !== rect.x ||
|
|
368
|
+
pending.rect.y !== rect.y ||
|
|
369
|
+
pending.rect.width !== rect.width ||
|
|
370
|
+
pending.rect.height !== rect.height)
|
|
371
|
+
) {
|
|
372
|
+
this._pendingBlitFrom = BLIT_POISONED;
|
|
373
|
+
} else if (this._pendingBlitFrom == null && Array.isArray(root._damage)) {
|
|
374
|
+
// Arming is the one moment the evidence still exists — the claim
|
|
375
|
+
// below coalesces earlier ones into itself, after which a change
|
|
376
|
+
// inside the rect is indistinguishable from this call's own claim.
|
|
377
|
+
// The same reasoning, and the same poison rather than a disarm, as
|
|
378
|
+
// scrollTo (react-x11#295).
|
|
379
|
+
//
|
|
380
|
+
// The zone is `rect` itself, where scrollTo's is the viewport plus
|
|
381
|
+
// slop (issue #309): the claim recorded below *is* this rect, so a
|
|
382
|
+
// claim it could swallow has to overlap it, and every claim carries
|
|
383
|
+
// its own slop already — `paintBounds` inflates a node's region by
|
|
384
|
+
// `DAMAGE_SLOP` on every side, so ink that bleeds into `rect` is
|
|
385
|
+
// claimed overlapping it. Furniture *beside* the rect — a minimap in
|
|
386
|
+
// a corner the element carved out and repaints itself — is not a
|
|
387
|
+
// change to the pixels about to move.
|
|
388
|
+
for (const claimed of root._damage) {
|
|
389
|
+
if (rectsOverlap(claimed, rect)) {
|
|
390
|
+
this._pendingBlitFrom = BLIT_POISONED;
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const state = (this._pendingBlitContents ??= {
|
|
396
|
+
// our own copy: the caller's rect is very often the live
|
|
397
|
+
// `contentBox()` of a node that is about to be laid out again
|
|
398
|
+
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
399
|
+
dx: 0,
|
|
400
|
+
dy: 0,
|
|
401
|
+
});
|
|
402
|
+
// several pans in one frame blit once, by the net shift — the same
|
|
403
|
+
// coalescing scrollTo gets from recording an origin
|
|
404
|
+
state.dx += dx;
|
|
405
|
+
state.dy += dy;
|
|
406
|
+
this._pendingBlitFrom ??= BLIT_CONTENTS;
|
|
407
|
+
(root._pendingScrolls ??= new Set()).add(this);
|
|
408
|
+
// ...and the claim about to be recorded is the shift itself, not a
|
|
409
|
+
// reason to un-blit it. `state.rect` by identity, so a second call this
|
|
410
|
+
// frame is recognised as the same claim rather than as foreign damage.
|
|
411
|
+
root._scrollClaim = state.rect;
|
|
412
|
+
root.invalidate(false, state.rect, 'scroll');
|
|
413
|
+
root._scrollClaim = null;
|
|
414
|
+
return this._pendingBlitFrom !== BLIT_POISONED;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* `paintBounds()` with the one clip a damage claim must respect: a scroll
|
|
419
|
+
* container above this node that is waiting to blit (issue #398).
|
|
420
|
+
*
|
|
421
|
+
* A viewport clips its children, so the part of a claim outside its box is
|
|
422
|
+
* pixels that cannot appear — and leaving it in costs the blit the frame.
|
|
423
|
+
* A virtualized list is the shape that makes this concrete: its spacers
|
|
424
|
+
* are boxes thousands of pixels tall whose visible extent is a sliver or
|
|
425
|
+
* nothing at all, and their unclipped claims, coalesced into the scroll's
|
|
426
|
+
* own, leave `_blitKeptDamage` a damage rect many times the viewport to
|
|
427
|
+
* refuse. Null when the clip left nothing.
|
|
428
|
+
*
|
|
429
|
+
* Only while a blit is pending — outside that this is `paintBounds()` and
|
|
430
|
+
* one property read. Clipping every claim to every clipping ancestor
|
|
431
|
+
* would be correct too, and is a bigger change than the frame this is
|
|
432
|
+
* about.
|
|
433
|
+
*/
|
|
434
|
+
_claimBounds() {
|
|
435
|
+
const bounds = this.paintBounds();
|
|
436
|
+
const sv = this._blitViewport();
|
|
437
|
+
return sv ? intersectRects(bounds, sv.paintBounds()) : bounds;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** The scroll container above this node that is waiting to blit, if there
|
|
441
|
+
* is one — the viewport whose ledger this node's claims belong in, and
|
|
442
|
+
* whose box clips them (issue #398). One property read when no blit is
|
|
443
|
+
* pending, which is every frame that is not a scroll. */
|
|
444
|
+
_blitViewport() {
|
|
445
|
+
if (!this.root?._pendingScrolls?.size) return null;
|
|
446
|
+
for (let n = this.parent; n; n = n.parent) {
|
|
447
|
+
if (n._blitLedgerOpen()) return n;
|
|
448
|
+
}
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** WindowNode's half of the scroll blit, installed onto `WindowNode.prototype` by window/window.js. */
|
|
454
|
+
export class WindowScrollBlit {
|
|
455
|
+
/**
|
|
456
|
+
* The scroll-blit fast path (issue #138): when the frame is a *pure*
|
|
457
|
+
* scroll of one viewport, ask ntk to CopyArea the band that stays visible
|
|
458
|
+
* into its new place inside the backing store, and narrow this frame's
|
|
459
|
+
* damage from the whole viewport to the strip the scroll exposed (plus
|
|
460
|
+
* the scrollbar tracks, whose pixels the blit dragged along).
|
|
461
|
+
*
|
|
462
|
+
* Everything here is a gate, cheapest first, and every gate falls back to
|
|
463
|
+
* the claim scrollTo already recorded — the full-viewport repaint that
|
|
464
|
+
* has always been the behavior. The fast path can therefore cost
|
|
465
|
+
* correctness nothing: the worst mistake it can make is not firing.
|
|
466
|
+
*/
|
|
467
|
+
_applyScrollBlits(width, height, layoutMoved = false) {
|
|
468
|
+
const pending = this._pendingScrolls;
|
|
469
|
+
if (!pending?.size) return;
|
|
470
|
+
const nodes = [...pending];
|
|
471
|
+
pending.clear();
|
|
472
|
+
const from = nodes[0]._pendingBlitFrom;
|
|
473
|
+
const contents = nodes[0]._pendingBlitContents;
|
|
474
|
+
const ledger = nodes[0]._blitLedger;
|
|
475
|
+
for (const n of nodes) {
|
|
476
|
+
n._pendingBlitFrom = null;
|
|
477
|
+
n._pendingBlitContents = null;
|
|
478
|
+
n._blitLedger = null;
|
|
479
|
+
}
|
|
480
|
+
// two viewports scrolling in one frame is rare enough that sorting out
|
|
481
|
+
// whether their regions interact is not worth it
|
|
482
|
+
if (nodes.length !== 1) return;
|
|
483
|
+
const node = nodes[0];
|
|
484
|
+
// a poisoned frame (react-x11#295) falls back to the full-viewport
|
|
485
|
+
// claim the scroll recorded, like every other declined gate here
|
|
486
|
+
if (from === BLIT_POISONED) return;
|
|
487
|
+
if (NO_SCROLL_BLIT || !from || node.destroyed || node.root !== this) return;
|
|
488
|
+
const wnd = this.window;
|
|
489
|
+
if (typeof wnd?.scrollRegion !== 'function') return; // ntk without #139
|
|
490
|
+
// the debug overlays and the DevTools highlight draw over the whole
|
|
491
|
+
// window; a blit would drag shifted copies of them along
|
|
492
|
+
if (
|
|
493
|
+
debugPaint ||
|
|
494
|
+
process.env.REACT_X11_DEBUG_LAYOUT ||
|
|
495
|
+
this._highlight ||
|
|
496
|
+
this._traceUpdates
|
|
497
|
+
) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (!Array.isArray(this._damage)) return; // unbounded frame already
|
|
501
|
+
// the layout diff claimed real movement this pass — the frame is not a
|
|
502
|
+
// pure scroll, and a blit under rearranged content would shift stale
|
|
503
|
+
// pixels into place the repaint no longer covers
|
|
504
|
+
if (layoutMoved) return;
|
|
505
|
+
// From here the two arming paths part company: an element handed us the
|
|
506
|
+
// region and the shift itself (issue #303), where a scroll container is
|
|
507
|
+
// its whole viewport shifted by the change in its offsets.
|
|
508
|
+
if (contents) {
|
|
509
|
+
this._applyContentsBlit(node, contents, width, height);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
// Back where the frame started: a burst of scrolls that went one way
|
|
513
|
+
// and back again inside one refresh — the Cocoa wheel folds a burst
|
|
514
|
+
// into one paced frame, and a trackpad's reversal is exactly that.
|
|
515
|
+
// Nothing on screen moved, so there is no band to shift and the
|
|
516
|
+
// scroll's claim on the whole viewport is owed nothing; what did change
|
|
517
|
+
// inside it is in the ledger, and the ledger is what gets repainted.
|
|
518
|
+
// (Every pixel-shift gate below is moot for a shift of nothing.)
|
|
519
|
+
if (from.x === node.scrollX && from.y === node.scrollY) {
|
|
520
|
+
const box = node.abs;
|
|
521
|
+
const kept = this._blitKeptDamage(box);
|
|
522
|
+
if (!kept) return;
|
|
523
|
+
let rects = kept;
|
|
524
|
+
for (const claim of ledger ?? []) {
|
|
525
|
+
const inside = intersectRects(claim, box);
|
|
526
|
+
if (inside) rects = addDamageRect(rects, inside);
|
|
527
|
+
}
|
|
528
|
+
this._damage = rects;
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
// children clip to the border box, so a border ring or rounded corner
|
|
532
|
+
// would be shifted like content — any painted side counts
|
|
533
|
+
const blitBorder = resolveBorderWidths(node.style, node.direction);
|
|
534
|
+
if (
|
|
535
|
+
blitBorder.top > 0 ||
|
|
536
|
+
blitBorder.right > 0 ||
|
|
537
|
+
blitBorder.bottom > 0 ||
|
|
538
|
+
blitBorder.left > 0 ||
|
|
539
|
+
node.style.borderRadius > 0
|
|
540
|
+
) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const vp = node.abs;
|
|
544
|
+
// fractional geometry or offsets change every pixel; only a whole-pixel
|
|
545
|
+
// shift is a copy
|
|
546
|
+
if (!isIntegerRect(vp)) return;
|
|
547
|
+
if (!Number.isInteger(from.x) || !Number.isInteger(from.y)) return;
|
|
548
|
+
// How far the kept pixels move, which is how far the content moved —
|
|
549
|
+
// the sense `scrollRegion` takes and `_applyContentsBlit` is handed.
|
|
550
|
+
// Not the change in the offsets: along x in RTL the two agree only in
|
|
551
|
+
// size (`_blitShift`).
|
|
552
|
+
const { x: dx, y: dy } = node._blitShift(from);
|
|
553
|
+
if (!Number.isInteger(dx) || !Number.isInteger(dy)) return;
|
|
554
|
+
if (dx === 0 && dy === 0) return;
|
|
555
|
+
// one axis at a time: a diagonal scroll needs an L of strips whose
|
|
556
|
+
// pieces overlap the bar rects, and overlapping damage rects merge into
|
|
557
|
+
// their box (translucent paint must not run twice) — the merges balloon
|
|
558
|
+
// toward the whole viewport and the blit stops paying. Wheels scroll
|
|
559
|
+
// one axis per event, so this costs almost nothing real.
|
|
560
|
+
if (dx !== 0 && dy !== 0) return;
|
|
561
|
+
if (Math.abs(dx) >= vp.width || Math.abs(dy) >= vp.height) return;
|
|
562
|
+
// the worth-it heuristics: below these, the plain repaint is one cheap
|
|
563
|
+
// pass and the blit's bookkeeping outweighs it
|
|
564
|
+
const area = vp.width * vp.height;
|
|
565
|
+
if (area < SCROLL_BLIT_MIN_AREA) return;
|
|
566
|
+
const kept = (vp.width - Math.abs(dx)) * (vp.height - Math.abs(dy));
|
|
567
|
+
if (kept < area * SCROLL_BLIT_MIN_KEEP) return;
|
|
568
|
+
// the band shifts in from inside the window; a viewport poking out of
|
|
569
|
+
// it has nothing there to shift
|
|
570
|
+
if (
|
|
571
|
+
vp.x < 0 ||
|
|
572
|
+
vp.y < 0 ||
|
|
573
|
+
vp.x + vp.width > width ||
|
|
574
|
+
vp.y + vp.height > height
|
|
575
|
+
) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const keep = this._blitKeptDamage(vp);
|
|
579
|
+
if (!keep) return;
|
|
580
|
+
// What changed inside the viewport while the blit was armed, in the
|
|
581
|
+
// coordinates the frame is about to paint in (issue #398). A claim made
|
|
582
|
+
// during the commit named where the content sat before the shift, and
|
|
583
|
+
// the blit is about to move those pixels by the frame's delta, so it
|
|
584
|
+
// moves with them; a claim from the layout diff already landed there.
|
|
585
|
+
//
|
|
586
|
+
// Repainting the result is what makes the blit honest about them: the
|
|
587
|
+
// blit translates the previous frame's rendering, which is correct
|
|
588
|
+
// everywhere the content did not change, and these are the places it
|
|
589
|
+
// did. That is finer than the strip-only rule issue #398 asks for and
|
|
590
|
+
// no more complicated, so a mid-viewport change — a row upgrading from
|
|
591
|
+
// skeleton to content while the list scrolls — rides the fast path too
|
|
592
|
+
// instead of falling back to the whole viewport.
|
|
593
|
+
//
|
|
594
|
+
// The strip the shift exposed, on the side the pixels moved away from,
|
|
595
|
+
// full breadth — it also covers the corner gutter beside the bars, whose
|
|
596
|
+
// old pixels the blit did not overwrite — is repainted on every frame
|
|
597
|
+
// the blit serves, whatever the ledger holds.
|
|
598
|
+
const axis = dy !== 0 ? 'y' : 'x';
|
|
599
|
+
const delta = dy !== 0 ? dy : dx;
|
|
600
|
+
const strip =
|
|
601
|
+
axis === 'y'
|
|
602
|
+
? {
|
|
603
|
+
x: vp.x,
|
|
604
|
+
y: delta > 0 ? vp.y : vp.y + vp.height + delta,
|
|
605
|
+
width: vp.width,
|
|
606
|
+
height: Math.abs(delta),
|
|
607
|
+
}
|
|
608
|
+
: {
|
|
609
|
+
x: delta > 0 ? vp.x : vp.x + vp.width + delta,
|
|
610
|
+
y: vp.y,
|
|
611
|
+
width: Math.abs(delta),
|
|
612
|
+
height: vp.height,
|
|
613
|
+
};
|
|
614
|
+
const repairs = [];
|
|
615
|
+
for (const claim of ledger ?? []) {
|
|
616
|
+
const moved = claim.pre
|
|
617
|
+
? {
|
|
618
|
+
x: claim.x + dx,
|
|
619
|
+
y: claim.y + dy,
|
|
620
|
+
width: claim.width,
|
|
621
|
+
height: claim.height,
|
|
622
|
+
}
|
|
623
|
+
: claim;
|
|
624
|
+
const inside = intersectRects(moved, vp);
|
|
625
|
+
if (inside) repairs.push(inside);
|
|
626
|
+
}
|
|
627
|
+
// Past this the blit plus a scatter of repaints is no longer cheaper
|
|
628
|
+
// than the one full-viewport pass it replaced. Priced as what the
|
|
629
|
+
// repairs add to the strip: a pixel under two of them, or under one and
|
|
630
|
+
// the strip, is painted once. Summed, a held sticky header paid for its
|
|
631
|
+
// rect twice — it claims where it was and where it is, a delta apart —
|
|
632
|
+
// then again for each sticky node held inside it, and scrolled back up
|
|
633
|
+
// it paid a third time for the strip the blit dragged its copy across.
|
|
634
|
+
const repairArea = repairs.length
|
|
635
|
+
? unionArea([strip, ...repairs]) - rectArea(strip)
|
|
636
|
+
: 0;
|
|
637
|
+
if (repairArea > area * BLIT_MAX_CLAIM_AREA) return;
|
|
638
|
+
if (!this._scrollBlitSafe(node, vp)) return;
|
|
639
|
+
// The band the scrolled bar's thumb travels in is repaired on its own.
|
|
640
|
+
// The thumb's rects are thin and run along the viewport's edge, and a
|
|
641
|
+
// rect reaching into the band from inside — a column held down the
|
|
642
|
+
// pane's whole height, the strip at the end the thumb has come to —
|
|
643
|
+
// merged with them into the box around both, which reaches back across
|
|
644
|
+
// the viewport. Cut at the band's line instead, each part coalesces on
|
|
645
|
+
// its own side of it, and the band stays a band.
|
|
646
|
+
//
|
|
647
|
+
// Assembled uncapped, so the parts of a cut stay apart until every rect
|
|
648
|
+
// is in: a cap merge would pick the two halves of one rect first — they
|
|
649
|
+
// waste nothing — and put the overlap back. The halves that met nothing
|
|
650
|
+
// on either side are rejoined after, which is the rect uncut and one
|
|
651
|
+
// pass fewer, and only then is the frame capped.
|
|
652
|
+
const scrolledBar = node._scrollbar(axis);
|
|
653
|
+
const band = scrolledBar ? scrollbarBand(scrolledBar, vp) : null;
|
|
654
|
+
let rects = keep;
|
|
655
|
+
const add = (rect) => {
|
|
656
|
+
const { inside, outside } = band
|
|
657
|
+
? splitAtBand(rect, band)
|
|
658
|
+
: { inside: null, outside: rect };
|
|
659
|
+
if (outside) rects = addDamageRect(rects, outside, Infinity);
|
|
660
|
+
if (inside) rects = addDamageRect(rects, inside, Infinity);
|
|
661
|
+
};
|
|
662
|
+
add(strip);
|
|
663
|
+
// Scrollbar repair. The scrolled axis's thumb moved *and* the blit
|
|
664
|
+
// dragged a copy of the old thumb along: repaint the dragged copy's
|
|
665
|
+
// rect and the new thumb's rect — small rects, where the full track
|
|
666
|
+
// would run the viewport's whole length and merge with the strip into
|
|
667
|
+
// most of the viewport. The cross-axis bar did not move, but the blit
|
|
668
|
+
// shifted its pixels like everything else: its track repaints whole,
|
|
669
|
+
// and so does the copy of the track the shift dragged off it — a pane
|
|
670
|
+
// scrolling both ways otherwise trails a smear of old thumb behind its
|
|
671
|
+
// bar, a row per pixel scrolled. Both lie along the strip on the far
|
|
672
|
+
// edge, so their merge stays a band.
|
|
673
|
+
if (scrolledBar) {
|
|
674
|
+
const savedX = node.scrollX;
|
|
675
|
+
const savedY = node.scrollY;
|
|
676
|
+
node.scrollX = from.x;
|
|
677
|
+
node.scrollY = from.y;
|
|
678
|
+
const oldBar = node._scrollbar(axis);
|
|
679
|
+
node.scrollX = savedX;
|
|
680
|
+
node.scrollY = savedY;
|
|
681
|
+
if (oldBar) {
|
|
682
|
+
rects = addDamageRect(
|
|
683
|
+
rects,
|
|
684
|
+
{
|
|
685
|
+
x: oldBar.x - 1 + dx,
|
|
686
|
+
y: oldBar.y - 1 + dy,
|
|
687
|
+
width: oldBar.width + 2,
|
|
688
|
+
height: oldBar.height + 2,
|
|
689
|
+
},
|
|
690
|
+
Infinity,
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
rects = addDamageRect(
|
|
694
|
+
rects,
|
|
695
|
+
{
|
|
696
|
+
x: scrolledBar.x - 1,
|
|
697
|
+
y: scrolledBar.y - 1,
|
|
698
|
+
width: scrolledBar.width + 2,
|
|
699
|
+
height: scrolledBar.height + 2,
|
|
700
|
+
},
|
|
701
|
+
Infinity,
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
const crossBar = node._scrollbar(axis === 'y' ? 'x' : 'y');
|
|
705
|
+
if (crossBar) {
|
|
706
|
+
const track = scrollbarTrackRect(crossBar);
|
|
707
|
+
add(track);
|
|
708
|
+
const dragged = intersectRects(
|
|
709
|
+
{ ...track, x: track.x + dx, y: track.y + dy },
|
|
710
|
+
vp,
|
|
711
|
+
);
|
|
712
|
+
if (dragged) add(dragged);
|
|
713
|
+
}
|
|
714
|
+
for (const repair of repairs) add(repair);
|
|
715
|
+
rects = joinAlongEdges(rects).reduce(
|
|
716
|
+
(capped, rect) => addDamageRect(capped, rect),
|
|
717
|
+
[],
|
|
718
|
+
);
|
|
719
|
+
// The last gate, and the only one that has to wait until the rects are
|
|
720
|
+
// assembled: damage rects must not overlap, and the frame carries at
|
|
721
|
+
// most MAX_DAMAGE_RECTS of them, so repairs that meet — a column and a
|
|
722
|
+
// row at a corner — or that the cap has to pair up are merged into the
|
|
723
|
+
// box around them, and that box can reach back across the viewport.
|
|
724
|
+
// When it does, the blit is buying a shift and paying for the viewport
|
|
725
|
+
// anyway, so let the plain repaint scrollTo already claimed have the
|
|
726
|
+
// frame.
|
|
727
|
+
let painted = 0;
|
|
728
|
+
for (const rect of rects) {
|
|
729
|
+
const inside = intersectRects(rect, vp);
|
|
730
|
+
if (inside) painted += inside.width * inside.height;
|
|
731
|
+
}
|
|
732
|
+
if (painted > area * SCROLL_BLIT_MAX_REPAINT) return;
|
|
733
|
+
if (!wnd.scrollRegion({ ...vp }, dx, dy)) return;
|
|
734
|
+
this._damage = rects;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* The frame's damage with the shift's own claim taken out, or null if the
|
|
739
|
+
* shift is not the only thing that happened inside `vp`.
|
|
740
|
+
*
|
|
741
|
+
* The claim recorded by `scrollTo` (with its slop) or by `scrollContents`
|
|
742
|
+
* (exactly `vp`) is the one rect allowed to reach in. Anything else — a
|
|
743
|
+
* virtualized table's row swap, a hover restyle mid-scroll, a coalesce
|
|
744
|
+
* that swallowed the claim into a bigger box — means pixels in there
|
|
745
|
+
* changed, and changed pixels must not be blitted around.
|
|
746
|
+
*
|
|
747
|
+
* `exact` is how an element blit asks for the strict form of that: the
|
|
748
|
+
* claim has to still *be* `vp`, not merely cover it within the slop
|
|
749
|
+
* (issue #309). The claim is dropped here in favour of the strips the
|
|
750
|
+
* shift exposed, so anything merged into it is dropped with it — and the
|
|
751
|
+
* damage cap merges neighbours on its own, without a claim-time gate to
|
|
752
|
+
* poison first. Requiring the rect back unchanged is what lets the
|
|
753
|
+
* claim-time gate narrow to `vp` itself and let furniture beside it live.
|
|
754
|
+
*/
|
|
755
|
+
_blitKeptDamage(vp, exact = false) {
|
|
756
|
+
const keep = [];
|
|
757
|
+
let sawClaim = false;
|
|
758
|
+
const slopped = insetRect(vp, -(DAMAGE_SLOP + 1));
|
|
759
|
+
for (const rect of this._damage) {
|
|
760
|
+
if (!rectsOverlap(rect, vp)) {
|
|
761
|
+
keep.push(rect);
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
if (
|
|
765
|
+
exact
|
|
766
|
+
? rect.x === vp.x &&
|
|
767
|
+
rect.y === vp.y &&
|
|
768
|
+
rect.width === vp.width &&
|
|
769
|
+
rect.height === vp.height
|
|
770
|
+
: rectContains(rect, vp) && rectContains(slopped, rect)
|
|
771
|
+
) {
|
|
772
|
+
sawClaim = true;
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
return sawClaim ? keep : null;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* The other half of the blit: a region an element shifted itself
|
|
782
|
+
* (`Node.scrollContents`, issue #303).
|
|
783
|
+
*
|
|
784
|
+
* The gates are the scroll path's, minus everything that was about a
|
|
785
|
+
* scroll container — there are no offsets to be whole, no scrollbars to
|
|
786
|
+
* repair, and no extent that decided the delta — and plus the two things
|
|
787
|
+
* only an element-owned region raises: the region has to be inside what
|
|
788
|
+
* the element itself draws, and this node's *children* are laid out over
|
|
789
|
+
* that drawing rather than being it.
|
|
790
|
+
*
|
|
791
|
+
* Diagonal shifts are allowed here, unlike the scroll path, and that is
|
|
792
|
+
* the point rather than an oversight: a pan is diagonal almost every
|
|
793
|
+
* frame, and the reason the scroll path takes one axis at a time is that
|
|
794
|
+
* the L of exposed strips overlaps the scrollbar rects and the merges
|
|
795
|
+
* balloon back towards the whole viewport. With no bars the L is two
|
|
796
|
+
* disjoint rects and stays two.
|
|
797
|
+
*/
|
|
798
|
+
_applyContentsBlit(node, { rect: vp, dx, dy }, width, height) {
|
|
799
|
+
// only a whole-pixel shift of a whole-pixel region is a copy
|
|
800
|
+
if (!isIntegerRect(vp)) return;
|
|
801
|
+
if (!Number.isInteger(dx) || !Number.isInteger(dy)) return;
|
|
802
|
+
// the net shift of the frame, which several pans can cancel out of
|
|
803
|
+
if (dx === 0 && dy === 0) return;
|
|
804
|
+
if (Math.abs(dx) >= vp.width || Math.abs(dy) >= vp.height) return;
|
|
805
|
+
// the worth-it heuristics, the scroll path's: below these the plain
|
|
806
|
+
// repaint is one cheap pass and the bookkeeping outweighs it
|
|
807
|
+
const area = vp.width * vp.height;
|
|
808
|
+
if (area < SCROLL_BLIT_MIN_AREA) return;
|
|
809
|
+
const kept = (vp.width - Math.abs(dx)) * (vp.height - Math.abs(dy));
|
|
810
|
+
if (kept < area * SCROLL_BLIT_MIN_KEEP) return;
|
|
811
|
+
// the band shifts in from inside the window; a region poking out of it
|
|
812
|
+
// has nothing there to shift
|
|
813
|
+
if (
|
|
814
|
+
vp.x < 0 ||
|
|
815
|
+
vp.y < 0 ||
|
|
816
|
+
vp.x + vp.width > width ||
|
|
817
|
+
vp.y + vp.height > height
|
|
818
|
+
) {
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
// Inside the element, and clear of its own border ring and rounded
|
|
822
|
+
// corners: those are `Node.paint`'s, not the element's drawing, and
|
|
823
|
+
// they do not translate. A solid background does, so the fill under the
|
|
824
|
+
// region is not a reason to decline.
|
|
825
|
+
const bw = resolveBorderWidths(node.style ?? EMPTY_STYLE, node.direction);
|
|
826
|
+
const inset = Math.max(
|
|
827
|
+
bw.top,
|
|
828
|
+
bw.right,
|
|
829
|
+
bw.bottom,
|
|
830
|
+
bw.left,
|
|
831
|
+
node.style?.borderRadius ?? 0,
|
|
832
|
+
);
|
|
833
|
+
if (!rectContains(insetRect(node.abs, inset), vp)) return;
|
|
834
|
+
// A scroll container's children *are* the scrolled content, which is
|
|
835
|
+
// why _scrollBlitSafe skips that subtree. An element's are not: it
|
|
836
|
+
// draws the region in paintContent and its children are laid out on
|
|
837
|
+
// top, so one reaching in would have its pixels dragged along.
|
|
838
|
+
for (const child of node.children) {
|
|
839
|
+
if (child.isWindow || !child.yoga || child.hidden) continue;
|
|
840
|
+
if (child.style?.display === 'none') continue;
|
|
841
|
+
if (rectsOverlap(child._subtreeBounds(), vp)) return;
|
|
842
|
+
}
|
|
843
|
+
const keep = this._blitKeptDamage(vp, true);
|
|
844
|
+
if (!keep) return;
|
|
845
|
+
// An ancestor's rounded corners reach into the top and bottom rows of
|
|
846
|
+
// the region (a graph pane inside a rounded card is the common shape):
|
|
847
|
+
// those rows do not translate, so they leave the blit and get repainted
|
|
848
|
+
// as bands — the same carve the element does for its own furniture —
|
|
849
|
+
// and the band that shifts is what is left between them.
|
|
850
|
+
const bands = this._cornerBands(node, vp);
|
|
851
|
+
const shifted =
|
|
852
|
+
bands.top || bands.bottom
|
|
853
|
+
? {
|
|
854
|
+
x: vp.x,
|
|
855
|
+
y: vp.y + bands.top,
|
|
856
|
+
width: vp.width,
|
|
857
|
+
height: vp.height - bands.top - bands.bottom,
|
|
858
|
+
}
|
|
859
|
+
: vp;
|
|
860
|
+
if (shifted.height <= 0 || Math.abs(dy) >= shifted.height) return;
|
|
861
|
+
if (
|
|
862
|
+
(shifted.width - Math.abs(dx)) * (shifted.height - Math.abs(dy)) <
|
|
863
|
+
area * SCROLL_BLIT_MIN_KEEP
|
|
864
|
+
) {
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (!this._scrollBlitSafe(node, shifted)) return;
|
|
868
|
+
// the element's deltas are already how far the pixels moved, the sense
|
|
869
|
+
// scrollRegion takes (0 + x rather than x: a caller's -0 would survive
|
|
870
|
+
// into request buffers and test comparisons)
|
|
871
|
+
if (!this.window.scrollRegion({ ...shifted }, 0 + dx, 0 + dy)) return;
|
|
872
|
+
let rects = keep;
|
|
873
|
+
if (bands.top) {
|
|
874
|
+
rects = addDamageRect(rects, {
|
|
875
|
+
x: vp.x,
|
|
876
|
+
y: vp.y,
|
|
877
|
+
width: vp.width,
|
|
878
|
+
height: bands.top,
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
if (bands.bottom) {
|
|
882
|
+
rects = addDamageRect(rects, {
|
|
883
|
+
x: vp.x,
|
|
884
|
+
y: shifted.y + shifted.height,
|
|
885
|
+
width: vp.width,
|
|
886
|
+
height: bands.bottom,
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
vp = shifted;
|
|
890
|
+
// The strips the shift exposed, on the sides the pixels came from. The
|
|
891
|
+
// horizontal one takes the full width and the vertical one takes what
|
|
892
|
+
// is left, so a diagonal shift claims two rects that do not overlap —
|
|
893
|
+
// overlapping claims merge into their box, and the box of an L is the
|
|
894
|
+
// whole region again.
|
|
895
|
+
if (dy !== 0) {
|
|
896
|
+
rects = addDamageRect(rects, {
|
|
897
|
+
x: vp.x,
|
|
898
|
+
y: dy > 0 ? vp.y : vp.y + vp.height + dy,
|
|
899
|
+
width: vp.width,
|
|
900
|
+
height: Math.abs(dy),
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
if (dx !== 0) {
|
|
904
|
+
rects = addDamageRect(rects, {
|
|
905
|
+
x: dx > 0 ? vp.x : vp.x + vp.width + dx,
|
|
906
|
+
y: dy > 0 ? vp.y + dy : vp.y,
|
|
907
|
+
width: Math.abs(dx),
|
|
908
|
+
height: vp.height - Math.abs(dy),
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
this._damage = rects;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* How many rows at the top and at the bottom of `vp` an ancestor's rounded
|
|
916
|
+
* corners reach into — the rows an element blit has to leave behind and
|
|
917
|
+
* repaint, so that what shifts stays clear of every corner square
|
|
918
|
+
* (`_scrollBlitSafe`). Whole pixels, and zero when no corner reaches in.
|
|
919
|
+
*/
|
|
920
|
+
_cornerBands(node, vp) {
|
|
921
|
+
let top = 0;
|
|
922
|
+
let bottom = 0;
|
|
923
|
+
for (let n = node.parent; n && n !== this; n = n.parent) {
|
|
924
|
+
const radius = n.style?.borderRadius ?? 0;
|
|
925
|
+
if (!(radius > 0) || !n.abs) continue;
|
|
926
|
+
if (!cornerSquaresOverlap(n.abs, radius, vp)) continue;
|
|
927
|
+
top = Math.max(top, Math.ceil(n.abs.y + radius - vp.y));
|
|
928
|
+
bottom = Math.max(
|
|
929
|
+
bottom,
|
|
930
|
+
Math.ceil(vp.y + vp.height - (n.abs.y + n.abs.height - radius)),
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
return { top: Math.max(0, top), bottom: Math.max(0, bottom) };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* May the viewport's pixels be moved wholesale? Only if every pixel in it
|
|
938
|
+
* belongs to the scrolled content (or to a plain solid fill behind it):
|
|
939
|
+
* any node outside the scroller's subtree whose drawing reaches into
|
|
940
|
+
* the viewport — an overlapping sibling, an ancestor's border ring or
|
|
941
|
+
* rounded corner, an enclosing viewport's scrollbar — would have its
|
|
942
|
+
* pixels dragged along by the blit, so any of them is a no.
|
|
943
|
+
*/
|
|
944
|
+
_scrollBlitSafe(scroller, vp) {
|
|
945
|
+
// the window itself scrolls: everything inside it *is* the scrolled
|
|
946
|
+
// content, so there is nothing that could be dragged along
|
|
947
|
+
if (scroller === this) return true;
|
|
948
|
+
const ancestors = new Set();
|
|
949
|
+
for (let n = scroller.parent; n && n !== this; n = n.parent) {
|
|
950
|
+
ancestors.add(n);
|
|
951
|
+
}
|
|
952
|
+
const check = (parent) => {
|
|
953
|
+
for (const child of parent.children) {
|
|
954
|
+
if (child === scroller) continue; // the scrolled content itself
|
|
955
|
+
if (child.isWindow || !child.yoga || child.hidden) continue;
|
|
956
|
+
if (child.style?.display === 'none') continue;
|
|
957
|
+
if (ancestors.has(child)) {
|
|
958
|
+
// on the path down: its solid background under the viewport is
|
|
959
|
+
// translation-invariant, its border ring and corners are not.
|
|
960
|
+
// The widest side is conservative for a non-uniform border
|
|
961
|
+
const bw = resolveBorderWidths(
|
|
962
|
+
child.style ?? EMPTY_STYLE,
|
|
963
|
+
child.direction,
|
|
964
|
+
);
|
|
965
|
+
const ring = Math.max(bw.top, bw.right, bw.bottom, bw.left);
|
|
966
|
+
if (ring > 0 && !rectContains(insetRect(child.abs, ring), vp)) {
|
|
967
|
+
return false;
|
|
968
|
+
}
|
|
969
|
+
// A rounded corner is not a ring: the arc lives in the four
|
|
970
|
+
// radius-sized squares at the corners, and the straight run of
|
|
971
|
+
// the edge between them is the border ring already excluded
|
|
972
|
+
// above. A viewport that reaches the edge but stays clear of the
|
|
973
|
+
// squares — an element that carved the corner rows into bands it
|
|
974
|
+
// repaints (`_cornerBands`) — is translation-safe.
|
|
975
|
+
const radius = child.style?.borderRadius ?? 0;
|
|
976
|
+
if (radius > 0 && cornerSquaresOverlap(child.abs, radius, vp)) {
|
|
977
|
+
return false;
|
|
978
|
+
}
|
|
979
|
+
if (typeof child._scrollbars === 'function') {
|
|
980
|
+
for (const bar of child._scrollbars()) {
|
|
981
|
+
if (rectsOverlap(scrollbarTrackRect(bar), vp)) return false;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
if (!check(child)) return false;
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
// A node on a layer of its own (src/cocoa/promotion.js) has no
|
|
988
|
+
// pixels in the bitmap the band is cut from, so nothing of it can
|
|
989
|
+
// be dragged along — a pulsing toast over a list is what promotion
|
|
990
|
+
// is for, and this is the half of it that keeps the pan a blit.
|
|
991
|
+
if (child._promoted) continue;
|
|
992
|
+
if (rectsOverlap(child._subtreeBounds(), vp)) return false;
|
|
993
|
+
}
|
|
994
|
+
return true;
|
|
995
|
+
};
|
|
996
|
+
return check(this);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* The node whose `opaqueRect()` holds the whole of `rect`, or null: the
|
|
1001
|
+
* pass needs no fill under that node. Whole pixels only — a fractional
|
|
1002
|
+
* edge is antialiased, and an antialiased pixel is not opaque. A clipping
|
|
1003
|
+
* ancestor shrinks the answer to what reaches the surface, a rounded one
|
|
1004
|
+
* by its radius all round, since the corner squares are exactly the
|
|
1005
|
+
* pixels a rounded clip gives up. A handful of nodes answer at all, so
|
|
1006
|
+
* this is a few rect tests per pass.
|
|
1007
|
+
*/
|
|
1008
|
+
_coverFor(rect) {
|
|
1009
|
+
const nodes = this._opaqueNodes;
|
|
1010
|
+
if (nodes.size === 0) return null;
|
|
1011
|
+
for (const node of nodes) {
|
|
1012
|
+
if (node.destroyed || node.hidden || node._promoted) continue;
|
|
1013
|
+
if (node.style?.display === 'none' || !(node.abs?.width > 0)) continue;
|
|
1014
|
+
let cover = node.opaqueRect();
|
|
1015
|
+
if (!cover) continue;
|
|
1016
|
+
cover = innerPixels(cover);
|
|
1017
|
+
for (let n = node.parent; cover && n && n !== this; n = n.parent) {
|
|
1018
|
+
if (n.hidden || n.style?.display === 'none') {
|
|
1019
|
+
cover = null;
|
|
1020
|
+
break;
|
|
1021
|
+
}
|
|
1022
|
+
if (n.clipsChildren()) {
|
|
1023
|
+
const radius = n.style?.borderRadius ?? 0;
|
|
1024
|
+
cover = intersectRects(
|
|
1025
|
+
cover,
|
|
1026
|
+
radius > 0 ? insetRect(n.abs, radius) : n.abs,
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
if (cover && rectContains(cover, rect)) return node;
|
|
1031
|
+
}
|
|
1032
|
+
return null;
|
|
1033
|
+
}
|
|
1034
|
+
}
|