react-x11 2.2.1 → 2.3.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 +5 -3
- package/package.json +12 -5
- package/src/Reconciler.js +76 -11
- package/src/anchor.js +15 -5
- package/src/appcontext.js +27 -7
- package/src/cocoa/app.js +557 -0
- package/src/cocoa/bezels.js +161 -0
- package/src/cocoa/context2d.js +679 -0
- package/src/cocoa/fonts.js +541 -0
- package/src/cocoa/glarea.js +321 -0
- package/src/cocoa/globalmenu.js +149 -0
- package/src/cocoa/keymap.js +86 -0
- package/src/cocoa/native.js +51 -0
- package/src/cocoa/panehost.js +50 -0
- package/src/cocoa/panewindow.js +236 -0
- package/src/cocoa/presenter.js +837 -0
- package/src/cocoa/window.js +383 -0
- package/src/components/Button.js +68 -0
- package/src/components/Checkbox.js +43 -0
- package/src/components/Radio.js +37 -1
- package/src/components/Select.js +73 -28
- package/src/components/Slider.js +59 -0
- package/src/components/Switch.js +52 -0
- package/src/components/native.js +142 -0
- package/src/decorations.js +21 -24
- package/src/editmenu.js +23 -14
- package/src/events.js +11 -2
- package/src/frame/childmain.js +9 -0
- package/src/frame/index.js +162 -2
- package/src/glnodes.js +43 -0
- package/src/globalmenu.js +11 -3
- package/src/index.d.ts +10 -0
- package/src/nodes.js +107 -33
- package/src/paintcache.js +15 -4
- package/src/palette.js +14 -0
- package/src/styles.js +5 -0
- package/src/types/elements.d.ts +10 -0
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
// The retained layer presenter — Tier L of docs/macos.md: one CALayer per
|
|
2
|
+
// drawn node, React commits landing as property sets inside a single
|
|
3
|
+
// disabled-actions CATransaction per frame, the WindowServer compositing.
|
|
4
|
+
//
|
|
5
|
+
// The node tree stays the model (layout, hit testing, events, focus); the
|
|
6
|
+
// layer tree is write-only presentation. Three visual kinds cover the whole
|
|
7
|
+
// vocabulary:
|
|
8
|
+
//
|
|
9
|
+
// PropBox a plain <box> — backgroundColor, uniform border, radius,
|
|
10
|
+
// clip — expressed entirely as layer properties. Zero raster.
|
|
11
|
+
// Raster every painted-code node (text, textinput, canvas, svg,
|
|
12
|
+
// images, registered elements) and any box whose self-paint
|
|
13
|
+
// exceeds the property vocabulary (gradients, shadows,
|
|
14
|
+
// per-edge borders, focus outlines): its OWN paint replayed
|
|
15
|
+
// through the CG context into a bitmap layer. Children are
|
|
16
|
+
// never inside — they get visuals of their own.
|
|
17
|
+
// Bars a scroller's scrollbars, rastered into an overlay sublayer
|
|
18
|
+
// above the content (zPosition keeps it on top).
|
|
19
|
+
//
|
|
20
|
+
// Sibling order is zPosition, assigned from the node's own paintOrder() —
|
|
21
|
+
// no sublayer-list surgery, ever. Dirt arrives on the invalidate channel
|
|
22
|
+
// (`noteInvalidate`): a node means that node, null means everything (the
|
|
23
|
+
// same "no bound named repaints everything" rule the X11 damage model has),
|
|
24
|
+
// and geometry is re-diffed every frame because comparing four numbers is
|
|
25
|
+
// cheaper than knowing.
|
|
26
|
+
import { cssColorStraight } from 'ntk';
|
|
27
|
+
|
|
28
|
+
import { CocoaContext2D } from './context2d.js';
|
|
29
|
+
|
|
30
|
+
const RASTER_PAD = 2; // antialiasing/italic overhang outside the ink bounds
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A recording "context" for ntk's SvgView.draw: instead of rasterizing, it
|
|
34
|
+
* captures every fill/stroke as a flat op the bridge's CAShapeLayer path
|
|
35
|
+
* vocabulary can take verbatim. SVG is CA's native tongue — a path per
|
|
36
|
+
* layer, composited and tintable by the render server — and this recorder
|
|
37
|
+
* is what turns the existing, fully-debugged SvgView traversal into that
|
|
38
|
+
* without reimplementing SVG. Anything it cannot express (gradients,
|
|
39
|
+
* images, text, clips) flips `unsupported` and the node falls back to the
|
|
40
|
+
* raster visual, so correctness never depends on coverage.
|
|
41
|
+
*/
|
|
42
|
+
class ShapeRecorder {
|
|
43
|
+
constructor(matrix) {
|
|
44
|
+
this.ops = [];
|
|
45
|
+
this.unsupported = false;
|
|
46
|
+
this._stack = [];
|
|
47
|
+
this._m = matrix; // [a, b, c, d, e, f]
|
|
48
|
+
this._alpha = 1;
|
|
49
|
+
this.fillStyle = '#000';
|
|
50
|
+
this.strokeStyle = 'none';
|
|
51
|
+
this.lineWidth = 1;
|
|
52
|
+
this.lineCap = 'butt';
|
|
53
|
+
this.lineJoin = 'miter';
|
|
54
|
+
this._dash = [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_apply(x, y) {
|
|
58
|
+
const [a, b, c, d, e, f] = this._m;
|
|
59
|
+
return [a * x + c * y + e, b * x + d * y + f];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_scaleFactor() {
|
|
63
|
+
const [a, b, c, d] = this._m;
|
|
64
|
+
return Math.sqrt(Math.abs(a * d - b * c));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
save() {
|
|
68
|
+
this._stack.push({ m: [...this._m], alpha: this._alpha });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
restore() {
|
|
72
|
+
const prev = this._stack.pop();
|
|
73
|
+
if (prev) {
|
|
74
|
+
this._m = prev.m;
|
|
75
|
+
this._alpha = prev.alpha;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
translate(x, y) {
|
|
80
|
+
const [a, b, c, d, e, f] = this._m;
|
|
81
|
+
this._m = [a, b, c, d, a * x + c * y + e, b * x + d * y + f];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
scale(x, y) {
|
|
85
|
+
const [a, b, c, d, e, f] = this._m;
|
|
86
|
+
this._m = [a * x, b * x, c * y, d * y, e, f];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
rotate(angle) {
|
|
90
|
+
const cos = Math.cos(angle);
|
|
91
|
+
const sin = Math.sin(angle);
|
|
92
|
+
const [a, b, c, d, e, f] = this._m;
|
|
93
|
+
this._m = [
|
|
94
|
+
a * cos + c * sin,
|
|
95
|
+
b * cos + d * sin,
|
|
96
|
+
c * cos - a * sin,
|
|
97
|
+
d * cos - b * sin,
|
|
98
|
+
e,
|
|
99
|
+
f,
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
transform(a2, b2, c2, d2, e2, f2) {
|
|
104
|
+
const [a, b, c, d, e, f] = this._m;
|
|
105
|
+
this._m = [
|
|
106
|
+
a * a2 + c * b2,
|
|
107
|
+
b * a2 + d * b2,
|
|
108
|
+
a * c2 + c * d2,
|
|
109
|
+
b * c2 + d * d2,
|
|
110
|
+
a * e2 + c * f2 + e,
|
|
111
|
+
b * e2 + d * f2 + f,
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getTransform() {
|
|
116
|
+
const [a, b, c, d, e, f] = this._m;
|
|
117
|
+
return { a, b, c, d, e, f };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
set globalAlpha(value) {
|
|
121
|
+
if (typeof value === 'number') this._alpha = value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
get globalAlpha() {
|
|
125
|
+
return this._alpha;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
setLineDash(segments) {
|
|
129
|
+
this._dash = Array.isArray(segments) ? segments : [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A recording gradient: SvgView resolves an SVG paint server to canvas
|
|
134
|
+
* gradient calls, and CAGradientLayer speaks the same vocabulary — a
|
|
135
|
+
* line, stops, colours — so a linear fill stays retained instead of
|
|
136
|
+
* pushing the whole document to the raster fallback. The line is mapped
|
|
137
|
+
* through the current matrix at creation, which is also where SvgView
|
|
138
|
+
* computes it.
|
|
139
|
+
*/
|
|
140
|
+
createLinearGradient(x0, y0, x1, y1) {
|
|
141
|
+
return {
|
|
142
|
+
__shapeGradient: true,
|
|
143
|
+
a: this._apply(x0, y0),
|
|
144
|
+
b: this._apply(x1, y1),
|
|
145
|
+
stops: [],
|
|
146
|
+
addColorStop(offset, color) {
|
|
147
|
+
this.stops.push([offset, color]);
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
createRadialGradient() {
|
|
153
|
+
// CA's radial type does not speak canvas's two-circle geometry; wrong
|
|
154
|
+
// pixels are worse than rastered ones, so this stays the fallback.
|
|
155
|
+
this.unsupported = true;
|
|
156
|
+
return { addColorStop() {} };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
drawImage() {
|
|
160
|
+
this.unsupported = true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fillText() {
|
|
164
|
+
this.unsupported = true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
clip() {
|
|
168
|
+
this.unsupported = true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
_pathOps(path) {
|
|
172
|
+
const cmds = path?._cmds;
|
|
173
|
+
if (!Array.isArray(cmds)) {
|
|
174
|
+
this.unsupported = true;
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const c of cmds) {
|
|
179
|
+
if (c.type === 'M') out.push(['move', ...this._apply(c.x, c.y)]);
|
|
180
|
+
else if (c.type === 'L') out.push(['line', ...this._apply(c.x, c.y)]);
|
|
181
|
+
else if (c.type === 'C')
|
|
182
|
+
out.push([
|
|
183
|
+
'curve',
|
|
184
|
+
...this._apply(c.x1, c.y1),
|
|
185
|
+
...this._apply(c.x2, c.y2),
|
|
186
|
+
...this._apply(c.x, c.y),
|
|
187
|
+
]);
|
|
188
|
+
else if (c.type === 'Q')
|
|
189
|
+
out.push([
|
|
190
|
+
'quad',
|
|
191
|
+
...this._apply(c.x1, c.y1),
|
|
192
|
+
...this._apply(c.x, c.y),
|
|
193
|
+
]);
|
|
194
|
+
else if (c.type === 'Z') out.push(['close']);
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
_color(style) {
|
|
200
|
+
if (typeof style !== 'string') {
|
|
201
|
+
this.unsupported = true;
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const parsed = cssColorStraight(style);
|
|
205
|
+
if (!parsed) return null;
|
|
206
|
+
const [r, g, b, a] = parsed;
|
|
207
|
+
return [r, g, b, a * this._alpha];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
fill(path, rule) {
|
|
211
|
+
const ops = this._pathOps(path);
|
|
212
|
+
if (!ops) return;
|
|
213
|
+
const style = this.fillStyle;
|
|
214
|
+
if (style && style.__shapeGradient) {
|
|
215
|
+
const stops = [];
|
|
216
|
+
for (const [offset, color] of style.stops) {
|
|
217
|
+
const parsed = cssColorStraight(String(color));
|
|
218
|
+
if (!parsed) continue;
|
|
219
|
+
const [r, g, b, a] = parsed;
|
|
220
|
+
stops.push([
|
|
221
|
+
Math.min(1, Math.max(0, offset)),
|
|
222
|
+
[r, g, b, a * this._alpha],
|
|
223
|
+
]);
|
|
224
|
+
}
|
|
225
|
+
if (stops.length === 0) return;
|
|
226
|
+
stops.sort((p, q) => p[0] - q[0]);
|
|
227
|
+
this.ops.push({
|
|
228
|
+
kind: 'gradientFill',
|
|
229
|
+
path: ops,
|
|
230
|
+
a: style.a,
|
|
231
|
+
b: style.b,
|
|
232
|
+
stops,
|
|
233
|
+
rule: rule ?? 'nonzero',
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const color = this._color(style);
|
|
238
|
+
if (!color || color[3] === 0) return;
|
|
239
|
+
this.ops.push({ kind: 'fill', path: ops, color, rule: rule ?? 'nonzero' });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
stroke(path) {
|
|
243
|
+
const ops = this._pathOps(path);
|
|
244
|
+
const color = this._color(this.strokeStyle);
|
|
245
|
+
if (!ops || !color || color[3] === 0) return;
|
|
246
|
+
this.ops.push({
|
|
247
|
+
kind: 'stroke',
|
|
248
|
+
path: ops,
|
|
249
|
+
color,
|
|
250
|
+
lineWidth: this.lineWidth * this._scaleFactor(),
|
|
251
|
+
lineCap: this.lineCap,
|
|
252
|
+
dash: this._dash.map((v) => v * this._scaleFactor()),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Paint everything a node draws itself — Node.paint minus the children. */
|
|
258
|
+
function paintSelf(node, ctx) {
|
|
259
|
+
node._paintShadow(ctx);
|
|
260
|
+
node._paintBackground(ctx);
|
|
261
|
+
node.paintContent(ctx);
|
|
262
|
+
node._paintBorder(ctx);
|
|
263
|
+
node._paintOutline(ctx);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const EDGE_PROPS = [
|
|
267
|
+
'borderTopColor',
|
|
268
|
+
'borderRightColor',
|
|
269
|
+
'borderBottomColor',
|
|
270
|
+
'borderLeftColor',
|
|
271
|
+
'borderStartColor',
|
|
272
|
+
'borderEndColor',
|
|
273
|
+
'borderTopWidth',
|
|
274
|
+
'borderRightWidth',
|
|
275
|
+
'borderBottomWidth',
|
|
276
|
+
'borderLeftWidth',
|
|
277
|
+
'borderStartWidth',
|
|
278
|
+
'borderEndWidth',
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
function stylePaintsPlain(node) {
|
|
282
|
+
if (node.kind !== 'box') return false;
|
|
283
|
+
const style = node.style ?? {};
|
|
284
|
+
if (style.backgroundImage || style.boxShadow || style.outlineWidth) {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
for (const prop of EDGE_PROPS) if (style[prop] !== undefined) return false;
|
|
288
|
+
if (style.borderStyle !== undefined && style.borderStyle !== 'solid') {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
if (style.borderWidth !== undefined && typeof style.borderWidth !== 'number')
|
|
292
|
+
return false;
|
|
293
|
+
return uniformRadius(style.borderRadius) !== null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function uniformRadius(radius) {
|
|
297
|
+
if (radius === undefined) return 0;
|
|
298
|
+
if (typeof radius === 'number') return radius;
|
|
299
|
+
return null; // per-corner shapes go to raster
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
class Visual {
|
|
303
|
+
constructor(presenter, node) {
|
|
304
|
+
this.presenter = presenter;
|
|
305
|
+
this.node = node;
|
|
306
|
+
this.layer = presenter.native.createLayer();
|
|
307
|
+
this.parentVisual = null;
|
|
308
|
+
this.props = {}; // last-sent layer properties, diffed against
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
set(next) {
|
|
312
|
+
const diff = {};
|
|
313
|
+
let changed = false;
|
|
314
|
+
for (const key of Object.keys(next)) {
|
|
315
|
+
const value = next[key];
|
|
316
|
+
const prev = this.props[key];
|
|
317
|
+
const same = Array.isArray(value)
|
|
318
|
+
? Array.isArray(prev) &&
|
|
319
|
+
prev.length === value.length &&
|
|
320
|
+
value.every((v, i) => v === prev[i])
|
|
321
|
+
: value === prev;
|
|
322
|
+
if (!same) {
|
|
323
|
+
diff[key] = value;
|
|
324
|
+
this.props[key] = value;
|
|
325
|
+
changed = true;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (changed) this.presenter.native.setLayerProps(this.layer, diff);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
attach(parentVisual) {
|
|
332
|
+
if (this.parentVisual === parentVisual) return;
|
|
333
|
+
this.presenter.native.removeFromSuperlayer(this.layer);
|
|
334
|
+
this.presenter.native.addSublayer(parentVisual.layer, this.layer);
|
|
335
|
+
this.parentVisual = parentVisual;
|
|
336
|
+
// a reparented layer re-sends everything: the new superlayer changes
|
|
337
|
+
// what `frame` is relative to
|
|
338
|
+
this.props = {};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
destroy() {
|
|
342
|
+
this.presenter.native.removeFromSuperlayer(this.layer);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
class RasterState {
|
|
347
|
+
constructor() {
|
|
348
|
+
this.surface = null;
|
|
349
|
+
this.gen = 0;
|
|
350
|
+
this.width = 0;
|
|
351
|
+
this.height = 0;
|
|
352
|
+
this.ctx = null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
ensure(presenter, width, height, scale) {
|
|
356
|
+
if (!this.surface || this.width !== width || this.height !== height) {
|
|
357
|
+
this.surface = presenter.native.createSurface(width, height, scale);
|
|
358
|
+
this.width = width;
|
|
359
|
+
this.height = height;
|
|
360
|
+
this.gen++;
|
|
361
|
+
if (!this.ctx) {
|
|
362
|
+
this.ctx = new CocoaContext2D(
|
|
363
|
+
presenter.native,
|
|
364
|
+
() => this.surface,
|
|
365
|
+
() => this.gen,
|
|
366
|
+
);
|
|
367
|
+
this.ctx._fonts = presenter.fonts;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return this.ctx;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export class CocoaLayerPresenter {
|
|
375
|
+
constructor(window) {
|
|
376
|
+
this.window = window;
|
|
377
|
+
this.native = window._native;
|
|
378
|
+
this.scale = window.scale;
|
|
379
|
+
this.fonts = window.app.fonts;
|
|
380
|
+
this.visuals = new Map(); // node -> Visual
|
|
381
|
+
this.rasters = new Map(); // node -> RasterState
|
|
382
|
+
this.bars = new Map(); // scroller node -> Map(axis -> { layer, raster })
|
|
383
|
+
this.dirty = new Set();
|
|
384
|
+
this.dirtyAll = true; // first frame rasters everything
|
|
385
|
+
this.rootVisual = {
|
|
386
|
+
layer: window._layer,
|
|
387
|
+
};
|
|
388
|
+
this.rootBackground = undefined;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
noteInvalidate(damage, layoutChanged) {
|
|
392
|
+
if (damage == null) {
|
|
393
|
+
this.dirtyAll = true;
|
|
394
|
+
} else if (damage.kind) {
|
|
395
|
+
this.dirty.add(damage);
|
|
396
|
+
} else if (layoutChanged) {
|
|
397
|
+
// A structural claim that names a rect (a child-list mutation's
|
|
398
|
+
// pre-arrangement bound) or nothing at all: the walk finds new and
|
|
399
|
+
// removed nodes by itself, but a SURVIVING node's content can have
|
|
400
|
+
// changed behind an unchanged box — a swapped text child in a
|
|
401
|
+
// flex-grown label was the shot that caught it — and a rect cannot
|
|
402
|
+
// say which node that was. Everything re-rasters; a scroll names its
|
|
403
|
+
// node and stays off this path.
|
|
404
|
+
this.dirtyAll = true;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The whole frame: one walk, one transaction, property diffs only. */
|
|
409
|
+
frame(windowNode) {
|
|
410
|
+
const native = this.native;
|
|
411
|
+
native.txBegin({ disableActions: true });
|
|
412
|
+
try {
|
|
413
|
+
this._syncWindowBackground(windowNode);
|
|
414
|
+
const seen = new Set();
|
|
415
|
+
this._syncChildren(windowNode, this.rootVisual, seen);
|
|
416
|
+
for (const [node, visual] of this.visuals) {
|
|
417
|
+
if (!seen.has(node)) {
|
|
418
|
+
visual.destroy();
|
|
419
|
+
this.visuals.delete(node);
|
|
420
|
+
this.rasters.delete(node);
|
|
421
|
+
const bars = this.bars.get(node);
|
|
422
|
+
if (bars) {
|
|
423
|
+
for (const entry of bars.values()) {
|
|
424
|
+
native.removeFromSuperlayer(entry.layer);
|
|
425
|
+
}
|
|
426
|
+
this.bars.delete(node);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} finally {
|
|
431
|
+
native.txCommit();
|
|
432
|
+
}
|
|
433
|
+
this.dirty.clear();
|
|
434
|
+
this.dirtyAll = false;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
_syncWindowBackground(windowNode) {
|
|
438
|
+
const color = windowNode._windowBackground?.();
|
|
439
|
+
if (color === this.rootBackground) return;
|
|
440
|
+
this.rootBackground = color;
|
|
441
|
+
const parsed =
|
|
442
|
+
typeof color === 'string' ? this.window.app._parseColor(color) : null;
|
|
443
|
+
this.native.setLayerProps(this.rootVisual.layer, {
|
|
444
|
+
backgroundColor: parsed ?? [0, 0, 0, 0],
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
_syncChildren(node, parentVisual, seen) {
|
|
449
|
+
let order = 0;
|
|
450
|
+
for (const child of node.paintOrder()) {
|
|
451
|
+
if (child.isWindow || !child.yoga) continue; // popups are windows
|
|
452
|
+
this._syncNode(child, parentVisual, order++, seen);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
_syncNode(node, parentVisual, order, seen) {
|
|
457
|
+
if (node.style?.display === 'none') return;
|
|
458
|
+
seen.add(node);
|
|
459
|
+
const wantsRaster = !stylePaintsPlain(node);
|
|
460
|
+
let visual = this.visuals.get(node);
|
|
461
|
+
if (visual && visual.isRaster !== wantsRaster) {
|
|
462
|
+
visual.destroy();
|
|
463
|
+
this.rasters.delete(node);
|
|
464
|
+
visual = null;
|
|
465
|
+
}
|
|
466
|
+
if (!visual) {
|
|
467
|
+
visual = new Visual(this, node);
|
|
468
|
+
visual.isRaster = wantsRaster;
|
|
469
|
+
this.visuals.set(node, visual);
|
|
470
|
+
}
|
|
471
|
+
visual.attach(parentVisual);
|
|
472
|
+
|
|
473
|
+
const parentOrigin = this._originOf(parentVisual);
|
|
474
|
+
if (wantsRaster) {
|
|
475
|
+
this._syncRaster(node, visual, parentOrigin, order);
|
|
476
|
+
} else {
|
|
477
|
+
this._syncPropBox(node, visual, parentOrigin, order);
|
|
478
|
+
}
|
|
479
|
+
// Children live inside the node's CONTENT box. A property box clips on
|
|
480
|
+
// its own layer; a rastered box whose layer covers its ink bounds needs
|
|
481
|
+
// an inner clip layer at the content box — and a SCROLLER always gets
|
|
482
|
+
// one, because the clip host is where Core Animation's native scroll
|
|
483
|
+
// lives: children sit at content coordinates and the host's bounds
|
|
484
|
+
// origin is the offset, so a wheel notch is one property set and the
|
|
485
|
+
// render server shifts what it already has.
|
|
486
|
+
const scroller = Boolean(node.isScroller?.());
|
|
487
|
+
let childHost = visual;
|
|
488
|
+
if (scroller || (wantsRaster && node.clipsChildren?.())) {
|
|
489
|
+
childHost = this._ensureClipHost(node, visual, scroller);
|
|
490
|
+
} else if (visual.clipHost) {
|
|
491
|
+
this.native.removeFromSuperlayer(visual.clipHost.layer);
|
|
492
|
+
visual.clipHost = null;
|
|
493
|
+
}
|
|
494
|
+
if (scroller) this._syncBars(node, visual);
|
|
495
|
+
// A <text>'s spans are painted by the paragraph's own raster
|
|
496
|
+
// (collectSpans walks them); giving them layers would draw them twice.
|
|
497
|
+
if (node.kind !== 'text') this._syncChildren(node, childHost, seen);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
_ensureClipHost(node, visual, scroller = false) {
|
|
501
|
+
if (!visual.clipHost) {
|
|
502
|
+
const layer = this.native.createLayer();
|
|
503
|
+
this.native.addSublayer(visual.layer, layer);
|
|
504
|
+
visual.clipHost = { layer, props: {} };
|
|
505
|
+
}
|
|
506
|
+
const abs = node.abs;
|
|
507
|
+
const host = visual.clipHost;
|
|
508
|
+
const s = this.scale;
|
|
509
|
+
const scrollX = scroller ? (node.scrollX ?? 0) : 0;
|
|
510
|
+
const scrollY = scroller ? (node.scrollY ?? 0) : 0;
|
|
511
|
+
const frame = [
|
|
512
|
+
(abs.x - visual.origin.x) / s,
|
|
513
|
+
(abs.y - visual.origin.y) / s,
|
|
514
|
+
Math.max(0, abs.width) / s,
|
|
515
|
+
Math.max(0, abs.height) / s,
|
|
516
|
+
];
|
|
517
|
+
const bounds = [
|
|
518
|
+
scrollX / s,
|
|
519
|
+
scrollY / s,
|
|
520
|
+
Math.max(0, abs.width) / s,
|
|
521
|
+
Math.max(0, abs.height) / s,
|
|
522
|
+
];
|
|
523
|
+
const prev = host.props;
|
|
524
|
+
const frameChanged =
|
|
525
|
+
!prev.frame || prev.frame.some((value, i) => value !== frame[i]);
|
|
526
|
+
const boundsChanged =
|
|
527
|
+
!prev.bounds || prev.bounds.some((value, i) => value !== bounds[i]);
|
|
528
|
+
if (frameChanged || boundsChanged) {
|
|
529
|
+
prev.frame = frame;
|
|
530
|
+
prev.bounds = bounds;
|
|
531
|
+
this.native.setLayerProps(host.layer, {
|
|
532
|
+
frame,
|
|
533
|
+
bounds,
|
|
534
|
+
masksToBounds: true,
|
|
535
|
+
zPosition: 0.5,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
// Children position against CONTENT coordinates: node.abs already has
|
|
539
|
+
// the scroll subtracted (absolutize applies the offset), so adding it
|
|
540
|
+
// back here means a pure scroll changes nothing about any child's
|
|
541
|
+
// frame — only the bounds origin above moves.
|
|
542
|
+
host.origin = { x: abs.x - scrollX, y: abs.y - scrollY };
|
|
543
|
+
return host;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
_originOf(visual) {
|
|
547
|
+
return visual.origin ?? { x: 0, y: 0 };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
_syncPropBox(node, visual, parentOrigin, order) {
|
|
551
|
+
const abs = node.abs;
|
|
552
|
+
const style = node.style ?? {};
|
|
553
|
+
const s = this.scale;
|
|
554
|
+
visual.origin = { x: abs.x, y: abs.y };
|
|
555
|
+
const border =
|
|
556
|
+
typeof style.borderWidth === 'number' ? style.borderWidth : 0;
|
|
557
|
+
visual.set({
|
|
558
|
+
frame: [
|
|
559
|
+
(abs.x - parentOrigin.x) / s,
|
|
560
|
+
(abs.y - parentOrigin.y) / s,
|
|
561
|
+
Math.max(0, abs.width) / s,
|
|
562
|
+
Math.max(0, abs.height) / s,
|
|
563
|
+
],
|
|
564
|
+
zPosition: order,
|
|
565
|
+
hidden: Boolean(node.hidden),
|
|
566
|
+
masksToBounds: Boolean(node.clipsChildren?.()) && !node.isScroller?.(),
|
|
567
|
+
cornerRadius: (uniformRadius(style.borderRadius) ?? 0) / s,
|
|
568
|
+
backgroundColor: style.backgroundColor
|
|
569
|
+
? (this.window.app._parseColor(String(style.backgroundColor)) ?? [
|
|
570
|
+
0, 0, 0, 0,
|
|
571
|
+
])
|
|
572
|
+
: [0, 0, 0, 0],
|
|
573
|
+
borderWidth: border / s,
|
|
574
|
+
borderColor: style.borderColor
|
|
575
|
+
? (this.window.app._parseColor(String(style.borderColor)) ?? [
|
|
576
|
+
0, 0, 0, 0,
|
|
577
|
+
])
|
|
578
|
+
: [0, 0, 0, 0],
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* `<svg>` as CAShapeLayers: record SvgView's own traversal through the
|
|
584
|
+
* ShapeRecorder and hand each captured fill/stroke to a shape layer. One
|
|
585
|
+
* icon becomes two or three server-composited paths instead of a bitmap;
|
|
586
|
+
* anything the recorder cannot express falls back to the raster visual.
|
|
587
|
+
* Returns whether the shape route handled the node.
|
|
588
|
+
*/
|
|
589
|
+
_trySvgShapes(node, visual, rect, sizeChanged) {
|
|
590
|
+
const s = this.scale;
|
|
591
|
+
if (
|
|
592
|
+
!sizeChanged &&
|
|
593
|
+
!this.dirtyAll &&
|
|
594
|
+
!this.dirty.has(node) &&
|
|
595
|
+
visual.shapeSignature
|
|
596
|
+
) {
|
|
597
|
+
return true; // shapes are current
|
|
598
|
+
}
|
|
599
|
+
const recorder = new ShapeRecorder([
|
|
600
|
+
1 / s,
|
|
601
|
+
0,
|
|
602
|
+
0,
|
|
603
|
+
1 / s,
|
|
604
|
+
-rect.x / s,
|
|
605
|
+
-rect.y / s,
|
|
606
|
+
]);
|
|
607
|
+
try {
|
|
608
|
+
node.paintContent(recorder);
|
|
609
|
+
} catch {
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
if (recorder.unsupported) return false;
|
|
613
|
+
const signature = JSON.stringify(recorder.ops);
|
|
614
|
+
if (signature === visual.shapeSignature) return true;
|
|
615
|
+
visual.shapeSignature = signature;
|
|
616
|
+
const native = this.native;
|
|
617
|
+
visual.shapeLayers ??= [];
|
|
618
|
+
while (visual.shapeLayers.length > recorder.ops.length) {
|
|
619
|
+
native.removeFromSuperlayer(visual.shapeLayers.pop().layer);
|
|
620
|
+
}
|
|
621
|
+
const w = rect.width / s;
|
|
622
|
+
const h = rect.height / s;
|
|
623
|
+
recorder.ops.forEach((op, i) => {
|
|
624
|
+
const wantGradient = op.kind === 'gradientFill';
|
|
625
|
+
let entry = visual.shapeLayers[i];
|
|
626
|
+
if (entry && entry.gradient !== wantGradient) {
|
|
627
|
+
native.removeFromSuperlayer(entry.layer);
|
|
628
|
+
entry = null;
|
|
629
|
+
}
|
|
630
|
+
if (!entry) {
|
|
631
|
+
entry = wantGradient
|
|
632
|
+
? {
|
|
633
|
+
gradient: true,
|
|
634
|
+
layer: native.createGradientLayer(),
|
|
635
|
+
// the mask is not in the sublayer tree — CA owns the
|
|
636
|
+
// relationship, and the External's finalizer owns the memory
|
|
637
|
+
mask: native.createShapeLayer(),
|
|
638
|
+
}
|
|
639
|
+
: { gradient: false, layer: native.createShapeLayer() };
|
|
640
|
+
native.addSublayer(visual.layer, entry.layer);
|
|
641
|
+
visual.shapeLayers[i] = entry;
|
|
642
|
+
}
|
|
643
|
+
native.setLayerProps(entry.layer, {
|
|
644
|
+
frame: [0, 0, w, h],
|
|
645
|
+
zPosition: i,
|
|
646
|
+
...(wantGradient ? { mask: entry.mask } : {}),
|
|
647
|
+
});
|
|
648
|
+
if (wantGradient) {
|
|
649
|
+
native.setLayerProps(entry.mask, { frame: [0, 0, w, h] });
|
|
650
|
+
native.setShapeProps(entry.mask, {
|
|
651
|
+
path: op.path,
|
|
652
|
+
fillColor: [0, 0, 0, 1],
|
|
653
|
+
strokeColor: null,
|
|
654
|
+
fillRule: op.rule,
|
|
655
|
+
});
|
|
656
|
+
// start/end are unit coordinates across the layer's bounds
|
|
657
|
+
native.setGradientProps(entry.layer, {
|
|
658
|
+
colors: op.stops.map(([, color]) => color),
|
|
659
|
+
locations: op.stops.map(([offset]) => offset),
|
|
660
|
+
startPoint: [op.a[0] / (w || 1), op.a[1] / (h || 1)],
|
|
661
|
+
endPoint: [op.b[0] / (w || 1), op.b[1] / (h || 1)],
|
|
662
|
+
type: 'axial',
|
|
663
|
+
});
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
native.setShapeProps(
|
|
667
|
+
entry.layer,
|
|
668
|
+
op.kind === 'fill'
|
|
669
|
+
? {
|
|
670
|
+
path: op.path,
|
|
671
|
+
fillColor: op.color,
|
|
672
|
+
strokeColor: null,
|
|
673
|
+
fillRule: op.rule,
|
|
674
|
+
}
|
|
675
|
+
: {
|
|
676
|
+
path: op.path,
|
|
677
|
+
fillColor: null,
|
|
678
|
+
strokeColor: op.color,
|
|
679
|
+
lineWidth: op.lineWidth,
|
|
680
|
+
lineCap: op.lineCap,
|
|
681
|
+
...(op.dash.length ? { lineDashPattern: op.dash } : {}),
|
|
682
|
+
},
|
|
683
|
+
);
|
|
684
|
+
});
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
_dropSvgShapes(visual) {
|
|
689
|
+
if (!visual.shapeLayers) return;
|
|
690
|
+
for (const entry of visual.shapeLayers) {
|
|
691
|
+
this.native.removeFromSuperlayer(entry.layer);
|
|
692
|
+
}
|
|
693
|
+
visual.shapeLayers = null;
|
|
694
|
+
visual.shapeSignature = null;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
_syncRaster(node, visual, parentOrigin, order) {
|
|
698
|
+
const bounds = node._ownPaintBounds
|
|
699
|
+
? node._ownPaintBounds()
|
|
700
|
+
: { ...node.abs };
|
|
701
|
+
const rect = {
|
|
702
|
+
x: Math.floor(bounds.x) - RASTER_PAD,
|
|
703
|
+
y: Math.floor(bounds.y) - RASTER_PAD,
|
|
704
|
+
width: Math.ceil(bounds.width) + RASTER_PAD * 2,
|
|
705
|
+
height: Math.ceil(bounds.height) + RASTER_PAD * 2,
|
|
706
|
+
};
|
|
707
|
+
// the layer's local origin is the ink rect's corner, and that is what
|
|
708
|
+
// children (and the clip host) position against
|
|
709
|
+
visual.origin = { x: rect.x, y: rect.y };
|
|
710
|
+
const s = this.scale;
|
|
711
|
+
visual.set({
|
|
712
|
+
frame: [
|
|
713
|
+
(rect.x - parentOrigin.x) / s,
|
|
714
|
+
(rect.y - parentOrigin.y) / s,
|
|
715
|
+
rect.width / s,
|
|
716
|
+
rect.height / s,
|
|
717
|
+
],
|
|
718
|
+
zPosition: order,
|
|
719
|
+
hidden: Boolean(node.hidden),
|
|
720
|
+
// the layer covers the ink bounds; clipping (if any) belongs to the
|
|
721
|
+
// CONTENT box, which a raster self cannot express — scrolling
|
|
722
|
+
// containers that also raster keep clipping via a child guard below
|
|
723
|
+
masksToBounds: false,
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
let raster = this.rasters.get(node);
|
|
727
|
+
if (!raster) {
|
|
728
|
+
raster = new RasterState();
|
|
729
|
+
this.rasters.set(node, raster);
|
|
730
|
+
}
|
|
731
|
+
const sizeChanged =
|
|
732
|
+
raster.width !== rect.width || raster.height !== rect.height;
|
|
733
|
+
if (node.kind === 'svg') {
|
|
734
|
+
if (this._trySvgShapes(node, visual, rect, sizeChanged)) {
|
|
735
|
+
raster.width = rect.width;
|
|
736
|
+
raster.height = rect.height;
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
this._dropSvgShapes(visual);
|
|
740
|
+
}
|
|
741
|
+
if (!this.dirtyAll && !this.dirty.has(node) && !sizeChanged) return;
|
|
742
|
+
const ctx = raster.ensure(this, rect.width, rect.height, this.window.scale);
|
|
743
|
+
ctx.save();
|
|
744
|
+
try {
|
|
745
|
+
ctx.clearRect(0, 0, rect.width, rect.height);
|
|
746
|
+
ctx.translate(-rect.x, -rect.y);
|
|
747
|
+
paintSelf(node, ctx);
|
|
748
|
+
} finally {
|
|
749
|
+
ctx.restore();
|
|
750
|
+
}
|
|
751
|
+
this.native.surfaceToLayer(raster.surface, visual.layer);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* One thin overlay layer per scroll axis, rastered at the bar's own strip
|
|
756
|
+
* — never at the scroller's size. The strip is the track extent by the
|
|
757
|
+
* bar width plus an antialiasing pad, so a scroll frame re-rasters a few
|
|
758
|
+
* thousand pixels instead of the viewport: the first run of the
|
|
759
|
+
* presenter bench caught the full-size version costing more than the
|
|
760
|
+
* content it decorated (scripts/bench/presenters.js, `scroll`).
|
|
761
|
+
*
|
|
762
|
+
* The painter is still `_paintScrollbars` — both bars, one call — with
|
|
763
|
+
* the other axis's ink falling outside this strip's surface, where
|
|
764
|
+
* CoreGraphics clips it for free. Cheaper than a per-bar painting seam,
|
|
765
|
+
* and the corner case where both bars show costs two thin rasters
|
|
766
|
+
* instead of one bounding box that would be nearly the scroller again.
|
|
767
|
+
*/
|
|
768
|
+
_syncBars(node, visual) {
|
|
769
|
+
const scrollbars = node._scrollbars?.() ?? [];
|
|
770
|
+
let bars = this.bars.get(node);
|
|
771
|
+
if (!scrollbars.length) {
|
|
772
|
+
if (bars) {
|
|
773
|
+
for (const entry of bars.values()) {
|
|
774
|
+
this.native.setLayerProps(entry.layer, { hidden: true });
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (!bars) {
|
|
780
|
+
bars = new Map();
|
|
781
|
+
this.bars.set(node, bars);
|
|
782
|
+
}
|
|
783
|
+
const s = this.scale;
|
|
784
|
+
const pad = Math.ceil(2 * s);
|
|
785
|
+
const seen = new Set();
|
|
786
|
+
for (const bar of scrollbars) {
|
|
787
|
+
seen.add(bar.axis);
|
|
788
|
+
let entry = bars.get(bar.axis);
|
|
789
|
+
if (!entry) {
|
|
790
|
+
entry = { layer: this.native.createLayer(), raster: new RasterState() };
|
|
791
|
+
this.native.addSublayer(visual.layer, entry.layer);
|
|
792
|
+
bars.set(bar.axis, entry);
|
|
793
|
+
}
|
|
794
|
+
const strip =
|
|
795
|
+
bar.axis === 'x'
|
|
796
|
+
? {
|
|
797
|
+
x: bar.trackStart - pad,
|
|
798
|
+
y: bar.crossStart - pad,
|
|
799
|
+
width: bar.trackLength + 2 * pad,
|
|
800
|
+
height: bar.height + 2 * pad,
|
|
801
|
+
}
|
|
802
|
+
: {
|
|
803
|
+
x: bar.crossStart - pad,
|
|
804
|
+
y: bar.trackStart - pad,
|
|
805
|
+
width: bar.width + 2 * pad,
|
|
806
|
+
height: bar.trackLength + 2 * pad,
|
|
807
|
+
};
|
|
808
|
+
const width = Math.max(1, Math.ceil(strip.width));
|
|
809
|
+
const height = Math.max(1, Math.ceil(strip.height));
|
|
810
|
+
this.native.setLayerProps(entry.layer, {
|
|
811
|
+
frame: [
|
|
812
|
+
(strip.x - visual.origin.x) / s,
|
|
813
|
+
(strip.y - visual.origin.y) / s,
|
|
814
|
+
width / s,
|
|
815
|
+
height / s,
|
|
816
|
+
],
|
|
817
|
+
zPosition: 1e6,
|
|
818
|
+
hidden: false,
|
|
819
|
+
});
|
|
820
|
+
const ctx = entry.raster.ensure(this, width, height, this.window.scale);
|
|
821
|
+
ctx.save();
|
|
822
|
+
try {
|
|
823
|
+
ctx.clearRect(0, 0, width, height);
|
|
824
|
+
ctx.translate(-strip.x, -strip.y);
|
|
825
|
+
node._paintScrollbars(ctx);
|
|
826
|
+
} finally {
|
|
827
|
+
ctx.restore();
|
|
828
|
+
}
|
|
829
|
+
this.native.surfaceToLayer(entry.raster.surface, entry.layer);
|
|
830
|
+
}
|
|
831
|
+
for (const [axis, entry] of bars) {
|
|
832
|
+
if (!seen.has(axis)) {
|
|
833
|
+
this.native.setLayerProps(entry.layer, { hidden: true });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|