react-x11 2.4.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.
@@ -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
  /**