react-x11 2.5.0 → 2.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/src/Reconciler.js +6 -0
- package/src/cocoa/app.js +240 -31
- package/src/cocoa/context2d.js +287 -26
- package/src/cocoa/panehost.js +20 -2
- package/src/cocoa/panewindow.js +61 -9
- package/src/cocoa/presenter.js +191 -16
- package/src/cocoa/surface.js +9 -3
- package/src/cocoa/window.js +123 -13
- package/src/events.js +23 -5
- package/src/index.d.ts +25 -0
- package/src/nodes.js +849 -138
- package/src/paintcache.js +71 -11
- package/src/screens.js +39 -4
- package/src/svgnodes.js +4 -3
- package/src/types/elements.d.ts +13 -0
package/src/cocoa/context2d.js
CHANGED
|
@@ -13,9 +13,24 @@ import { cssColorStraight } from 'ntk';
|
|
|
13
13
|
|
|
14
14
|
const BLACK = [0, 0, 0, 1];
|
|
15
15
|
|
|
16
|
+
// A colour string is parsed once: a frame over a large tree sets the same
|
|
17
|
+
// few fills thousands of times, and the parse — a regex and four numbers —
|
|
18
|
+
// cost a third of `_applyFill` (measured on the presenter bench's `tiny`
|
|
19
|
+
// cell, 5,000 fills of two colours: 100ms of a 200ms frame). Bounded, and
|
|
20
|
+
// dropped whole rather than evicted, since a palette is a few dozen strings.
|
|
21
|
+
const parsedColors = new Map();
|
|
22
|
+
const PARSED_COLORS_MAX = 256;
|
|
23
|
+
|
|
16
24
|
function parseColor(value) {
|
|
17
25
|
if (value == null) return BLACK;
|
|
18
|
-
|
|
26
|
+
const key = typeof value === 'string' ? value : String(value);
|
|
27
|
+
let parsed = parsedColors.get(key);
|
|
28
|
+
if (parsed === undefined) {
|
|
29
|
+
parsed = cssColorStraight(key) ?? BLACK;
|
|
30
|
+
if (parsedColors.size >= PARSED_COLORS_MAX) parsedColors.clear();
|
|
31
|
+
parsedColors.set(key, parsed);
|
|
32
|
+
}
|
|
33
|
+
return parsed;
|
|
19
34
|
}
|
|
20
35
|
|
|
21
36
|
class LinearGradient {
|
|
@@ -77,6 +92,61 @@ const clamp01 = (v) => Math.min(1, Math.max(0, Number(v) || 0));
|
|
|
77
92
|
const PICT_OP = Object.freeze({ Src: 1, Over: 3 });
|
|
78
93
|
const RENDER = Object.freeze({ PictOp: PICT_OP });
|
|
79
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The path, recorded alongside the native one, so `stroke` can re-issue it
|
|
97
|
+
* in pieces — `CGContextStrokePath` is QUADRATIC in the number of subpaths
|
|
98
|
+
* in the path it is given (issue #456). Measured here, 13-vertex closed
|
|
99
|
+
* rings scattered over a 1024x1024 surface at a 2px line, one stroke call:
|
|
100
|
+
*
|
|
101
|
+
* 500 rings 20ms | 1000 rings 59ms | 2000 rings 208ms | 4000 rings 986ms
|
|
102
|
+
*
|
|
103
|
+
* Splitting the same geometry into strokes of a few hundred subpaths is
|
|
104
|
+
* linear in it: 14ms, 29ms, 56ms, 113ms. The driver is the subpath count,
|
|
105
|
+
* not the vertex count — one 26,000-vertex subpath strokes in 4ms where
|
|
106
|
+
* two thousand 13-vertex ones take 208. The full table, the two shapes
|
|
107
|
+
* left whole and why, are in docs/macos.md
|
|
108
|
+
* §"Stroking a path with many subpaths".
|
|
109
|
+
*
|
|
110
|
+
* Note the X11 context wants the opposite — there a stroke is an a8
|
|
111
|
+
* coverage mask over the path's bounding box uploaded with one PutImage,
|
|
112
|
+
* so a bigger path is fewer uploads over the same pixels. That is why this
|
|
113
|
+
* lives in the backend: a caller that batches for one backend pessimizes
|
|
114
|
+
* the other, and it cannot know which it is drawing on.
|
|
115
|
+
*
|
|
116
|
+
* The commands are a flat number array — `[op, ...args, op, ...args]` —
|
|
117
|
+
* reused across paths, so a path build is three pushes into a packed
|
|
118
|
+
* double array per point next to the napi call it already makes.
|
|
119
|
+
*/
|
|
120
|
+
const P_MOVE = 0;
|
|
121
|
+
const P_LINE = 1;
|
|
122
|
+
const P_CURVE = 2;
|
|
123
|
+
const P_QUAD = 3;
|
|
124
|
+
const P_CLOSE = 4;
|
|
125
|
+
const P_RECT = 5;
|
|
126
|
+
const P_ROUND = 6;
|
|
127
|
+
const P_ARC = 7;
|
|
128
|
+
const P_ELLIPSE = 8;
|
|
129
|
+
/** how many numbers each op carries, and what it costs a chunk's budget */
|
|
130
|
+
const P_ARGS = [2, 2, 6, 4, 0, 4, 8, 6, 4];
|
|
131
|
+
const P_POINTS = [1, 1, 3, 2, 0, 4, 8, 8, 4];
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A chunk closes at the first subpath boundary past either budget. Swept
|
|
135
|
+
* over the shapes above: 512 points is within 5% of the best chunk for
|
|
136
|
+
* every one of them, and the subpath cap catches the degenerate shape the
|
|
137
|
+
* point budget misses — thousands of 3- and 4-point subpaths, where 512
|
|
138
|
+
* points is already 128 strokes' worth of setup.
|
|
139
|
+
*/
|
|
140
|
+
const STROKE_CHUNK_POINTS = 512;
|
|
141
|
+
const STROKE_CHUNK_SUBPATHS = 128;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The off switch, for a process that cannot reach the context — the same
|
|
145
|
+
* line `ctx.strokeChunking = false` draws, and what a bench comparing the
|
|
146
|
+
* two sets. Read once.
|
|
147
|
+
*/
|
|
148
|
+
const NO_STROKE_CHUNKING = process.env.REACT_X11_NO_STROKE_CHUNKING === '1';
|
|
149
|
+
|
|
80
150
|
/**
|
|
81
151
|
* A solid ink for `drawGlyphs` — ntk's `createSolidPicture` answers an
|
|
82
152
|
* XRender picture; here it is the colour itself. Premultiplied 0..1 in, as
|
|
@@ -121,6 +191,11 @@ export class CocoaContext2D {
|
|
|
121
191
|
ctm: [1, 0, 0, 1, 0, 0],
|
|
122
192
|
};
|
|
123
193
|
this._onDirty = null;
|
|
194
|
+
// the recorded path, and whether the native one still matches it (a
|
|
195
|
+
// chunked stroke leaves only its last chunk behind)
|
|
196
|
+
this._cmds = [];
|
|
197
|
+
this._pathStale = false;
|
|
198
|
+
this._strokeChunking = !NO_STROKE_CHUNKING;
|
|
124
199
|
}
|
|
125
200
|
|
|
126
201
|
_s() {
|
|
@@ -137,6 +212,10 @@ export class CocoaContext2D {
|
|
|
137
212
|
n.ctxSetGlobalAlpha(surface, st.globalAlpha);
|
|
138
213
|
n.ctxSetLineDash(surface, st.dash, st.dashOffset);
|
|
139
214
|
this._stack.length = 0;
|
|
215
|
+
// the path went with the surface it was built on; nothing may
|
|
216
|
+
// replay it onto the new one
|
|
217
|
+
this._cmds.length = 0;
|
|
218
|
+
this._pathStale = false;
|
|
140
219
|
}
|
|
141
220
|
return surface;
|
|
142
221
|
}
|
|
@@ -203,6 +282,26 @@ export class CocoaContext2D {
|
|
|
203
282
|
}
|
|
204
283
|
}
|
|
205
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Whether a stroke of a path with many subpaths may go out as several
|
|
287
|
+
* `CGContextStrokePath` calls — on by default, because the alternative
|
|
288
|
+
* is quadratic (see P_MOVE above) and every path small enough for the
|
|
289
|
+
* difference to be invisible is below the threshold anyway.
|
|
290
|
+
*
|
|
291
|
+
* Set it false for a path whose subpaths OVERLAP and whose seams have to
|
|
292
|
+
* composite exactly: chunked, a pixel the strokes of two subpaths each
|
|
293
|
+
* half cover is inked twice at half coverage rather than once at full,
|
|
294
|
+
* and reads a little lighter. `REACT_X11_NO_STROKE_CHUNKING=1` is the
|
|
295
|
+
* same switch for a whole process.
|
|
296
|
+
*/
|
|
297
|
+
get strokeChunking() {
|
|
298
|
+
return this._strokeChunking;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
set strokeChunking(value) {
|
|
302
|
+
this._strokeChunking = !!value;
|
|
303
|
+
}
|
|
304
|
+
|
|
206
305
|
get font() {
|
|
207
306
|
return this._state.font;
|
|
208
307
|
}
|
|
@@ -374,20 +473,96 @@ export class CocoaContext2D {
|
|
|
374
473
|
|
|
375
474
|
// --- paths ---------------------------------------------------------------
|
|
376
475
|
|
|
476
|
+
/**
|
|
477
|
+
* The surface to build or paint the current path on, with the native
|
|
478
|
+
* path restored first if a chunked stroke consumed it. Lazy on purpose:
|
|
479
|
+
* a caller that strokes and then starts a new path — which is every
|
|
480
|
+
* caller in a paint loop — never pays for the rebuild.
|
|
481
|
+
*/
|
|
482
|
+
_path() {
|
|
483
|
+
const surface = this._s();
|
|
484
|
+
if (this._pathStale) {
|
|
485
|
+
this._pathStale = false;
|
|
486
|
+
this._native.ctxBeginPath(surface);
|
|
487
|
+
this._emit(surface, 0, this._cmds.length);
|
|
488
|
+
}
|
|
489
|
+
return surface;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** replay recorded commands `[from, to)` into the native path */
|
|
493
|
+
_emit(surface, from, to) {
|
|
494
|
+
const c = this._cmds;
|
|
495
|
+
const n = this._native;
|
|
496
|
+
for (let i = from; i < to;) {
|
|
497
|
+
const op = c[i];
|
|
498
|
+
const a = i + 1;
|
|
499
|
+
if (op === P_MOVE) n.ctxMoveTo(surface, c[a], c[a + 1]);
|
|
500
|
+
else if (op === P_LINE) n.ctxLineTo(surface, c[a], c[a + 1]);
|
|
501
|
+
else if (op === P_CURVE)
|
|
502
|
+
n.ctxCurveTo(
|
|
503
|
+
surface,
|
|
504
|
+
c[a],
|
|
505
|
+
c[a + 1],
|
|
506
|
+
c[a + 2],
|
|
507
|
+
c[a + 3],
|
|
508
|
+
c[a + 4],
|
|
509
|
+
c[a + 5],
|
|
510
|
+
);
|
|
511
|
+
else if (op === P_QUAD)
|
|
512
|
+
n.ctxQuadTo(surface, c[a], c[a + 1], c[a + 2], c[a + 3]);
|
|
513
|
+
else if (op === P_CLOSE) n.ctxClosePath(surface);
|
|
514
|
+
else if (op === P_RECT)
|
|
515
|
+
n.ctxRect(surface, c[a], c[a + 1], c[a + 2], c[a + 3]);
|
|
516
|
+
else if (op === P_ROUND)
|
|
517
|
+
n.ctxRoundRect(
|
|
518
|
+
surface,
|
|
519
|
+
c[a],
|
|
520
|
+
c[a + 1],
|
|
521
|
+
c[a + 2],
|
|
522
|
+
c[a + 3],
|
|
523
|
+
c[a + 4],
|
|
524
|
+
c[a + 5],
|
|
525
|
+
c[a + 6],
|
|
526
|
+
c[a + 7],
|
|
527
|
+
);
|
|
528
|
+
else if (op === P_ARC)
|
|
529
|
+
n.ctxArc(
|
|
530
|
+
surface,
|
|
531
|
+
c[a],
|
|
532
|
+
c[a + 1],
|
|
533
|
+
c[a + 2],
|
|
534
|
+
c[a + 3],
|
|
535
|
+
c[a + 4],
|
|
536
|
+
!!c[a + 5],
|
|
537
|
+
);
|
|
538
|
+
else if (op === P_ELLIPSE)
|
|
539
|
+
n.ctxEllipse(surface, c[a], c[a + 1], c[a + 2], c[a + 3]);
|
|
540
|
+
i += 1 + P_ARGS[op];
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
377
544
|
beginPath() {
|
|
545
|
+
this._cmds.length = 0;
|
|
546
|
+
this._pathStale = false;
|
|
378
547
|
this._native.ctxBeginPath(this._s());
|
|
379
548
|
}
|
|
380
549
|
|
|
381
550
|
moveTo(x, y) {
|
|
382
|
-
this.
|
|
551
|
+
const surface = this._path();
|
|
552
|
+
this._cmds.push(P_MOVE, x, y);
|
|
553
|
+
this._native.ctxMoveTo(surface, x, y);
|
|
383
554
|
}
|
|
384
555
|
|
|
385
556
|
lineTo(x, y) {
|
|
386
|
-
this.
|
|
557
|
+
const surface = this._path();
|
|
558
|
+
this._cmds.push(P_LINE, x, y);
|
|
559
|
+
this._native.ctxLineTo(surface, x, y);
|
|
387
560
|
}
|
|
388
561
|
|
|
389
562
|
rect(x, y, w, h) {
|
|
390
|
-
this.
|
|
563
|
+
const surface = this._path();
|
|
564
|
+
this._cmds.push(P_RECT, x, y, w, h);
|
|
565
|
+
this._native.ctxRect(surface, x, y, w, h);
|
|
391
566
|
}
|
|
392
567
|
|
|
393
568
|
roundRect(x, y, w, h, radii) {
|
|
@@ -398,37 +573,45 @@ export class CocoaContext2D {
|
|
|
398
573
|
else if (r.length === 3) r = [r[0], r[1], r[2], r[1]];
|
|
399
574
|
const cap = Math.min(Math.abs(w) / 2, Math.abs(h) / 2);
|
|
400
575
|
const clamp = (v) => Math.max(0, Math.min(Number(v) || 0, cap));
|
|
401
|
-
this.
|
|
402
|
-
|
|
403
|
-
x,
|
|
404
|
-
y,
|
|
405
|
-
w,
|
|
406
|
-
h,
|
|
576
|
+
const surface = this._path();
|
|
577
|
+
const [r0, r1, r2, r3] = [
|
|
407
578
|
clamp(r[0]),
|
|
408
579
|
clamp(r[1]),
|
|
409
580
|
clamp(r[2]),
|
|
410
581
|
clamp(r[3]),
|
|
411
|
-
|
|
582
|
+
];
|
|
583
|
+
this._cmds.push(P_ROUND, x, y, w, h, r0, r1, r2, r3);
|
|
584
|
+
this._native.ctxRoundRect(surface, x, y, w, h, r0, r1, r2, r3);
|
|
412
585
|
}
|
|
413
586
|
|
|
414
587
|
arc(x, y, radius, start, end, anticlockwise = false) {
|
|
415
|
-
this.
|
|
588
|
+
const surface = this._path();
|
|
589
|
+
this._cmds.push(P_ARC, x, y, radius, start, end, anticlockwise ? 1 : 0);
|
|
590
|
+
this._native.ctxArc(surface, x, y, radius, start, end, anticlockwise);
|
|
416
591
|
}
|
|
417
592
|
|
|
418
593
|
ellipse(x, y, rx, ry) {
|
|
419
|
-
this.
|
|
594
|
+
const surface = this._path();
|
|
595
|
+
this._cmds.push(P_ELLIPSE, x, y, rx, ry);
|
|
596
|
+
this._native.ctxEllipse(surface, x, y, rx, ry);
|
|
420
597
|
}
|
|
421
598
|
|
|
422
599
|
bezierCurveTo(c1x, c1y, c2x, c2y, x, y) {
|
|
423
|
-
this.
|
|
600
|
+
const surface = this._path();
|
|
601
|
+
this._cmds.push(P_CURVE, c1x, c1y, c2x, c2y, x, y);
|
|
602
|
+
this._native.ctxCurveTo(surface, c1x, c1y, c2x, c2y, x, y);
|
|
424
603
|
}
|
|
425
604
|
|
|
426
605
|
quadraticCurveTo(cx, cy, x, y) {
|
|
427
|
-
this.
|
|
606
|
+
const surface = this._path();
|
|
607
|
+
this._cmds.push(P_QUAD, cx, cy, x, y);
|
|
608
|
+
this._native.ctxQuadTo(surface, cx, cy, x, y);
|
|
428
609
|
}
|
|
429
610
|
|
|
430
611
|
closePath() {
|
|
431
|
-
this.
|
|
612
|
+
const surface = this._path();
|
|
613
|
+
this._cmds.push(P_CLOSE);
|
|
614
|
+
this._native.ctxClosePath(surface);
|
|
432
615
|
}
|
|
433
616
|
|
|
434
617
|
// --- painting ------------------------------------------------------------
|
|
@@ -462,24 +645,95 @@ export class CocoaContext2D {
|
|
|
462
645
|
_replayPath(path) {
|
|
463
646
|
const cmds = path?._cmds;
|
|
464
647
|
if (!Array.isArray(cmds)) return false;
|
|
465
|
-
|
|
466
|
-
const s = this._s();
|
|
467
|
-
n.ctxBeginPath(s);
|
|
648
|
+
this.beginPath();
|
|
468
649
|
for (const c of cmds) {
|
|
469
|
-
if (c.type === 'M')
|
|
470
|
-
else if (c.type === 'L')
|
|
650
|
+
if (c.type === 'M') this.moveTo(c.x, c.y);
|
|
651
|
+
else if (c.type === 'L') this.lineTo(c.x, c.y);
|
|
471
652
|
else if (c.type === 'C')
|
|
472
|
-
|
|
473
|
-
else if (c.type === 'Q')
|
|
474
|
-
else if (c.type === 'Z')
|
|
653
|
+
this.bezierCurveTo(c.x1, c.y1, c.x2, c.y2, c.x, c.y);
|
|
654
|
+
else if (c.type === 'Q') this.quadraticCurveTo(c.x1, c.y1, c.x, c.y);
|
|
655
|
+
else if (c.type === 'Z') this.closePath();
|
|
475
656
|
}
|
|
476
657
|
return true;
|
|
477
658
|
}
|
|
478
659
|
|
|
660
|
+
/**
|
|
661
|
+
* Whether a stroke of the current path may be split into several
|
|
662
|
+
* `CGContextStrokePath` calls. Two things say no:
|
|
663
|
+
*
|
|
664
|
+
* - A hairline. At or below a device-space width of 1 CoreGraphics
|
|
665
|
+
* strokes through a path that is already linear in the subpath count
|
|
666
|
+
* — 2,000 rings cost 7ms whole — and splitting it is a 2x LOSS, the
|
|
667
|
+
* per-call setup with nothing to win back.
|
|
668
|
+
* - Anything that composites. Each chunk paints separately, so where two
|
|
669
|
+
* subpaths' strokes overlap, a translucent ink, a globalAlpha or a
|
|
670
|
+
* shadow blends twice and reads darker where one call blends the union
|
|
671
|
+
* once. An opaque ink is exact everywhere the coverage is full, which
|
|
672
|
+
* is what the geometry that gets big looks like; what is left is the
|
|
673
|
+
* antialiased fringe at those same overlaps, half-covered twice
|
|
674
|
+
* instead of covered once, which reads a little lighter — the one
|
|
675
|
+
* difference `strokeChunking` exists to turn off.
|
|
676
|
+
*/
|
|
677
|
+
_chunkableStroke() {
|
|
678
|
+
if (!this._strokeChunking) return false;
|
|
679
|
+
const st = this._state;
|
|
680
|
+
const [a, b, c, d] = st.ctm;
|
|
681
|
+
const scale = Math.sqrt(Math.abs(a * d - b * c));
|
|
682
|
+
if (!(st.lineWidth * scale > 1)) return false;
|
|
683
|
+
if (st.globalAlpha < 1) return false;
|
|
684
|
+
if (parseColor(st.strokeStyle)[3] < 1) return false;
|
|
685
|
+
if (st.shadowBlur > 0 && parseColor(st.shadowColor)[3] > 0) return false;
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Stroke the recorded path as a series of chunks, cut at subpath
|
|
691
|
+
* boundaries so every chunk carries the `moveTo` its segments start
|
|
692
|
+
* from. Answers false when there was nothing to split, leaving the
|
|
693
|
+
* native path untouched for the caller's single stroke.
|
|
694
|
+
*/
|
|
695
|
+
_strokeChunks(surface) {
|
|
696
|
+
if (!this._chunkableStroke()) return false;
|
|
697
|
+
const cmds = this._cmds;
|
|
698
|
+
const n = this._native;
|
|
699
|
+
let start = 0;
|
|
700
|
+
let points = 0;
|
|
701
|
+
let subpaths = 0;
|
|
702
|
+
let split = false;
|
|
703
|
+
for (let i = 0; i < cmds.length;) {
|
|
704
|
+
const op = cmds[i];
|
|
705
|
+
if (
|
|
706
|
+
op === P_MOVE &&
|
|
707
|
+
i > start &&
|
|
708
|
+
(points >= STROKE_CHUNK_POINTS || subpaths >= STROKE_CHUNK_SUBPATHS)
|
|
709
|
+
) {
|
|
710
|
+
n.ctxBeginPath(surface);
|
|
711
|
+
this._emit(surface, start, i);
|
|
712
|
+
n.ctxStroke(surface);
|
|
713
|
+
split = true;
|
|
714
|
+
start = i;
|
|
715
|
+
points = 0;
|
|
716
|
+
subpaths = 0;
|
|
717
|
+
}
|
|
718
|
+
if (op === P_MOVE) subpaths++;
|
|
719
|
+
points += P_POINTS[op];
|
|
720
|
+
i += 1 + P_ARGS[op];
|
|
721
|
+
}
|
|
722
|
+
if (!split) return false;
|
|
723
|
+
n.ctxBeginPath(surface);
|
|
724
|
+
this._emit(surface, start, cmds.length);
|
|
725
|
+
n.ctxStroke(surface);
|
|
726
|
+
// the native path is the last chunk now; _path() puts the whole one
|
|
727
|
+
// back if anything asks for it
|
|
728
|
+
this._pathStale = true;
|
|
729
|
+
return true;
|
|
730
|
+
}
|
|
731
|
+
|
|
479
732
|
fill(pathOrRule, maybeRule) {
|
|
480
733
|
const hasPath = pathOrRule != null && typeof pathOrRule === 'object';
|
|
481
734
|
const rule = hasPath ? maybeRule : pathOrRule;
|
|
482
735
|
if (hasPath && !this._replayPath(pathOrRule)) return;
|
|
736
|
+
this._path();
|
|
483
737
|
const style = this._state.fillStyle;
|
|
484
738
|
if (style instanceof LinearGradient) {
|
|
485
739
|
const { coords, flat } = style._normalized();
|
|
@@ -503,7 +757,14 @@ export class CocoaContext2D {
|
|
|
503
757
|
return;
|
|
504
758
|
}
|
|
505
759
|
this._applyStroke();
|
|
506
|
-
|
|
760
|
+
// one call per chunk where the path has enough subpaths to be worth it
|
|
761
|
+
// — see P_MOVE and _chunkableStroke above — and one for everything
|
|
762
|
+
// else. The chunks are issued from the record, so a stroke that splits
|
|
763
|
+
// never pays for the restore a previous split owed: `_path()` is asked
|
|
764
|
+
// for the surface only on the whole-path route.
|
|
765
|
+
if (!this._strokeChunks(this._s())) {
|
|
766
|
+
this._native.ctxStroke(this._path());
|
|
767
|
+
}
|
|
507
768
|
this._dirty();
|
|
508
769
|
}
|
|
509
770
|
|
|
@@ -515,7 +776,7 @@ export class CocoaContext2D {
|
|
|
515
776
|
) {
|
|
516
777
|
return;
|
|
517
778
|
}
|
|
518
|
-
this._native.ctxClip(this.
|
|
779
|
+
this._native.ctxClip(this._path());
|
|
519
780
|
}
|
|
520
781
|
|
|
521
782
|
fillRect(x, y, w, h) {
|
package/src/cocoa/panehost.js
CHANGED
|
@@ -36,10 +36,28 @@ export class CocoaPaneHost {
|
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* A pane-present landed: scan out of the named shared surface.
|
|
41
|
+
*
|
|
42
|
+
* A present names a buffer the pane may since have retired. The channel
|
|
43
|
+
* is a queue and nothing acknowledges a present, so a pane-rect that
|
|
44
|
+
* changes the pane's size (or its scale — the host window moved to another
|
|
45
|
+
* display during startup, which is how this was first hit) can cross a
|
|
46
|
+
* present already in flight: the pane rebuilds its ring on that rect and
|
|
47
|
+
* releases the old one (`CocoaPaneWindow._ensureSurface`), and by the
|
|
48
|
+
* time the host looks the id up the surface is gone. That present is
|
|
49
|
+
* stale by construction — the pane's full frame on the fresh ring is
|
|
50
|
+
* queued behind it — so it is dropped, and the layer keeps the frame it
|
|
51
|
+
* already holds a reference to. Nothing else here throws; any other
|
|
52
|
+
* error is the bug it says it is.
|
|
53
|
+
*/
|
|
40
54
|
present(iosurfaceId) {
|
|
41
55
|
if (this.destroyed) return;
|
|
42
|
-
|
|
56
|
+
try {
|
|
57
|
+
this._native.setLayerContentsIOSurface(this.layer, iosurfaceId);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (!/IOSurfaceLookup/.test(err?.message ?? '')) throw err;
|
|
60
|
+
}
|
|
43
61
|
}
|
|
44
62
|
|
|
45
63
|
destroy() {
|
package/src/cocoa/panewindow.js
CHANGED
|
@@ -50,6 +50,7 @@ export class CocoaPaneWindow {
|
|
|
50
50
|
this._dirty = false;
|
|
51
51
|
this._flushDamage = 'full';
|
|
52
52
|
this._seq = 0;
|
|
53
|
+
this._presentedAt = -Infinity;
|
|
53
54
|
this._reactX11Node = null;
|
|
54
55
|
app._registerWindow(this);
|
|
55
56
|
}
|
|
@@ -98,8 +99,24 @@ export class CocoaPaneWindow {
|
|
|
98
99
|
return this.app._requestFrame(cb);
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Whether the host may still be showing the frame before the last one —
|
|
104
|
+
* the gate `flushPendingFrames` (src/frames.js) is written around: a
|
|
105
|
+
* discrete input paints on the spot only when the last frame has landed,
|
|
106
|
+
* which is what folds a burst into one paced frame instead of a frame per
|
|
107
|
+
* event. On X11 the server says when a present was shown; a pane hears
|
|
108
|
+
* nothing back from the host, which flips the layer on its next pump tick
|
|
109
|
+
* and has Core Animation scan it out at the following refresh — so a
|
|
110
|
+
* present counts as in flight for one frame interval. Without a gate here
|
|
111
|
+
* every message the host queued while the pane was busy was answered
|
|
112
|
+
* with a full frame of its own: a forty-tick resize of a pane whose frame
|
|
113
|
+
* costs 300ms stepped through forty sizes for twelve seconds after the
|
|
114
|
+
* drag had ended, each one the previous surface stretched to the layer.
|
|
115
|
+
*/
|
|
101
116
|
frameInFlight() {
|
|
102
|
-
return
|
|
117
|
+
return (
|
|
118
|
+
performance.now() - this._presentedAt < this.app.frameIntervalFor(null)
|
|
119
|
+
);
|
|
103
120
|
}
|
|
104
121
|
|
|
105
122
|
// Three buffers, not two. A pane is cross-process: the host keeps
|
|
@@ -112,6 +129,28 @@ export class CocoaPaneWindow {
|
|
|
112
129
|
// windows need only two because Core Animation latches the front buffer.
|
|
113
130
|
static RING = 3;
|
|
114
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Free the ring now, not when V8 collects the handles — CocoaWindow's
|
|
134
|
+
* `_releaseBacking`, for three buffers instead of two: a host window
|
|
135
|
+
* drag resizes the pane a tick at a time, and each tick retires a ring
|
|
136
|
+
* that the finalizer would have held until a collection happened to run.
|
|
137
|
+
* The host keeps its own reference to whichever buffer its layer shows,
|
|
138
|
+
* so the frame on glass survives the free; a present of a retired buffer
|
|
139
|
+
* still in the channel finds no surface, and the host drops it
|
|
140
|
+
* (`CocoaPaneHost.present`) — the next present is the full frame on the
|
|
141
|
+
* new ring anyway. Bridges before 0.4 have no `releaseSurface`; there the
|
|
142
|
+
* finalizer is still the only owner, and this is the drop it always was.
|
|
143
|
+
*/
|
|
144
|
+
_releaseRing() {
|
|
145
|
+
const ring = this._ring;
|
|
146
|
+
this._ring = null;
|
|
147
|
+
this._surface = null;
|
|
148
|
+
if (!ring) return;
|
|
149
|
+
const release = this._native.releaseSurface;
|
|
150
|
+
if (typeof release !== 'function') return;
|
|
151
|
+
for (const s of ring) release.call(this._native, s.handle);
|
|
152
|
+
}
|
|
153
|
+
|
|
115
154
|
_ensureSurface() {
|
|
116
155
|
const w = this.width;
|
|
117
156
|
const h = this.height;
|
|
@@ -121,6 +160,7 @@ export class CocoaPaneWindow {
|
|
|
121
160
|
this._surfaceSize?.height !== h
|
|
122
161
|
) {
|
|
123
162
|
const hadSurface = Boolean(this._ring);
|
|
163
|
+
this._releaseRing();
|
|
124
164
|
this._ring = [];
|
|
125
165
|
for (let i = 0; i < CocoaPaneWindow.RING; i += 1) {
|
|
126
166
|
const s = this._native.createSurfaceIOSurface(w, h, this.scale, true);
|
|
@@ -134,12 +174,9 @@ export class CocoaPaneWindow {
|
|
|
134
174
|
this._surfaceSize = { width: w, height: h };
|
|
135
175
|
this._surfaceGen++;
|
|
136
176
|
this._flushDamage = 'full';
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (node && !node.destroyed) node.invalidate(true, null, 'resize');
|
|
141
|
-
});
|
|
142
|
-
}
|
|
177
|
+
// decided when the flush reports its rects — see CocoaWindow's
|
|
178
|
+
// `_ensureSurface` for why not a queued full frame from here
|
|
179
|
+
if (hadSurface) this._freshSurface = true;
|
|
143
180
|
}
|
|
144
181
|
return this._surface;
|
|
145
182
|
}
|
|
@@ -163,6 +200,20 @@ export class CocoaPaneWindow {
|
|
|
163
200
|
}
|
|
164
201
|
|
|
165
202
|
noteFrameDamage(rects) {
|
|
203
|
+
if (this._freshSurface) {
|
|
204
|
+
this._freshSurface = false;
|
|
205
|
+
// a bounded flush onto a fresh ring leaves garbage outside its rects:
|
|
206
|
+
// one full frame, and no present until it lands (CocoaWindow's rule)
|
|
207
|
+
if (rects) {
|
|
208
|
+
this._holdPresent = true;
|
|
209
|
+
const node = this._reactX11Node;
|
|
210
|
+
if (node && !node.destroyed) node.invalidate(false, null, 'resize');
|
|
211
|
+
} else {
|
|
212
|
+
this._holdPresent = false;
|
|
213
|
+
}
|
|
214
|
+
} else if (!rects) {
|
|
215
|
+
this._holdPresent = false;
|
|
216
|
+
}
|
|
166
217
|
if (this._flushDamage === 'full') return;
|
|
167
218
|
if (!rects) {
|
|
168
219
|
this._flushDamage = 'full';
|
|
@@ -190,6 +241,7 @@ export class CocoaPaneWindow {
|
|
|
190
241
|
/** Flip and tell the host, instead of touching any layer of our own. */
|
|
191
242
|
present() {
|
|
192
243
|
if (!this._dirty || !this._ring || this.destroyed) return;
|
|
244
|
+
if (this._holdPresent) return;
|
|
193
245
|
this._dirty = false;
|
|
194
246
|
const shown = this._ring[this._drawIndex];
|
|
195
247
|
this._native.surfaceUnlock(shown.handle);
|
|
@@ -202,6 +254,7 @@ export class CocoaPaneWindow {
|
|
|
202
254
|
width: this.width,
|
|
203
255
|
height: this.height,
|
|
204
256
|
});
|
|
257
|
+
this._presentedAt = performance.now();
|
|
205
258
|
this._shownIndex = this._drawIndex;
|
|
206
259
|
// the next buffer round the ring — two behind what the host will be
|
|
207
260
|
// showing, so it is safe to write even before the host has switched
|
|
@@ -230,7 +283,6 @@ export class CocoaPaneWindow {
|
|
|
230
283
|
if (this.destroyed) return;
|
|
231
284
|
this.destroyed = true;
|
|
232
285
|
this.app._unregisterWindow(this);
|
|
233
|
-
this.
|
|
234
|
-
this._surface = null;
|
|
286
|
+
this._releaseRing();
|
|
235
287
|
}
|
|
236
288
|
}
|