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.
@@ -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, 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.
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
- /** Paint everything a node draws itself — Node.paint minus the children. */
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
- this.dirty = new Set();
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.rasters.delete(node);
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
- this.dirty.clear();
434
- this.dirtyAll = false;
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.rasters.delete(node);
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
- !this.dirtyAll &&
594
- !this.dirty.has(node) &&
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
- if (!this.dirtyAll && !this.dirty.has(node) && !sizeChanged) return;
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
- paintSelf(node, ctx);
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
  }
@@ -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 collection.** The bridge frees the bitmap from the handle's
37
- // finalizer; `destroy()` drops the handle and refuses further use, so the
38
- // memory goes with the next GC rather than on the call.
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]() {
@@ -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._surface = null;
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._chain = null;
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: whatever bounded damage
262
- // this frame carries, everything else on it would be garbage. Ask for
263
- // the full frame one extra repaint per real resize, correctness for
264
- // every pixel outside the damage rect.
265
- if (hadSurface) {
266
- queueMicrotask(() => {
267
- const node = this._reactX11Node;
268
- if (node && !node.destroyed) node.invalidate(true, null, 'resize');
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
- const s = manager.scale;
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
- const owedX = this._wheelOwed.x + ev.deltaX * this.scale;
806
- const owedY = this._wheelOwed.y + ev.deltaY * this.scale;
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 / this.scale, y: dy / this.scale });
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
  /**