react-x11 2.5.0 → 2.6.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 +4 -3
- package/src/Reconciler.js +6 -0
- package/src/cocoa/app.js +199 -12
- package/src/cocoa/context2d.js +16 -1
- 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/svgnodes.js +4 -3
- package/src/types/elements.d.ts +13 -0
package/src/cocoa/presenter.js
CHANGED
|
@@ -19,12 +19,19 @@
|
|
|
19
19
|
//
|
|
20
20
|
// Sibling order is zPosition, assigned from the node's own paintOrder() —
|
|
21
21
|
// no sublayer-list surgery, ever. Dirt arrives on the invalidate channel
|
|
22
|
-
// (`noteInvalidate`): a node means that node,
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// cheaper than
|
|
22
|
+
// (`noteInvalidate`): a node means that node, a bare rect means that part
|
|
23
|
+
// of every raster it touches, null means everything (the same "no bound
|
|
24
|
+
// named repaints everything" rule the X11 damage model has), and geometry
|
|
25
|
+
// is re-diffed every frame because comparing four numbers is cheaper than
|
|
26
|
+
// knowing.
|
|
26
27
|
import { cssColorStraight } from 'ntk';
|
|
27
28
|
|
|
29
|
+
import {
|
|
30
|
+
Node,
|
|
31
|
+
addDamageRect,
|
|
32
|
+
damageToPaint,
|
|
33
|
+
intersectRects,
|
|
34
|
+
} from '../nodes.js';
|
|
28
35
|
import { CocoaContext2D } from './context2d.js';
|
|
29
36
|
|
|
30
37
|
const RASTER_PAD = 2; // antialiasing/italic overhang outside the ink bounds
|
|
@@ -254,8 +261,27 @@ class ShapeRecorder {
|
|
|
254
261
|
}
|
|
255
262
|
}
|
|
256
263
|
|
|
257
|
-
/**
|
|
264
|
+
/**
|
|
265
|
+
* Paint everything a node draws itself — Node.paint minus the children.
|
|
266
|
+
*
|
|
267
|
+
* An element that overrides `paint` (every drawing element in
|
|
268
|
+
* @react-x11/components does: `super.paint(ctx)` for the box, then the
|
|
269
|
+
* scene) has its content nowhere but in that override, so the override is
|
|
270
|
+
* what a raster replays — with the children held back by `_ownPaintOnly`,
|
|
271
|
+
* the one presenter-side flag `Node._paintChildren` honours, because they
|
|
272
|
+
* have visuals of their own. Everything else paints piecewise, which is
|
|
273
|
+
* what `Node.paint` would do minus the child walk.
|
|
274
|
+
*/
|
|
258
275
|
function paintSelf(node, ctx) {
|
|
276
|
+
if (node.paint !== Node.prototype.paint) {
|
|
277
|
+
node._ownPaintOnly = true;
|
|
278
|
+
try {
|
|
279
|
+
node.paint(ctx);
|
|
280
|
+
} finally {
|
|
281
|
+
node._ownPaintOnly = false;
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
259
285
|
node._paintShadow(ctx);
|
|
260
286
|
node._paintBackground(ctx);
|
|
261
287
|
node.paintContent(ctx);
|
|
@@ -263,6 +289,26 @@ function paintSelf(node, ctx) {
|
|
|
263
289
|
node._paintOutline(ctx);
|
|
264
290
|
}
|
|
265
291
|
|
|
292
|
+
// Past this many bare-rect claims in one frame the overlap test costs more
|
|
293
|
+
// than it saves, and the answer is the one a null claim gives: everything.
|
|
294
|
+
const MAX_DIRTY_RECTS = 64;
|
|
295
|
+
|
|
296
|
+
// The pass list of a raster that repaints in full: one pass, unbounded.
|
|
297
|
+
const FULL_PASS = Object.freeze([null]);
|
|
298
|
+
|
|
299
|
+
/** A rect grown outward to whole pixels — a pass clears and clips to its
|
|
300
|
+
* edges, and a fractional edge would antialias the clip into a seam. */
|
|
301
|
+
function wholePixels(rect) {
|
|
302
|
+
const x = Math.floor(rect.x);
|
|
303
|
+
const y = Math.floor(rect.y);
|
|
304
|
+
return {
|
|
305
|
+
x,
|
|
306
|
+
y,
|
|
307
|
+
width: Math.ceil(rect.x + rect.width) - x,
|
|
308
|
+
height: Math.ceil(rect.y + rect.height) - y,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
266
312
|
const EDGE_PROPS = [
|
|
267
313
|
'borderTopColor',
|
|
268
314
|
'borderRightColor',
|
|
@@ -354,6 +400,9 @@ class RasterState {
|
|
|
354
400
|
|
|
355
401
|
ensure(presenter, width, height, scale) {
|
|
356
402
|
if (!this.surface || this.width !== width || this.height !== height) {
|
|
403
|
+
// the layer holds its own copy of what it shows, so the bitmap a
|
|
404
|
+
// resize retires can go now rather than with the handle's finalizer
|
|
405
|
+
this.release(presenter.native);
|
|
357
406
|
this.surface = presenter.native.createSurface(width, height, scale);
|
|
358
407
|
this.width = width;
|
|
359
408
|
this.height = height;
|
|
@@ -369,6 +418,16 @@ class RasterState {
|
|
|
369
418
|
}
|
|
370
419
|
return this.ctx;
|
|
371
420
|
}
|
|
421
|
+
|
|
422
|
+
/** Free the bitmap now (bridge 0.4's `releaseSurface`); older bridges
|
|
423
|
+
* free it from the handle's finalizer, and this is then just the drop. */
|
|
424
|
+
release(native) {
|
|
425
|
+
const surface = this.surface;
|
|
426
|
+
this.surface = null;
|
|
427
|
+
if (surface && typeof native.releaseSurface === 'function') {
|
|
428
|
+
native.releaseSurface(surface);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
372
431
|
}
|
|
373
432
|
|
|
374
433
|
export class CocoaLayerPresenter {
|
|
@@ -380,8 +439,14 @@ export class CocoaLayerPresenter {
|
|
|
380
439
|
this.visuals = new Map(); // node -> Visual
|
|
381
440
|
this.rasters = new Map(); // node -> RasterState
|
|
382
441
|
this.bars = new Map(); // scroller node -> Map(axis -> { layer, raster })
|
|
383
|
-
|
|
442
|
+
// Claims since the last frame — taken at the top of `frame()`, the way
|
|
443
|
+
// the X11 path takes its damage before painting, so a claim made from
|
|
444
|
+
// inside a paint lands in the next frame instead of being cleared with
|
|
445
|
+
// this one.
|
|
446
|
+
this.dirty = new Set(); // nodes
|
|
447
|
+
this.dirtyRects = []; // bare rects, window coordinates
|
|
384
448
|
this.dirtyAll = true; // first frame rasters everything
|
|
449
|
+
this._claims = null; // the frame in progress: { all, nodes, rects }
|
|
385
450
|
this.rootVisual = {
|
|
386
451
|
layer: window._layer,
|
|
387
452
|
};
|
|
@@ -402,12 +467,77 @@ export class CocoaLayerPresenter {
|
|
|
402
467
|
// say which node that was. Everything re-rasters; a scroll names its
|
|
403
468
|
// node and stays off this path.
|
|
404
469
|
this.dirtyAll = true;
|
|
470
|
+
} else if (damage.width > 0 && damage.height > 0) {
|
|
471
|
+
// A bare rect with no layout behind it is a claim about pixels — an
|
|
472
|
+
// element's `invalidate(false, rect)` for the box a dragged node
|
|
473
|
+
// moved through, the region `scrollContents` shifts, the strip an
|
|
474
|
+
// animation ticks in — and it cannot name the node it came from
|
|
475
|
+
// either. The frame repaints that part of every raster visual whose
|
|
476
|
+
// ink the rect touches (the same conservative answer the damage model
|
|
477
|
+
// gives a rect: whatever draws there repaints, clipped to it);
|
|
478
|
+
// property boxes carry no raster and re-diff every frame regardless.
|
|
479
|
+
// Copied, because a caller's rect is very often a live `abs` about
|
|
480
|
+
// to be laid out.
|
|
481
|
+
if (this.dirtyRects.length >= MAX_DIRTY_RECTS) {
|
|
482
|
+
this.dirtyAll = true;
|
|
483
|
+
} else {
|
|
484
|
+
this.dirtyRects.push({
|
|
485
|
+
x: damage.x,
|
|
486
|
+
y: damage.y,
|
|
487
|
+
width: damage.width,
|
|
488
|
+
height: damage.height,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
405
491
|
}
|
|
406
492
|
}
|
|
407
493
|
|
|
494
|
+
/** The claims a frame consumes, cleared for the next one. */
|
|
495
|
+
_takeClaims() {
|
|
496
|
+
const claims = {
|
|
497
|
+
all: this.dirtyAll,
|
|
498
|
+
nodes: this.dirty,
|
|
499
|
+
rects: this.dirtyAll ? [] : this.dirtyRects,
|
|
500
|
+
};
|
|
501
|
+
this.dirtyAll = false;
|
|
502
|
+
this.dirty = new Set();
|
|
503
|
+
this.dirtyRects = [];
|
|
504
|
+
return claims;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* What this frame repaints of the raster covering `rect` for `node`: null
|
|
509
|
+
* for nothing, `FULL_PASS` for all of it, otherwise the window-space rects
|
|
510
|
+
* the frame's bare claims cover inside it, shaped exactly as the X11 path
|
|
511
|
+
* shapes its damage list: whole pixels, disjoint, at most a few of them,
|
|
512
|
+
* and one box instead of several when the several fill most of it —
|
|
513
|
+
* because a node painted in two overlapping passes blends translucent ink
|
|
514
|
+
* over itself, and because each pass is a full replay of the node's paint
|
|
515
|
+
* that only its own culling makes cheap. A pan's shifted region and the
|
|
516
|
+
* two strips beside it come out as the one pass they are.
|
|
517
|
+
*/
|
|
518
|
+
_rasterPasses(node, rect, sizeChanged) {
|
|
519
|
+
const claims = this._claims;
|
|
520
|
+
if (sizeChanged || !claims || claims.all || claims.nodes.has(node)) {
|
|
521
|
+
return FULL_PASS;
|
|
522
|
+
}
|
|
523
|
+
let passes = null;
|
|
524
|
+
for (const claimed of claims.rects) {
|
|
525
|
+
const hit = intersectRects(wholePixels(claimed), rect);
|
|
526
|
+
if (hit) passes = addDamageRect(passes, hit);
|
|
527
|
+
}
|
|
528
|
+
return passes && damageToPaint(passes);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Does this frame re-raster the visual covering `rect` for `node`? */
|
|
532
|
+
_needsRaster(node, rect) {
|
|
533
|
+
return this._rasterPasses(node, rect, false) !== null;
|
|
534
|
+
}
|
|
535
|
+
|
|
408
536
|
/** The whole frame: one walk, one transaction, property diffs only. */
|
|
409
537
|
frame(windowNode) {
|
|
410
538
|
const native = this.native;
|
|
539
|
+
const claims = (this._claims = this._takeClaims());
|
|
540
|
+
let presented = false;
|
|
411
541
|
native.txBegin({ disableActions: true });
|
|
412
542
|
try {
|
|
413
543
|
this._syncWindowBackground(windowNode);
|
|
@@ -417,21 +547,42 @@ export class CocoaLayerPresenter {
|
|
|
417
547
|
if (!seen.has(node)) {
|
|
418
548
|
visual.destroy();
|
|
419
549
|
this.visuals.delete(node);
|
|
420
|
-
this.
|
|
550
|
+
this._dropRaster(node);
|
|
421
551
|
const bars = this.bars.get(node);
|
|
422
552
|
if (bars) {
|
|
423
553
|
for (const entry of bars.values()) {
|
|
424
554
|
native.removeFromSuperlayer(entry.layer);
|
|
555
|
+
entry.raster.release(native);
|
|
425
556
|
}
|
|
426
557
|
this.bars.delete(node);
|
|
427
558
|
}
|
|
428
559
|
}
|
|
429
560
|
}
|
|
561
|
+
presented = true;
|
|
430
562
|
} finally {
|
|
431
563
|
native.txCommit();
|
|
564
|
+
this._claims = null;
|
|
565
|
+
// a frame that threw half-way presents what it got to; the claims it
|
|
566
|
+
// was answering are still owed, so the next frame answers them again
|
|
567
|
+
if (!presented) this._restoreClaims(claims);
|
|
432
568
|
}
|
|
433
|
-
|
|
434
|
-
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
_restoreClaims(claims) {
|
|
572
|
+
this.dirtyAll ||= claims.all;
|
|
573
|
+
for (const node of claims.nodes) this.dirty.add(node);
|
|
574
|
+
if (this.dirtyRects.length + claims.rects.length > MAX_DIRTY_RECTS) {
|
|
575
|
+
this.dirtyAll = true;
|
|
576
|
+
} else {
|
|
577
|
+
this.dirtyRects.push(...claims.rects);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
_dropRaster(node) {
|
|
582
|
+
const raster = this.rasters.get(node);
|
|
583
|
+
if (!raster) return;
|
|
584
|
+
raster.release(this.native);
|
|
585
|
+
this.rasters.delete(node);
|
|
435
586
|
}
|
|
436
587
|
|
|
437
588
|
_syncWindowBackground(windowNode) {
|
|
@@ -460,7 +611,7 @@ export class CocoaLayerPresenter {
|
|
|
460
611
|
let visual = this.visuals.get(node);
|
|
461
612
|
if (visual && visual.isRaster !== wantsRaster) {
|
|
462
613
|
visual.destroy();
|
|
463
|
-
this.
|
|
614
|
+
this._dropRaster(node);
|
|
464
615
|
visual = null;
|
|
465
616
|
}
|
|
466
617
|
if (!visual) {
|
|
@@ -590,9 +741,8 @@ export class CocoaLayerPresenter {
|
|
|
590
741
|
const s = this.scale;
|
|
591
742
|
if (
|
|
592
743
|
!sizeChanged &&
|
|
593
|
-
|
|
594
|
-
!this.
|
|
595
|
-
visual.shapeSignature
|
|
744
|
+
visual.shapeSignature &&
|
|
745
|
+
!this._needsRaster(node, rect)
|
|
596
746
|
) {
|
|
597
747
|
return true; // shapes are current
|
|
598
748
|
}
|
|
@@ -738,13 +888,38 @@ export class CocoaLayerPresenter {
|
|
|
738
888
|
}
|
|
739
889
|
this._dropSvgShapes(visual);
|
|
740
890
|
}
|
|
741
|
-
|
|
891
|
+
const passes = this._rasterPasses(node, rect, sizeChanged);
|
|
892
|
+
if (!passes) return;
|
|
742
893
|
const ctx = raster.ensure(this, rect.width, rect.height, this.window.scale);
|
|
894
|
+
// The bitmap is this visual's composition cache, the way the window's
|
|
895
|
+
// backing store is the surface presenter's: a pass over part of it
|
|
896
|
+
// clears and clips to that part and leaves the rest as last frame drew
|
|
897
|
+
// it, and `paintDamage()` names the pass, so an element culls a drag
|
|
898
|
+
// step or an animation tick exactly as it does on X11 (docs/extending.md,
|
|
899
|
+
// "Drawing a scene into one node") instead of replaying its whole scene
|
|
900
|
+
// into a clip that throws almost all of it away. The full pass is the
|
|
901
|
+
// same loop with nothing to clip.
|
|
902
|
+
const root = node.root;
|
|
743
903
|
ctx.save();
|
|
744
904
|
try {
|
|
745
|
-
ctx.clearRect(0, 0, rect.width, rect.height);
|
|
746
905
|
ctx.translate(-rect.x, -rect.y);
|
|
747
|
-
|
|
906
|
+
for (const pass of passes) {
|
|
907
|
+
const area = pass ?? rect;
|
|
908
|
+
ctx.save();
|
|
909
|
+
try {
|
|
910
|
+
if (pass) {
|
|
911
|
+
ctx.beginPath();
|
|
912
|
+
ctx.rect(area.x, area.y, area.width, area.height);
|
|
913
|
+
ctx.clip();
|
|
914
|
+
}
|
|
915
|
+
ctx.clearRect(area.x, area.y, area.width, area.height);
|
|
916
|
+
if (root) root._paintDamage = pass;
|
|
917
|
+
paintSelf(node, ctx);
|
|
918
|
+
} finally {
|
|
919
|
+
if (root) root._paintDamage = null;
|
|
920
|
+
ctx.restore();
|
|
921
|
+
}
|
|
922
|
+
}
|
|
748
923
|
} finally {
|
|
749
924
|
ctx.restore();
|
|
750
925
|
}
|
package/src/cocoa/surface.js
CHANGED
|
@@ -33,9 +33,11 @@
|
|
|
33
33
|
// composite differently.
|
|
34
34
|
// - **No Picture.** `picture()` is X's compositing handle; here a surface
|
|
35
35
|
// composites through `ctx.drawImage`, and asking for the picture says so.
|
|
36
|
-
// - **Freed on
|
|
37
|
-
//
|
|
38
|
-
//
|
|
36
|
+
// - **Freed on `destroy()`.** The bridge's `releaseSurface` (0.4) frees the
|
|
37
|
+
// bitmap on the call and hands its bytes back to V8's account; the
|
|
38
|
+
// handle's finalizer stays as the safety net for a surface that is
|
|
39
|
+
// dropped without one. Under an older bridge the memory goes with the
|
|
40
|
+
// next GC, as it always did.
|
|
39
41
|
//
|
|
40
42
|
// Units are device pixels, like the window's backing store: a caller sizes
|
|
41
43
|
// one from `contentBox()` numbers, which are device pixels already
|
|
@@ -217,8 +219,12 @@ export class CocoaSurface {
|
|
|
217
219
|
destroy() {
|
|
218
220
|
if (this._destroyed) return;
|
|
219
221
|
this._destroyed = true;
|
|
222
|
+
const handle = this._surfaceHandle;
|
|
220
223
|
this._surfaceHandle = null;
|
|
221
224
|
this._ctx = null;
|
|
225
|
+
if (typeof this._native.releaseSurface === 'function') {
|
|
226
|
+
this._native.releaseSurface(handle);
|
|
227
|
+
}
|
|
222
228
|
}
|
|
223
229
|
|
|
224
230
|
[Symbol.dispose]() {
|
package/src/cocoa/window.js
CHANGED
|
@@ -26,6 +26,12 @@ export class CocoaWindow {
|
|
|
26
26
|
this._surfaceGen = 0;
|
|
27
27
|
this._ctx = null;
|
|
28
28
|
this._dirty = false;
|
|
29
|
+
// AppKit's occlusion state, as the delegate reports it (`_visible`).
|
|
30
|
+
this._occluded = false;
|
|
31
|
+
// This window's frame clock: when it last painted, and how often it may
|
|
32
|
+
// — the period of the display it is on (`_refreshFrameInterval`).
|
|
33
|
+
this._rafLast = 0;
|
|
34
|
+
this._frameInterval = 0;
|
|
29
35
|
|
|
30
36
|
const s = this.scale;
|
|
31
37
|
// Snapped to whole POINTS: AppKit rounds window sizes to the point
|
|
@@ -73,8 +79,18 @@ export class CocoaWindow {
|
|
|
73
79
|
this.windowNumber = this._native.windowNumber(this._h);
|
|
74
80
|
this._layer = this._native.windowRootLayer(this._h);
|
|
75
81
|
this._refreshOrigin();
|
|
82
|
+
this._refreshFrameInterval();
|
|
76
83
|
if (attributes.sizeHints) this.setSizeHints(attributes.sizeHints);
|
|
77
84
|
|
|
85
|
+
// How many damage rects a frame may keep before merging them (nodes.js,
|
|
86
|
+
// MAX_DAMAGE_RECTS is the X11 answer). A pass here costs one CoreGraphics
|
|
87
|
+
// clip and a culled walk, where an X pass costs the server a clip mask,
|
|
88
|
+
// so a frame in which a clock, a graph and a status row all ticked keeps
|
|
89
|
+
// the three small rects instead of the box around them — which on a
|
|
90
|
+
// large tree was most of the window, painted for three cells' worth of
|
|
91
|
+
// change.
|
|
92
|
+
this.damageRectCap = 16;
|
|
93
|
+
|
|
78
94
|
// The retained layer presenter (docs/macos.md Tier L), behind
|
|
79
95
|
// REACT_X11_COCOA_PRESENTER=layers while the surface path is the
|
|
80
96
|
// measured default. Its two hooks exist only in this mode, so the
|
|
@@ -115,6 +131,18 @@ export class CocoaWindow {
|
|
|
115
131
|
this._screenOrigin = { x: this.x, y: this.y };
|
|
116
132
|
}
|
|
117
133
|
|
|
134
|
+
/**
|
|
135
|
+
* How often this window may paint: the period of the display it is on,
|
|
136
|
+
* asked of the app (`frameIntervalFor`), which reads the screen list the
|
|
137
|
+
* bridge reported. Re-read whenever the window moves, because a drag
|
|
138
|
+
* from a 120Hz panel to a 60Hz monitor halves the rate it is worth
|
|
139
|
+
* painting at — and a window that straddles two answers for the one
|
|
140
|
+
* under its centre.
|
|
141
|
+
*/
|
|
142
|
+
_refreshFrameInterval() {
|
|
143
|
+
this._frameInterval = this.app.frameIntervalFor(this);
|
|
144
|
+
}
|
|
145
|
+
|
|
118
146
|
/** Native geometry changed (delegate event, points). */
|
|
119
147
|
_nativeResized(points) {
|
|
120
148
|
const s = this.scale;
|
|
@@ -123,6 +151,13 @@ export class CocoaWindow {
|
|
|
123
151
|
this.x = Math.round(points.x * s);
|
|
124
152
|
this.y = Math.round(points.y * s);
|
|
125
153
|
this._screenOrigin = { x: this.x, y: this.y };
|
|
154
|
+
this._refreshFrameInterval();
|
|
155
|
+
// AppKit's inLiveResize, as the delegate reported it: the renderer
|
|
156
|
+
// answers a live tick with the layout floors it has and measures fresh
|
|
157
|
+
// ones after the drag (nodes.js, `_deferContentFloors`). Cleared by
|
|
158
|
+
// the pump (`_endLiveResizes`), because the pump cannot run while the
|
|
159
|
+
// resize loop owns the thread — a tick of it is the drag being over.
|
|
160
|
+
if (points.live === true) this.liveResizing = true;
|
|
126
161
|
}
|
|
127
162
|
|
|
128
163
|
resize(width, height) {
|
|
@@ -144,6 +179,7 @@ export class CocoaWindow {
|
|
|
144
179
|
this.y = Math.round(y);
|
|
145
180
|
this._native.setWindowFrame(this._h, x / s, y / s, null, null);
|
|
146
181
|
this._screenOrigin = { x: this.x, y: this.y };
|
|
182
|
+
this._refreshFrameInterval();
|
|
147
183
|
}
|
|
148
184
|
|
|
149
185
|
// --- lifecycle -----------------------------------------------------------
|
|
@@ -151,10 +187,17 @@ export class CocoaWindow {
|
|
|
151
187
|
map() {
|
|
152
188
|
if (this.destroyed) return;
|
|
153
189
|
this.mapped = true;
|
|
190
|
+
// Showing is a claim that the window is on glass; if it comes up behind
|
|
191
|
+
// another application's window, AppKit's occlusion event says so on the
|
|
192
|
+
// next pump and the frames wait from then. Reset here rather than kept,
|
|
193
|
+
// so a window that was hidden behind one, unmapped and mapped again
|
|
194
|
+
// does not wait on an event that may already have been delivered.
|
|
195
|
+
this._occluded = false;
|
|
154
196
|
// A popup must not take the keyboard from its owner; a toplevel's first
|
|
155
197
|
// map is the app coming up and takes it.
|
|
156
198
|
this._native.showWindow(this._h, !this._popup);
|
|
157
199
|
this._refreshOrigin();
|
|
200
|
+
this._refreshFrameInterval();
|
|
158
201
|
}
|
|
159
202
|
|
|
160
203
|
unmap() {
|
|
@@ -169,7 +212,7 @@ export class CocoaWindow {
|
|
|
169
212
|
this.mapped = false;
|
|
170
213
|
this.app._unregisterWindow(this);
|
|
171
214
|
this._native.destroyWindow2(this._h);
|
|
172
|
-
this.
|
|
215
|
+
this._releaseBacking();
|
|
173
216
|
}
|
|
174
217
|
|
|
175
218
|
// --- window-manager-ish surface (feature-detected by nodes.js) -----------
|
|
@@ -232,6 +275,14 @@ export class CocoaWindow {
|
|
|
232
275
|
* the just-shown frame's damage across — a damage-sized memcpy replacing
|
|
233
276
|
* a window-sized upload. Falls back to the single plain surface where
|
|
234
277
|
* IOSurface creation fails.
|
|
278
|
+
*
|
|
279
|
+
* A new size retires the pair, and the retired pair is released on the
|
|
280
|
+
* spot (`_releaseBacking`): a resize tick allocates two window-sized
|
|
281
|
+
* IOSurfaces, 20MB at 900x700@2x, and left to the handles' finalizers a
|
|
282
|
+
* forty-tick drag held 800MB until a collection happened to run — the
|
|
283
|
+
* `rss +80MB` docs/macos.md measured. The layer keeps its own reference
|
|
284
|
+
* to whichever IOSurface it is still showing, so the free is safe while
|
|
285
|
+
* that frame is on glass.
|
|
235
286
|
*/
|
|
236
287
|
_ensureSurface() {
|
|
237
288
|
const w = this.width;
|
|
@@ -242,7 +293,7 @@ export class CocoaWindow {
|
|
|
242
293
|
this._surfaceSize?.height !== h
|
|
243
294
|
) {
|
|
244
295
|
const hadSurface = Boolean(this._surface);
|
|
245
|
-
this.
|
|
296
|
+
this._releaseBacking();
|
|
246
297
|
try {
|
|
247
298
|
const a = this._native.createSurfaceIOSurface(w, h, this.scale);
|
|
248
299
|
const b = this._native.createSurfaceIOSurface(w, h, this.scale);
|
|
@@ -258,20 +309,41 @@ export class CocoaWindow {
|
|
|
258
309
|
this._surfaceSize = { width: w, height: h };
|
|
259
310
|
this._surfaceGen++;
|
|
260
311
|
this._flushDamage = 'full';
|
|
261
|
-
// A replaced backing surface holds nothing
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
312
|
+
// A replaced backing surface holds nothing but what the flush now
|
|
313
|
+
// painting puts on it. Whether that is enough is decided when the
|
|
314
|
+
// flush reports its rects (`noteFrameDamage`) — not here, and not by
|
|
315
|
+
// queueing a full frame behind this one. That used to be the answer,
|
|
316
|
+
// and it made every tick of a live resize two full frames: the resize
|
|
317
|
+
// event's own unbounded repaint, then this one, painting the same
|
|
318
|
+
// pixels again. Worse, inside AppKit's resize loop no microtask runs
|
|
319
|
+
// until the drag ends, so a drag of forty ticks queued forty full
|
|
320
|
+
// frames that all ran on the mouse release — the freeze after a
|
|
321
|
+
// resize, measured at seconds on a large tree.
|
|
322
|
+
if (hadSurface) this._freshSurface = true;
|
|
271
323
|
}
|
|
272
324
|
return this._surface;
|
|
273
325
|
}
|
|
274
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Free the backing store now — the swapchain pair, or the plain surface
|
|
329
|
+
* the fallback holds — rather than when V8 collects the handles. Bridges
|
|
330
|
+
* before 0.4 have no `releaseSurface`; there the finalizer is still the
|
|
331
|
+
* only owner, and this is the drop it always was.
|
|
332
|
+
*/
|
|
333
|
+
_releaseBacking() {
|
|
334
|
+
const release = this._native.releaseSurface;
|
|
335
|
+
if (typeof release === 'function') {
|
|
336
|
+
if (this._chain) {
|
|
337
|
+
release.call(this._native, this._chain.back.handle);
|
|
338
|
+
release.call(this._native, this._chain.front.handle);
|
|
339
|
+
} else if (this._surface) {
|
|
340
|
+
release.call(this._native, this._surface);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
this._chain = null;
|
|
344
|
+
this._surface = null;
|
|
345
|
+
}
|
|
346
|
+
|
|
275
347
|
/**
|
|
276
348
|
* The per-flush painted rects (nodes.js's swapchain seam), accumulated
|
|
277
349
|
* until the next present: they are what the flip's catch-up copy covers.
|
|
@@ -279,6 +351,22 @@ export class CocoaWindow {
|
|
|
279
351
|
*/
|
|
280
352
|
noteFrameDamage(rects) {
|
|
281
353
|
if (this._presenter) return;
|
|
354
|
+
if (this._freshSurface) {
|
|
355
|
+
this._freshSurface = false;
|
|
356
|
+
// A full flush painted every pixel of the new surface, and a resize
|
|
357
|
+
// event's flush is one (nodes.js, the 'resize' listener). A bounded
|
|
358
|
+
// one left garbage outside its rects: hold the present until the full
|
|
359
|
+
// frame asked for here lands, so the garbage is never on glass.
|
|
360
|
+
if (rects) {
|
|
361
|
+
this._holdPresent = true;
|
|
362
|
+
const node = this._reactX11Node;
|
|
363
|
+
if (node && !node.destroyed) node.invalidate(false, null, 'resize');
|
|
364
|
+
} else {
|
|
365
|
+
this._holdPresent = false;
|
|
366
|
+
}
|
|
367
|
+
} else if (!rects) {
|
|
368
|
+
this._holdPresent = false;
|
|
369
|
+
}
|
|
282
370
|
if (this._flushDamage === 'full') return;
|
|
283
371
|
if (!rects) {
|
|
284
372
|
this._flushDamage = 'full';
|
|
@@ -287,6 +375,25 @@ export class CocoaWindow {
|
|
|
287
375
|
(this._flushDamage ??= []).push(...rects);
|
|
288
376
|
}
|
|
289
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Whether anyone can see this window: mapped by the renderer, and not
|
|
380
|
+
* ordered out or miniaturized by the user. A window that fails this owes
|
|
381
|
+
* no frames — its callbacks wait in the app's queue (`_tickFrames`) and
|
|
382
|
+
* its last paint stays unpresented until it is back on glass, where one
|
|
383
|
+
* catch-up frame covers everything that changed in between.
|
|
384
|
+
*
|
|
385
|
+
* Occlusion by another application's window counts too: a window that
|
|
386
|
+
* is entirely behind one is visible by `isVisible`'s measure and still
|
|
387
|
+
* costs every frame its tree produces, and AppKit knows the difference.
|
|
388
|
+
* `windowDidChangeOcclusionState` arrives as the bridge's
|
|
389
|
+
* `window-occlusion` event (`CocoaApp._routeOcclusion`), `visible` being
|
|
390
|
+
* "some pixel of it is on glass"; `_occluded` is the last word of it.
|
|
391
|
+
*/
|
|
392
|
+
_visible() {
|
|
393
|
+
if (this.destroyed || !this.mapped || this._occluded) return false;
|
|
394
|
+
return this._native.windowIsVisible(this._h) !== false;
|
|
395
|
+
}
|
|
396
|
+
|
|
290
397
|
getContext() {
|
|
291
398
|
if (!this._ctx) {
|
|
292
399
|
this._ctx = new CocoaContext2D(
|
|
@@ -345,13 +452,16 @@ export class CocoaWindow {
|
|
|
345
452
|
}
|
|
346
453
|
|
|
347
454
|
requestAnimationFrame(cb) {
|
|
348
|
-
return this.app._requestFrame(cb);
|
|
455
|
+
return this.app._requestFrame(cb, this);
|
|
349
456
|
}
|
|
350
457
|
|
|
351
458
|
/** Push the backing surface at the WindowServer, if anything drew. */
|
|
352
459
|
present() {
|
|
353
460
|
if (this._presenter) return; // layers upload as they sync
|
|
354
461
|
if (!this._dirty || !this._surface || this.destroyed) return;
|
|
462
|
+
// …and if anyone would see it. `_dirty` stays set, so the pump asks
|
|
463
|
+
// again next tick and the frame goes out the moment the window is back.
|
|
464
|
+
if (this._holdPresent || !this._visible()) return;
|
|
355
465
|
this._dirty = false;
|
|
356
466
|
if (this._chain) {
|
|
357
467
|
const shown = this._chain.back;
|
package/src/events.js
CHANGED
|
@@ -78,7 +78,16 @@ class SyntheticEvent {
|
|
|
78
78
|
// by, and `localX` below subtracts an `abs` divided the same way
|
|
79
79
|
// (src/scale.js). Everything *internal* — hit testing, drag
|
|
80
80
|
// thresholds, the scroll accumulator — keeps reading `native`.
|
|
81
|
-
|
|
81
|
+
//
|
|
82
|
+
// **The target's** unit, not the window's, so that a subtree zoomed by
|
|
83
|
+
// a `scale` prop reads its own: `ev.x` then compares with the target's
|
|
84
|
+
// `getClientRects()`, which divides by the same factor, and `localX`
|
|
85
|
+
// lands on the styles that node was written with. This is CSS `zoom`'s
|
|
86
|
+
// trade-off rather than a transform's, and the corner it costs is
|
|
87
|
+
// named in docs/scale.md — a handler on an *unzoomed* ancestor reads a
|
|
88
|
+
// coordinate in the zoomed descendant's unit, because the target is
|
|
89
|
+
// what decides. `nativeEvent` is the way back to the window's pixels.
|
|
90
|
+
const s = target?.scale ?? manager.scale;
|
|
82
91
|
this._manager = manager;
|
|
83
92
|
this._targetNode = target;
|
|
84
93
|
this.type = type;
|
|
@@ -802,8 +811,14 @@ export class EventManager {
|
|
|
802
811
|
// `ev.deltaX/Y` are logical (what handlers read); the scroll they
|
|
803
812
|
// become moves device pixels, and truncating *after* the multiply is
|
|
804
813
|
// what keeps the blit on whole device pixels at fractional scales.
|
|
805
|
-
|
|
806
|
-
|
|
814
|
+
// The target's scale, the same unit the event's coordinates are in
|
|
815
|
+
// (`SyntheticEvent`), so a notch over a subtree zoomed by a `scale`
|
|
816
|
+
// prop moves a notch of *its* pixels — the content under the pointer
|
|
817
|
+
// travels the distance the zoom says, which is what CSS `zoom` does
|
|
818
|
+
// and what a pane whose rows are twice the size needs.
|
|
819
|
+
const wheelScale = target.scale;
|
|
820
|
+
const owedX = this._wheelOwed.x + ev.deltaX * wheelScale;
|
|
821
|
+
const owedY = this._wheelOwed.y + ev.deltaY * wheelScale;
|
|
807
822
|
const dx = Math.trunc(owedX);
|
|
808
823
|
const dy = Math.trunc(owedY);
|
|
809
824
|
this._wheelOwed = { x: owedX - dx, y: owedY - dy };
|
|
@@ -822,9 +837,12 @@ export class EventManager {
|
|
|
822
837
|
for (let n = target; n; n = n.parent) {
|
|
823
838
|
if (n.canScroll?.(dx, dy)) {
|
|
824
839
|
// built-in scrollers take the device delta whole; a registered
|
|
825
|
-
// element's own scrollBy speaks the public (logical) unit
|
|
840
|
+
// element's own scrollBy speaks the public (logical) unit — and
|
|
841
|
+
// *that node's* logical unit, since `scrollBy` multiplies by its
|
|
842
|
+
// own scale on the way back in, which is the only division that
|
|
843
|
+
// round-trips exactly across a scale boundary
|
|
826
844
|
if (n._scrollByDevice) n._scrollByDevice(dx, dy);
|
|
827
|
-
else n.scrollBy({ x: dx /
|
|
845
|
+
else n.scrollBy({ x: dx / n.scale, y: dy / n.scale });
|
|
828
846
|
break;
|
|
829
847
|
}
|
|
830
848
|
if (n === this.node) break;
|
package/src/index.d.ts
CHANGED
|
@@ -180,6 +180,31 @@ export interface RootOptions {
|
|
|
180
180
|
* is a backend.
|
|
181
181
|
*/
|
|
182
182
|
backend?: 'auto' | 'x11' | 'cocoa';
|
|
183
|
+
/**
|
|
184
|
+
* The Cocoa backend's knobs (docs/macos.md). `presenter` picks the frame
|
|
185
|
+
* path: `'surface'` (the measured default — one bitmap per window, the
|
|
186
|
+
* X11 paint machinery over an IOSurface swapchain) or `'layers'` (one
|
|
187
|
+
* CALayer per drawn node, opt-in while it is measured).
|
|
188
|
+
* `frameInterval` is how often a scheduled frame may paint, in ms. By
|
|
189
|
+
* default each window paces itself on the display it is on — 8.3ms on
|
|
190
|
+
* a 120Hz panel, 16.7 on a 60Hz monitor, the screen's own refresh rate
|
|
191
|
+
* as the bridge reports it, 16 where the OS cannot say. A number here
|
|
192
|
+
* applies to every window instead. `pumpInterval` is the AppKit event
|
|
193
|
+
* pump's cadence, in ms (8 by default), which is the floor under input
|
|
194
|
+
* latency. Ignored off macOS and when {@link RootOptions.app} is passed.
|
|
195
|
+
*/
|
|
196
|
+
cocoa?: {
|
|
197
|
+
presenter?: 'surface' | 'layers';
|
|
198
|
+
frameInterval?: number;
|
|
199
|
+
pumpInterval?: number;
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* The size, in logical pixels, under which a `<text>` is painted as a
|
|
203
|
+
* strip of its ink where its lines are instead of as glyphs — a
|
|
204
|
+
* zoomed-out view's labels, which nobody can read and which cost a glyph
|
|
205
|
+
* run each. 6 by default; 0 paints glyphs at every size.
|
|
206
|
+
*/
|
|
207
|
+
textStripBelow?: number;
|
|
183
208
|
/** `':1'`, `'host:0.0'`, or a unix socket path. Defaults to `$DISPLAY`. */
|
|
184
209
|
display?: string;
|
|
185
210
|
/**
|