react-x11 2.7.0 → 2.8.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/package.json +2 -2
- package/src/cocoa/app.js +52 -4
- package/src/cocoa/dnd.js +12 -1
- package/src/cocoa/presenter.js +243 -190
- package/src/cocoa/promotion.js +708 -0
- package/src/cocoa/window.js +29 -1
- package/src/dnd.js +70 -33
- package/src/index.d.ts +12 -1
- package/src/nodes.js +55 -6
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
// Layer promotion — the surface presenter's answer to an animation it cannot
|
|
2
|
+
// otherwise take off the frame clock (issue #483): the few nodes that
|
|
3
|
+
// animate get a CALayer of their own above the window's bitmap, and the
|
|
4
|
+
// rest of the scene stays exactly the frame it was.
|
|
5
|
+
//
|
|
6
|
+
// The mechanism is the one `<glarea>` already uses. The surface presenter's
|
|
7
|
+
// pixels are the window root layer's `contents`, and a layer's contents
|
|
8
|
+
// draw below its sublayers — so a sublayer on the root composites over the
|
|
9
|
+
// bitmap for free, and the paint walk leaves a hole where the node was
|
|
10
|
+
// (`Node._promoted`, the way `GlAreaNode.paint` is empty). The node itself
|
|
11
|
+
// is a property box, its background, border and radius as properties of
|
|
12
|
+
// the layer — exactly the vocabulary the render server animates
|
|
13
|
+
// (`LayerAnimations`) — and its children, if it has any, are one raster
|
|
14
|
+
// sublayer painted by the node's own `_paintChildren` walk. Hit testing
|
|
15
|
+
// never moves: the node tree stays the source of truth for input on every
|
|
16
|
+
// presenter, so promotion changes pixels and nothing else.
|
|
17
|
+
//
|
|
18
|
+
// The policy is not inferred from the scene. A node is promoted because it
|
|
19
|
+
// has a transition or a loop on a property a layer can express, for as
|
|
20
|
+
// long as it has one and a moment after (`IDLE_GRACE_MS`), and returns to
|
|
21
|
+
// the bitmap then; nothing in a style asks for it, and nothing can promote
|
|
22
|
+
// two hundred nodes by mistake. What it is careful about is z-order, which is the hard
|
|
23
|
+
// part: a promoted layer sits above ALL the 2D content, so a node is
|
|
24
|
+
// promoted only when nothing painted after it in the walk reaches into its
|
|
25
|
+
// bounds — no later sibling at any level, no ancestor's border ring or
|
|
26
|
+
// focus ring, no scrollbar — and only when every clipping ancestor holds
|
|
27
|
+
// the whole of it, because the layer would not be clipped. Declining is
|
|
28
|
+
// always safe: the frame clock runs the animation exactly as it does
|
|
29
|
+
// without this file. And the same test runs again every frame, so a node
|
|
30
|
+
// that becomes overlapped, hidden, clipped or non-plain returns to the
|
|
31
|
+
// bitmap in the frame that finds it, the animation handed back to the
|
|
32
|
+
// clock. Overlays, toasts, drag ghosts, spinners and floating cards pass by
|
|
33
|
+
// construction; a hover fade on a row in the middle of a list does not,
|
|
34
|
+
// and stays on the clock. docs/macos.md §"Layer promotion" is the account.
|
|
35
|
+
import {
|
|
36
|
+
BoxNode,
|
|
37
|
+
addDamageRect,
|
|
38
|
+
damageToPaint,
|
|
39
|
+
intersectRects,
|
|
40
|
+
} from '../nodes.js';
|
|
41
|
+
import { resolveBorderWidths } from '../styles.js';
|
|
42
|
+
import {
|
|
43
|
+
LayerAnimations,
|
|
44
|
+
RASTER_PAD,
|
|
45
|
+
RasterState,
|
|
46
|
+
Visual,
|
|
47
|
+
propBoxProps,
|
|
48
|
+
stylePaintsPlain,
|
|
49
|
+
} from './presenter.js';
|
|
50
|
+
|
|
51
|
+
const ORIGIN = Object.freeze({ x: 0, y: 0 });
|
|
52
|
+
|
|
53
|
+
// How long a node that has stopped animating keeps its layer. A hover card
|
|
54
|
+
// fades in and, a moment later, out; a palette step ends one transition and
|
|
55
|
+
// starts the next; a toast pulses again. Demoting on the last frame of each
|
|
56
|
+
// would cost a layer and a repaint of the hole per round trip, and both are
|
|
57
|
+
// frames the bitmap pays for — the presenter bench's `anim` made 96 layers
|
|
58
|
+
// for 48 cards. A second is longer than any such gap and shorter than a
|
|
59
|
+
// user's attention span for a layer that is only holding still.
|
|
60
|
+
const IDLE_GRACE_MS = 1000;
|
|
61
|
+
|
|
62
|
+
// Past this many bare-rect claims on one raster the passes cost more than
|
|
63
|
+
// the repaint they save (the layer presenter's rule, per raster).
|
|
64
|
+
const MAX_DIRTY_RECTS = 16;
|
|
65
|
+
|
|
66
|
+
const rectsOverlap = (a, b) =>
|
|
67
|
+
a.x < b.x + b.width &&
|
|
68
|
+
b.x < a.x + a.width &&
|
|
69
|
+
a.y < b.y + b.height &&
|
|
70
|
+
b.y < a.y + a.height;
|
|
71
|
+
|
|
72
|
+
const containsRect = (outer, inner) =>
|
|
73
|
+
inner.x >= outer.x &&
|
|
74
|
+
inner.y >= outer.y &&
|
|
75
|
+
inner.x + inner.width <= outer.x + outer.width &&
|
|
76
|
+
inner.y + inner.height <= outer.y + outer.height;
|
|
77
|
+
|
|
78
|
+
const insetRect = (rect, by) => ({
|
|
79
|
+
x: rect.x + by,
|
|
80
|
+
y: rect.y + by,
|
|
81
|
+
width: rect.width - 2 * by,
|
|
82
|
+
height: rect.height - 2 * by,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
function unionRect(a, b) {
|
|
86
|
+
if (!a) return b;
|
|
87
|
+
if (!b) return a;
|
|
88
|
+
const x = Math.min(a.x, b.x);
|
|
89
|
+
const y = Math.min(a.y, b.y);
|
|
90
|
+
return {
|
|
91
|
+
x,
|
|
92
|
+
y,
|
|
93
|
+
width: Math.max(a.x + a.width, b.x + b.width) - x,
|
|
94
|
+
height: Math.max(a.y + a.height, b.y + b.height) - y,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A rect grown outward to whole pixels — a pass clears and clips to its
|
|
99
|
+
* edges, and a fractional edge would antialias the clip into a seam. */
|
|
100
|
+
function wholePixels(rect) {
|
|
101
|
+
const x = Math.floor(rect.x);
|
|
102
|
+
const y = Math.floor(rect.y);
|
|
103
|
+
return {
|
|
104
|
+
x,
|
|
105
|
+
y,
|
|
106
|
+
width: Math.ceil(rect.x + rect.width) - x,
|
|
107
|
+
height: Math.ceil(rect.y + rect.height) - y,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The widest of a node's four borders, or 0. */
|
|
112
|
+
function borderReach(node) {
|
|
113
|
+
const style = node.style ?? {};
|
|
114
|
+
const w = resolveBorderWidths(style, node.direction);
|
|
115
|
+
return Math.max(0, w.top, w.right, w.bottom, w.left);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Would `_paintOutline` draw a ring on this node right now? Counted as
|
|
119
|
+
* ink whatever its colour: a wrong "no" here is a ring drawn under a layer. */
|
|
120
|
+
function paintsOutline(node) {
|
|
121
|
+
const style = node.style ?? {};
|
|
122
|
+
if (style.outlineWidth === undefined && !node.states?.[':focus-visible']) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
return node._outline?.() ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A `<box>` painting as core paints one — not an element with a paint of
|
|
129
|
+
* its own, whose content exists nowhere but in that override. */
|
|
130
|
+
const plainBox = (node) =>
|
|
131
|
+
node.kind === 'box' && node.paint === BoxNode.prototype.paint;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Does this node put any ink of its own on the bitmap? A layout-only box —
|
|
135
|
+
* no background, no border, no shadow, no ring — overlaps nothing, however
|
|
136
|
+
* big its rect; everything else is taken to paint its whole box.
|
|
137
|
+
*/
|
|
138
|
+
function paintsSomething(node) {
|
|
139
|
+
if (!plainBox(node)) return true;
|
|
140
|
+
const style = node.style ?? {};
|
|
141
|
+
if (style.backgroundColor || style.backgroundImage || style.boxShadow) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
if (borderReach(node) > 0 || paintsOutline(node)) return true;
|
|
145
|
+
return Boolean(node.isScroller?.());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Can this node be a property box on a layer at all — the static half of
|
|
150
|
+
* the answer, the same whatever the scene around it does: a plain box by
|
|
151
|
+
* its target style, not a scroller (its bars and clip host are the layer
|
|
152
|
+
* presenter's business), no ring lit on it, no paint of its own.
|
|
153
|
+
*/
|
|
154
|
+
function promotableNode(node) {
|
|
155
|
+
if (node.destroyed || !plainBox(node)) return false;
|
|
156
|
+
if (!stylePaintsPlain(node, node._targetStyle ?? node.style)) return false;
|
|
157
|
+
if (node.isScroller?.()) return false;
|
|
158
|
+
return !paintsOutline(node);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The strip a scrollbar's track occupies, with the pad the thumb's
|
|
162
|
+
* antialiasing needs. Mirrors the layer presenter's bar strip. */
|
|
163
|
+
function scrollbarStrip(bar, scale) {
|
|
164
|
+
const pad = Math.ceil(2 * scale);
|
|
165
|
+
return bar.axis === 'x'
|
|
166
|
+
? {
|
|
167
|
+
x: bar.trackStart - pad,
|
|
168
|
+
y: bar.crossStart - pad,
|
|
169
|
+
width: bar.trackLength + 2 * pad,
|
|
170
|
+
height: bar.height + 2 * pad,
|
|
171
|
+
}
|
|
172
|
+
: {
|
|
173
|
+
x: bar.crossStart - pad,
|
|
174
|
+
y: bar.trackStart - pad,
|
|
175
|
+
width: bar.width + 2 * pad,
|
|
176
|
+
height: bar.trackLength + 2 * pad,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The surface window's promoted nodes: which ones have a layer, what each
|
|
182
|
+
* layer shows, and the animations the render server runs on them.
|
|
183
|
+
* `frame()` is the whole of the per-frame work, called by the window from
|
|
184
|
+
* nodes.js's `prepareFrame` seam — after layout, before the damage is taken.
|
|
185
|
+
*/
|
|
186
|
+
export class CocoaPromotion {
|
|
187
|
+
constructor(window) {
|
|
188
|
+
this.window = window;
|
|
189
|
+
this.native = window._native;
|
|
190
|
+
this.scale = window.scale;
|
|
191
|
+
this.app = window.app;
|
|
192
|
+
this.fonts = window.app.fonts;
|
|
193
|
+
this.rootVisual = { layer: window._layer };
|
|
194
|
+
this.promoted = new Map(); // node -> { visual, content, dirty }
|
|
195
|
+
// nodes whose animation was taken and have no layer yet: the frame
|
|
196
|
+
// decides, against the scene as laid out
|
|
197
|
+
this.candidates = new Set();
|
|
198
|
+
// a refusal, by the layout it was decided in: the same scene answers
|
|
199
|
+
// the same way, and every refusal a frame makes restarts the entry
|
|
200
|
+
this.denied = new WeakMap(); // node -> layout generation
|
|
201
|
+
this.layoutGen = 0;
|
|
202
|
+
// nodes whose last animation ended, waiting out the grace before the
|
|
203
|
+
// frame that takes them back into the bitmap; and the ones that frame
|
|
204
|
+
// is owed for
|
|
205
|
+
this.idle = new Map(); // node -> timer
|
|
206
|
+
this.releasing = new Set();
|
|
207
|
+
this.animations = new LayerAnimations({
|
|
208
|
+
native: this.native,
|
|
209
|
+
app: this.app,
|
|
210
|
+
scale: this.scale,
|
|
211
|
+
layerOf: (node) => this.promoted.get(node)?.visual.layer ?? null,
|
|
212
|
+
onIdle: (node) => this._idle(node),
|
|
213
|
+
});
|
|
214
|
+
this._claiming = false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- the window's animation seam -------------------------------------------
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Take `prop`'s animation for `node` off the frame clock, or decline.
|
|
221
|
+
* Taken here means only that the node is a candidate: whether it gets a
|
|
222
|
+
* layer is decided by the next frame, with the scene laid out, and a
|
|
223
|
+
* frame that decides against it hands the entry back to the clock from
|
|
224
|
+
* the top — before anything was painted at the target, so nothing shows.
|
|
225
|
+
*/
|
|
226
|
+
animate(node, prop, entry) {
|
|
227
|
+
if (this.window.destroyed) return false;
|
|
228
|
+
if (!this.promoted.has(node)) {
|
|
229
|
+
if (this.denied.get(node) === this.layoutGen) return false;
|
|
230
|
+
if (!promotableNode(node)) return false;
|
|
231
|
+
}
|
|
232
|
+
if (!this.animations.take(node, prop, entry)) return false;
|
|
233
|
+
if (this.promoted.has(node)) this._keep(node);
|
|
234
|
+
else this.candidates.add(node);
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Stop what runs for `prop` on `node`. The layer stays: a node with
|
|
239
|
+
* nothing left running on it waits out the grace like one whose
|
|
240
|
+
* animation ended, and the frame after that takes it back. */
|
|
241
|
+
cancel(node, prop) {
|
|
242
|
+
this.animations.cancel(node, prop);
|
|
243
|
+
if (this.promoted.has(node) && !this.animations.has(node)) {
|
|
244
|
+
this._idle(node);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Nothing runs on `node`'s layer any more: keep it for the grace, then
|
|
249
|
+
* ask for the frame that takes the node back into the bitmap. */
|
|
250
|
+
_idle(node) {
|
|
251
|
+
if (!this.promoted.has(node) || this.idle.has(node)) return;
|
|
252
|
+
const timer = setTimeout(() => {
|
|
253
|
+
this.idle.delete(node);
|
|
254
|
+
this._release(node);
|
|
255
|
+
}, IDLE_GRACE_MS);
|
|
256
|
+
timer.unref?.();
|
|
257
|
+
this.idle.set(node, timer);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Something runs on `node`'s layer again: it stays. */
|
|
261
|
+
_keep(node) {
|
|
262
|
+
const timer = this.idle.get(node);
|
|
263
|
+
if (timer) {
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
this.idle.delete(node);
|
|
266
|
+
}
|
|
267
|
+
this.releasing.delete(node);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
_release(node) {
|
|
271
|
+
if (!this.promoted.has(node) || node.destroyed) return;
|
|
272
|
+
if (this.animations.has(node)) return; // taken again meanwhile
|
|
273
|
+
this.releasing.add(node);
|
|
274
|
+
const root = this.window._reactX11Node;
|
|
275
|
+
if (root && !root.destroyed) root.invalidate(false, node, 'animation');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Every idle node back into the bitmap on the next frame, grace or no
|
|
279
|
+
* grace — for the tests, which do not wait a second. */
|
|
280
|
+
releaseIdle() {
|
|
281
|
+
for (const node of [...this.idle.keys()]) {
|
|
282
|
+
clearTimeout(this.idle.get(node));
|
|
283
|
+
this.idle.delete(node);
|
|
284
|
+
this._release(node);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// --- the invalidate channel ----------------------------------------------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A claim against the bitmap, seen on its way there: what it says about
|
|
292
|
+
* a promoted node's own layer needs no note — the model is re-diffed
|
|
293
|
+
* every frame — but its children's raster repaints only what is claimed
|
|
294
|
+
* inside it. A node claim inside a promoted subtree is a pass over that
|
|
295
|
+
* node's reach; a layout change anywhere in the subtree, or a claim with
|
|
296
|
+
* no bound, repaints the raster in full; a bare rect repaints that part
|
|
297
|
+
* of every raster it reaches into (the layer presenter's rule).
|
|
298
|
+
*/
|
|
299
|
+
noteInvalidate(damage, layoutChanged) {
|
|
300
|
+
if (this._claiming || this.promoted.size === 0) return false;
|
|
301
|
+
if (damage == null) {
|
|
302
|
+
for (const p of this.promoted.values()) p.dirty.all = true;
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
if (damage.kind) {
|
|
306
|
+
let owner = null;
|
|
307
|
+
for (let n = damage; n && !n.isWindow; n = n.parent) {
|
|
308
|
+
if (this.promoted.has(n)) {
|
|
309
|
+
owner = n;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (!owner) return false;
|
|
314
|
+
const p = this.promoted.get(owner);
|
|
315
|
+
if (layoutChanged) {
|
|
316
|
+
p.dirty.all = true;
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
if (owner !== damage) this._dirtyRect(p, damage.paintBounds());
|
|
320
|
+
// answered here — the model re-diffed, the raster repainted — and the
|
|
321
|
+
// bitmap owes nothing: it holds a hole where this node is
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
if (!(damage.width > 0 && damage.height > 0)) return false;
|
|
325
|
+
for (const p of this.promoted.values()) {
|
|
326
|
+
if (p.content && rectsOverlap(p.content.rect, damage)) {
|
|
327
|
+
this._dirtyRect(p, damage);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
_dirtyRect(p, rect) {
|
|
334
|
+
if (p.dirty.all) return;
|
|
335
|
+
if (p.dirty.rects.length >= MAX_DIRTY_RECTS) {
|
|
336
|
+
p.dirty.all = true;
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
// copied: a caller's rect is very often a live `abs` about to move
|
|
340
|
+
p.dirty.rects.push({
|
|
341
|
+
x: rect.x,
|
|
342
|
+
y: rect.y,
|
|
343
|
+
width: rect.width,
|
|
344
|
+
height: rect.height,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// --- the frame -------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The presenter's half of a frame. Three passes, all inside one
|
|
352
|
+
* disabled-actions transaction: every promoted node is asked again
|
|
353
|
+
* whether it may stay, the candidates are decided, and what remains is
|
|
354
|
+
* synced — frame, properties, the animations waiting to attach, the
|
|
355
|
+
* children's raster. A node taken off a layer here is painted back into
|
|
356
|
+
* the bitmap by this same frame, which is why this runs before the
|
|
357
|
+
* damage is taken (`_claim`).
|
|
358
|
+
*/
|
|
359
|
+
frame(root, layoutRan) {
|
|
360
|
+
if (layoutRan) this.layoutGen++;
|
|
361
|
+
if (this.promoted.size === 0 && this.candidates.size === 0) return;
|
|
362
|
+
const native = this.native;
|
|
363
|
+
native.txBegin({ disableActions: true });
|
|
364
|
+
try {
|
|
365
|
+
// Later-painted first: a node painted after this one that is coming
|
|
366
|
+
// off its layer is what this one would be overlapped by, and the
|
|
367
|
+
// answer has to be known in the same frame, not the next.
|
|
368
|
+
const keyed = this._inPaintOrder([...this.promoted.keys()]);
|
|
369
|
+
for (let i = keyed.length - 1; i >= 0; i--) {
|
|
370
|
+
const { node, key } = keyed[i];
|
|
371
|
+
if (!this._mayStay(node, key)) this._demote(node, root, true);
|
|
372
|
+
}
|
|
373
|
+
if (this.candidates.size) {
|
|
374
|
+
const candidates = this._inPaintOrder([...this.candidates]);
|
|
375
|
+
this.candidates.clear();
|
|
376
|
+
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
377
|
+
const { node, key } = candidates[i];
|
|
378
|
+
if (this.promoted.has(node) || !this.animations.has(node)) continue;
|
|
379
|
+
if (key && promotableNode(node) && this._clear(node)) {
|
|
380
|
+
this._promote(node, root);
|
|
381
|
+
} else {
|
|
382
|
+
this.denied.set(node, this.layoutGen);
|
|
383
|
+
this.animations.drop(node, true);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const order = this._inPaintOrder([...this.promoted.keys()]);
|
|
388
|
+
for (let i = 0; i < order.length; i++) {
|
|
389
|
+
this._sync(order[i].node, i, root, layoutRan);
|
|
390
|
+
}
|
|
391
|
+
} finally {
|
|
392
|
+
native.txCommit();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
_mayStay(node, key) {
|
|
397
|
+
if (!key || !promotableNode(node)) return false;
|
|
398
|
+
if (this.releasing.has(node)) return false; // its grace ran out
|
|
399
|
+
return this._clear(node);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
_promote(node, root) {
|
|
403
|
+
const visual = new Visual(this, node);
|
|
404
|
+
visual.attach(this.rootVisual);
|
|
405
|
+
this.promoted.set(node, {
|
|
406
|
+
visual,
|
|
407
|
+
content: null,
|
|
408
|
+
dirty: { all: true, rects: [] },
|
|
409
|
+
});
|
|
410
|
+
node._promoted = true;
|
|
411
|
+
// the bitmap under it repaints without it, from this frame on
|
|
412
|
+
this._claim(root, node.paintBounds());
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Off its layer and back into the bitmap, in this frame. `reclaim` hands
|
|
417
|
+
* what was waiting for a frame back to the clock; what was running is
|
|
418
|
+
* over either way, and the model shows — a loop comes back to the clock
|
|
419
|
+
* through `_offloadEnded`'s own rule.
|
|
420
|
+
*/
|
|
421
|
+
_demote(node, root, reclaim) {
|
|
422
|
+
const p = this.promoted.get(node);
|
|
423
|
+
if (!p) return;
|
|
424
|
+
this.promoted.delete(node);
|
|
425
|
+
this._keep(node);
|
|
426
|
+
node._promoted = false;
|
|
427
|
+
this.animations.drop(node, reclaim && !node.destroyed);
|
|
428
|
+
this._dropContent(p);
|
|
429
|
+
p.visual.destroy();
|
|
430
|
+
if (!node.destroyed && node.abs) this._claim(root, node.paintBounds());
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
_dropContent(p) {
|
|
434
|
+
if (!p.content) return;
|
|
435
|
+
this.native.removeFromSuperlayer(p.content.layer);
|
|
436
|
+
p.content.raster.release(this.native);
|
|
437
|
+
p.content = null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** A claim of our own against the bitmap, kept off our own books. */
|
|
441
|
+
_claim(root, rect) {
|
|
442
|
+
if (!root || root.destroyed) return;
|
|
443
|
+
this._claiming = true;
|
|
444
|
+
try {
|
|
445
|
+
root.invalidate(false, rect, 'animation');
|
|
446
|
+
} finally {
|
|
447
|
+
this._claiming = false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// --- where a node stands in the walk ----------------------------------------
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The node's place in the window's paint order — its index in each
|
|
455
|
+
* ancestor's `paintOrder()`, top down — or null when the walk would not
|
|
456
|
+
* reach it: hidden, `display: none`, or not attached to a window.
|
|
457
|
+
*/
|
|
458
|
+
_paintKey(node) {
|
|
459
|
+
const key = [];
|
|
460
|
+
for (let n = node; !n.isWindow; n = n.parent) {
|
|
461
|
+
const parent = n.parent;
|
|
462
|
+
if (!parent) return null;
|
|
463
|
+
const i = parent.paintOrder().indexOf(n);
|
|
464
|
+
if (i < 0) return null;
|
|
465
|
+
key.push(i);
|
|
466
|
+
}
|
|
467
|
+
return key.reverse();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
_inPaintOrder(nodes) {
|
|
471
|
+
const keyed = nodes.map((node) => ({ node, key: this._paintKey(node) }));
|
|
472
|
+
keyed.sort((a, b) => {
|
|
473
|
+
if (!a.key || !b.key) return (a.key ? 1 : 0) - (b.key ? 1 : 0);
|
|
474
|
+
const n = Math.min(a.key.length, b.key.length);
|
|
475
|
+
for (let i = 0; i < n; i++) {
|
|
476
|
+
if (a.key[i] !== b.key[i]) return a.key[i] - b.key[i];
|
|
477
|
+
}
|
|
478
|
+
return a.key.length - b.key.length;
|
|
479
|
+
});
|
|
480
|
+
return keyed;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Is nothing painted over this node, and is all of it visible? Walked up
|
|
485
|
+
* the chain: at every level, what the parent paints after the child on
|
|
486
|
+
* the way to this node — later siblings, then the parent's own border and
|
|
487
|
+
* ring, then its bars — must keep out of the node's reach, and the
|
|
488
|
+
* parent's clip, if it has one, must hold the whole of it.
|
|
489
|
+
*/
|
|
490
|
+
_clear(node) {
|
|
491
|
+
// the exact reach, not `paintBounds()`: that one carries the damage
|
|
492
|
+
// model's pixel of slop, and a section laid out flush under a card
|
|
493
|
+
// would read as reaching into it
|
|
494
|
+
const bounds = node._subtreeBounds();
|
|
495
|
+
for (let n = node; !n.isWindow; n = n.parent) {
|
|
496
|
+
const parent = n.parent;
|
|
497
|
+
if (!parent) return false;
|
|
498
|
+
const order = parent.paintOrder();
|
|
499
|
+
for (let j = order.indexOf(n) + 1; j < order.length; j++) {
|
|
500
|
+
if (this._reaches(order[j], bounds)) return false;
|
|
501
|
+
}
|
|
502
|
+
if (!parent.isWindow) {
|
|
503
|
+
const border = borderReach(parent);
|
|
504
|
+
if (
|
|
505
|
+
border > 0 &&
|
|
506
|
+
!containsRect(insetRect(parent.abs, border), bounds)
|
|
507
|
+
) {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
const ring = paintsOutline(parent);
|
|
511
|
+
if (ring) {
|
|
512
|
+
const inside = Math.max(0, ring.width / 2 - ring.offset) + 1;
|
|
513
|
+
if (!containsRect(insetRect(parent.abs, inside), bounds))
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
if (parent.clipsChildren?.()) {
|
|
517
|
+
const radius = parent.style?.borderRadius;
|
|
518
|
+
const clip =
|
|
519
|
+
typeof radius === 'number' && radius > 0
|
|
520
|
+
? insetRect(parent.abs, radius)
|
|
521
|
+
: parent.abs;
|
|
522
|
+
if (!containsRect(clip, bounds)) return false;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (typeof parent._scrollbars === 'function') {
|
|
526
|
+
for (const bar of parent._scrollbars()) {
|
|
527
|
+
if (rectsOverlap(scrollbarStrip(bar, this.scale), bounds)) {
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Does anything in `n`'s subtree put ink inside `rect`? A promoted
|
|
538
|
+
* subtree does not: it is on a layer of its own, above this one since it
|
|
539
|
+
* is painted later. A subtree whose reach misses the rect is answered
|
|
540
|
+
* from the cached reach without a walk.
|
|
541
|
+
*/
|
|
542
|
+
_reaches(n, rect) {
|
|
543
|
+
if (!rectsOverlap(n._subtreeBounds(), rect)) return false;
|
|
544
|
+
if (this.promoted.has(n)) return false;
|
|
545
|
+
if (paintsSomething(n) && rectsOverlap(n._ownPaintBounds(), rect)) {
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
if (n.kind === 'text') return true; // its spans are its own ink
|
|
549
|
+
for (const child of n.paintOrder()) {
|
|
550
|
+
if (this._reaches(child, rect)) return true;
|
|
551
|
+
}
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// --- what the layer shows ----------------------------------------------------
|
|
556
|
+
|
|
557
|
+
_sync(node, order, root, layoutRan) {
|
|
558
|
+
const p = this.promoted.get(node);
|
|
559
|
+
p.visual.set(propBoxProps(node, this.app, this.scale, ORIGIN, order));
|
|
560
|
+
// after the model value went out, inside the same transaction
|
|
561
|
+
this.animations.apply(node, p.visual.layer);
|
|
562
|
+
this._syncContent(node, p, root, layoutRan);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* The children, rastered into one sublayer at their reach: the node's own
|
|
567
|
+
* `_paintChildren` walk, translated so the reach lands at the bitmap's
|
|
568
|
+
* origin, with `paintDamage()` naming each pass the way the window's
|
|
569
|
+
* paint does. Repainted for the claims that reached into it since the
|
|
570
|
+
* last frame, for a change of size, and for any change in where the
|
|
571
|
+
* children sit inside the node — a layout pass can move them without a
|
|
572
|
+
* claim naming any of them, so a layout frame compares their rects.
|
|
573
|
+
*/
|
|
574
|
+
_syncContent(node, p, root, layoutRan) {
|
|
575
|
+
const abs = node.abs;
|
|
576
|
+
let reach = null;
|
|
577
|
+
for (const child of node.paintOrder()) {
|
|
578
|
+
if (child._promoted) continue; // on a layer of its own
|
|
579
|
+
reach = unionRect(reach, child._subtreeBounds());
|
|
580
|
+
}
|
|
581
|
+
if (reach && node.clipsChildren?.()) reach = intersectRects(reach, abs);
|
|
582
|
+
if (!reach || !(reach.width > 0 && reach.height > 0)) {
|
|
583
|
+
this._dropContent(p);
|
|
584
|
+
p.dirty = { all: true, rects: [] };
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const rect = {
|
|
588
|
+
x: Math.floor(reach.x) - RASTER_PAD,
|
|
589
|
+
y: Math.floor(reach.y) - RASTER_PAD,
|
|
590
|
+
width: Math.ceil(reach.width) + RASTER_PAD * 2,
|
|
591
|
+
height: Math.ceil(reach.height) + RASTER_PAD * 2,
|
|
592
|
+
};
|
|
593
|
+
let content = p.content;
|
|
594
|
+
if (!content) {
|
|
595
|
+
const layer = this.native.createLayer();
|
|
596
|
+
this.native.addSublayer(p.visual.layer, layer);
|
|
597
|
+
content = p.content = {
|
|
598
|
+
layer,
|
|
599
|
+
raster: new RasterState(),
|
|
600
|
+
rect: null,
|
|
601
|
+
layoutKey: null,
|
|
602
|
+
props: {},
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
const s = this.scale;
|
|
606
|
+
const frame = [
|
|
607
|
+
(rect.x - abs.x) / s,
|
|
608
|
+
(rect.y - abs.y) / s,
|
|
609
|
+
rect.width / s,
|
|
610
|
+
rect.height / s,
|
|
611
|
+
];
|
|
612
|
+
if (
|
|
613
|
+
!content.props.frame ||
|
|
614
|
+
frame.some((v, i) => v !== content.props.frame[i])
|
|
615
|
+
) {
|
|
616
|
+
content.props.frame = frame;
|
|
617
|
+
this.native.setLayerProps(content.layer, { frame, zPosition: 0 });
|
|
618
|
+
}
|
|
619
|
+
const raster = content.raster;
|
|
620
|
+
const sizeChanged =
|
|
621
|
+
raster.width !== rect.width || raster.height !== rect.height;
|
|
622
|
+
const layoutKey =
|
|
623
|
+
layoutRan || !content.layoutKey ? layoutKeyOf(node) : null;
|
|
624
|
+
const moved =
|
|
625
|
+
content.rect && (content.rect.x !== rect.x || content.rect.y !== rect.y);
|
|
626
|
+
const full =
|
|
627
|
+
p.dirty.all ||
|
|
628
|
+
sizeChanged ||
|
|
629
|
+
(layoutKey !== null && layoutKey !== content.layoutKey) ||
|
|
630
|
+
(moved && p.dirty.rects.length > 0);
|
|
631
|
+
let passes = null;
|
|
632
|
+
if (full) {
|
|
633
|
+
passes = [null];
|
|
634
|
+
} else if (p.dirty.rects.length) {
|
|
635
|
+
for (const claimed of p.dirty.rects) {
|
|
636
|
+
const hit = intersectRects(wholePixels(claimed), rect);
|
|
637
|
+
if (hit) passes = addDamageRect(passes, hit);
|
|
638
|
+
}
|
|
639
|
+
if (passes) passes = damageToPaint(passes);
|
|
640
|
+
}
|
|
641
|
+
p.dirty = { all: false, rects: [] };
|
|
642
|
+
content.rect = rect;
|
|
643
|
+
if (layoutKey !== null) content.layoutKey = layoutKey;
|
|
644
|
+
if (!passes) return;
|
|
645
|
+
const ctx = raster.ensure(this, rect.width, rect.height, s);
|
|
646
|
+
ctx.save();
|
|
647
|
+
try {
|
|
648
|
+
ctx.translate(-rect.x, -rect.y);
|
|
649
|
+
for (const pass of passes) {
|
|
650
|
+
const area = pass ?? rect;
|
|
651
|
+
ctx.save();
|
|
652
|
+
try {
|
|
653
|
+
if (pass) {
|
|
654
|
+
ctx.beginPath();
|
|
655
|
+
ctx.rect(area.x, area.y, area.width, area.height);
|
|
656
|
+
ctx.clip();
|
|
657
|
+
}
|
|
658
|
+
ctx.clearRect(area.x, area.y, area.width, area.height);
|
|
659
|
+
root._paintDamage = pass;
|
|
660
|
+
node._paintChildren(ctx);
|
|
661
|
+
} finally {
|
|
662
|
+
root._paintDamage = null;
|
|
663
|
+
ctx.restore();
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
} finally {
|
|
667
|
+
ctx.restore();
|
|
668
|
+
}
|
|
669
|
+
this.native.surfaceToLayer(raster.surface, content.layer);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Everything off the root layer and freed: the window is going. */
|
|
673
|
+
destroy() {
|
|
674
|
+
for (const timer of this.idle.values()) clearTimeout(timer);
|
|
675
|
+
this.idle.clear();
|
|
676
|
+
this.releasing.clear();
|
|
677
|
+
for (const [node, p] of this.promoted) {
|
|
678
|
+
node._promoted = false;
|
|
679
|
+
this.animations.drop(node, false);
|
|
680
|
+
this._dropContent(p);
|
|
681
|
+
p.visual.destroy();
|
|
682
|
+
}
|
|
683
|
+
this.promoted.clear();
|
|
684
|
+
this.candidates.clear();
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Where a node's drawn descendants sit inside it, as one string: the same
|
|
690
|
+
* arrangement gives the same key, and a raster painted for one is good for
|
|
691
|
+
* the other. Promoted subtrees are left out — they are on layers of their
|
|
692
|
+
* own — and so is anything that is not layout, which claims for itself.
|
|
693
|
+
*/
|
|
694
|
+
function layoutKeyOf(node) {
|
|
695
|
+
const ox = node.abs.x;
|
|
696
|
+
const oy = node.abs.y;
|
|
697
|
+
let key = '';
|
|
698
|
+
const walk = (n) => {
|
|
699
|
+
for (const child of n.paintOrder()) {
|
|
700
|
+
if (child._promoted) continue;
|
|
701
|
+
const a = child.abs;
|
|
702
|
+
key += `${a.x - ox},${a.y - oy},${a.width},${a.height};`;
|
|
703
|
+
if (child.kind !== 'text') walk(child);
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
walk(node);
|
|
707
|
+
return key;
|
|
708
|
+
}
|