react-x11 2.39.0 → 2.41.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.
@@ -12,9 +12,25 @@
12
12
  // And the players its `<video src>`s ask for, which a process with no
13
13
  // AppKit would never hear from: each is a player here, its events and its
14
14
  // frames sent back (`HostedPlayer`, src/cocoa/paneplayer.js).
15
+ import { now } from '../nodes/animation.js';
15
16
  import { HostedPlayer } from './paneplayer.js';
16
17
  import { withoutActions } from './quiet.js';
17
18
 
19
+ /** The live-resize handshake's budget when the app names none
20
+ * (`RESIZE_WAIT_MS`, src/cocoa/app.js). */
21
+ const RESIZE_WAIT_MS = 50;
22
+
23
+ /** How many sizes asked of the pane are remembered until a frame of one
24
+ * answers them (`_asked`): a drag asks one a frame. */
25
+ const ASKED_KEPT = 16;
26
+
27
+ /** How long after the last size asked a new one starts a gesture of its
28
+ * own, which finds the pane keeping up until it shows otherwise: a pane
29
+ * whose layout at a width it was not dragged to took long — a long page
30
+ * laid out whole, as the first step of a drag is — keeps up with the
31
+ * drag itself. */
32
+ const GESTURE_GAP_MS = 500;
33
+
18
34
  /** One shared surface on glass: a sublayer of a window's root layer, its
19
35
  * contents the IOSurface the pane last presented into it. */
20
36
  class PaneLayer {
@@ -29,6 +45,82 @@ class PaneLayer {
29
45
  });
30
46
  this.destroyed = false;
31
47
  this._rect = null;
48
+ // How the frame the layer holds sits in it while one of the size the
49
+ // layer was just given is on its way (`_gravityNow`): the gravity set,
50
+ // Core Animation's own until one is, the sizes asked of the pane with
51
+ // when each was asked, and whether the pane has fallen behind them.
52
+ this._gravity = 'resize';
53
+ this._contentsScale = null;
54
+ this._asked = [];
55
+ this._askedAt = null;
56
+ this._behind = false;
57
+ }
58
+
59
+ /**
60
+ * The gravity a pane's last frame sits in its layer by, until a frame of
61
+ * the layer's new size lands. The host gives the layer its new size the
62
+ * moment the window lays out, and the pane's frame of that size comes a
63
+ * pane frame later: Core Animation's default, `resize`, stretches the
64
+ * frame it has to the new bounds meanwhile, and a page's left column was
65
+ * drawn scaled a little at every step of a window dragged wider, and then
66
+ * at its size again. A pane that keeps up has its frame anchored at the
67
+ * top left instead, at its size, what it does not cover yet showing the
68
+ * window under it for a pane frame; one that falls behind the budget is
69
+ * stretched, which is the better picture for the long wait — a pane
70
+ * repainting at three frames a second shows a squeezed page, not a page
71
+ * and a growing strip. The budget is the window's live-resize handshake's
72
+ * (`createRoot({ cocoa: { resizeWait } })`), how long a frame of a new
73
+ * size may take before the old one is shown in its place: 0 stretches
74
+ * always, as before. A bridge that predates `contentsGravity` ignores it,
75
+ * and stretches.
76
+ */
77
+ _gravityNow() {
78
+ const budget = this.app._resizeWait ?? RESIZE_WAIT_MS;
79
+ if (!(budget > 0)) return 'resize';
80
+ // a size asked longer ago than the budget, and no frame of it yet
81
+ const oldest = this._asked[0];
82
+ if (oldest && now() - oldest.at > budget) this._behind = true;
83
+ return this._behind ? 'resize' : 'topLeft';
84
+ }
85
+
86
+ /** The props that set the gravity, where it changed. */
87
+ _gravityProps() {
88
+ const gravity = this._gravityNow();
89
+ if (gravity === this._gravity) return null;
90
+ this._gravity = gravity;
91
+ // a frame anchored and larger than the layer would hang out of it
92
+ return { contentsGravity: gravity, masksToBounds: gravity !== 'resize' };
93
+ }
94
+
95
+ /**
96
+ * The props that say how many of the pane's pixels make a point of the
97
+ * layer, where that changed: the window's scale, which the pane draws
98
+ * at. A frame stretched to the bounds never asked, and one anchored at
99
+ * its size is shown at it — pixels over `contentsScale` points, so at
100
+ * the default of 1 a page at 2x came out twice its size.
101
+ */
102
+ _scaleProps() {
103
+ const scale = this.wnd.scale ?? 1;
104
+ if (scale === this._contentsScale) return null;
105
+ this._contentsScale = scale;
106
+ return { contentsScale: scale };
107
+ }
108
+
109
+ /** A frame of `width` by `height` device px landed: what it answers of
110
+ * the sizes asked, and how long it took. */
111
+ _answered(width, height) {
112
+ const asked = this._asked;
113
+ // the pane rounds the logical size it was sent back to device pixels
114
+ const slack = Math.max(2, this.wnd.scale ?? 1);
115
+ const at = asked.findIndex(
116
+ (a) =>
117
+ Math.abs(a.width - width) <= slack &&
118
+ Math.abs(a.height - height) <= slack,
119
+ );
120
+ if (at < 0) return;
121
+ this._behind =
122
+ now() - asked[at].at > (this.app._resizeWait ?? RESIZE_WAIT_MS);
123
+ asked.splice(0, at + 1);
32
124
  }
33
125
 
34
126
  /** Geometry in device px, the node's abs — points at the layer. */
@@ -46,6 +138,17 @@ class PaneLayer {
46
138
  return;
47
139
  }
48
140
  this._rect = { ...rect };
141
+ // a size the pane is asked for, answered by the first frame of it
142
+ if (prev && (prev.width !== rect.width || prev.height !== rect.height)) {
143
+ const at = now();
144
+ if (at - (this._askedAt ?? -Infinity) >= GESTURE_GAP_MS) {
145
+ this._asked = [];
146
+ this._behind = false;
147
+ }
148
+ this._askedAt = at;
149
+ if (this._asked.length === ASKED_KEPT) this._asked.shift();
150
+ this._asked.push({ width: rect.width, height: rect.height, at });
151
+ }
49
152
  // Actions off: the layer is ours, not a presenter's, so no frame's
50
153
  // transaction covers it, and a bare set tweens the pane into place at
51
154
  // mount and after every resize — the trap CocoaGLArea._setLayerProps
@@ -57,6 +160,8 @@ class PaneLayer {
57
160
  frame: [rect.x / s, rect.y / s, rect.width / s, rect.height / s],
58
161
  zPosition: 1e7,
59
162
  hidden: false,
163
+ ...this._scaleProps(),
164
+ ...this._gravityProps(),
60
165
  });
61
166
  } finally {
62
167
  native.txCommit();
@@ -67,18 +172,22 @@ class PaneLayer {
67
172
  * A pane-present landed: scan out of the named shared surface.
68
173
  *
69
174
  * A present names a buffer the pane may since have retired. The channel
70
- * is a queue and nothing acknowledges a present, so a pane-rect that
71
- * changes the pane's size (or its scale — the host window moved to another
72
- * display during startup, which is how this was first hit) can cross a
73
- * present already in flight: the pane rebuilds its ring on that rect and
74
- * releases the old one (`CocoaPaneWindow._ensureSurface`), and by the
75
- * time the host looks the id up the surface is gone. That present is
76
- * stale by construction — the pane's full frame on the fresh ring is
77
- * queued behind it — so it is dropped, and the layer keeps the frame it
78
- * already holds a reference to. Nothing else here throws; any other
79
- * error is the bug it says it is.
175
+ * is a queue, so a pane-rect that changes the pane's size (or its scale —
176
+ * the host window moved to another display during startup, which is how
177
+ * this was first hit) can cross a present already in flight: the pane
178
+ * rebuilds its ring on that rect (`CocoaPaneWindow._ensureSurface`)
179
+ * before the host has looked the id up. The id is all the present
180
+ * carries, and the system gives a freed id to the next surface made, so
181
+ * the pane keeps such a buffer until the host says it has looked it up
182
+ * (`pane-shown`, which `CocoaPaneHost` sends once it has) — the lookup finds
183
+ * the frame the present named, a size behind, and the full frame on the
184
+ * fresh ring is queued behind it. Where the buffer is gone anyway — a
185
+ * pane that freed it for want of an answer, or one that died with
186
+ * presents in the channel — the present is dropped, and the layer keeps
187
+ * the frame it already holds a reference to. Nothing else here throws;
188
+ * any other error is the bug it says it is.
80
189
  */
81
- present(iosurfaceId) {
190
+ present(iosurfaceId, size = null) {
82
191
  if (this.destroyed) return false;
83
192
  try {
84
193
  this._native.setLayerContentsIOSurface(this.layer, iosurfaceId);
@@ -86,6 +195,17 @@ class PaneLayer {
86
195
  if (!/IOSurfaceLookup/.test(err?.message ?? '')) throw err;
87
196
  return false;
88
197
  }
198
+ // how long the pane took to a frame of a size it was asked for, which
199
+ // is what the gravity follows (`_gravityNow`)
200
+ if (size) {
201
+ this._answered(size.width, size.height);
202
+ const gravity = this._gravityProps();
203
+ if (gravity) {
204
+ withoutActions(this._native, () =>
205
+ this._native.setLayerProps(this.layer, gravity),
206
+ );
207
+ }
208
+ }
89
209
  return true;
90
210
  }
91
211
 
@@ -144,6 +264,28 @@ export class CocoaPaneHost extends PaneLayer {
144
264
  this._players = new Map();
145
265
  }
146
266
 
267
+ /**
268
+ * The pane's own present, scanned out (`PaneLayer.present`), and the pane
269
+ * told: whatever the buffer it named holds is the layer's now, or was
270
+ * dropped, so the pane may free it (`CocoaPaneWindow._releaseRing`).
271
+ */
272
+ present(iosurfaceId, size = null) {
273
+ const shown = super.present(iosurfaceId, size);
274
+ this._acknowledge(size?.seq, null);
275
+ return shown;
276
+ }
277
+
278
+ /** `pane-shown`: the host has looked up the presents to `seq` of the
279
+ * pane's own window, or of the one `window` names. */
280
+ _acknowledge(seq, window) {
281
+ if (this.destroyed || seq == null) return;
282
+ this._send?.({
283
+ type: 'pane-shown',
284
+ seq,
285
+ ...(window != null && { window }),
286
+ });
287
+ }
288
+
147
289
  /**
148
290
  * A `pane-player` message: one of the pane's `<video src>`s made, steered
149
291
  * or let go of its player. A player that cannot be made says so the way
@@ -259,9 +401,15 @@ export class CocoaPaneHost extends PaneLayer {
259
401
  /** A pane-present naming one of the pane's other windows. */
260
402
  presentWindow(msg) {
261
403
  const entry = this._windows.get(msg.window);
262
- if (!entry || this.destroyed) return;
404
+ if (!entry || this.destroyed) {
405
+ this._acknowledge(msg.seq, msg.window);
406
+ return;
407
+ }
263
408
  entry.layer.setRect({ x: 0, y: 0, width: msg.width, height: msg.height });
264
- if (!entry.layer.present(msg.id)) return;
409
+ const shown = entry.layer.present(msg.id);
410
+ // its ring is the pane's own ring, retired the same way
411
+ this._acknowledge(msg.seq, msg.window);
412
+ if (!shown) return;
265
413
  entry.presented = true;
266
414
  // a transparent window's shadow is the shape of what it shows
267
415
  entry.wnd._shadowAfterFlip?.();
@@ -18,6 +18,15 @@
18
18
  // other: the host writes the buffer after the one it last named, so the one
19
19
  // the pane is told about is not written again for two frames — some 66ms at
20
20
  // 30fps, against a pane that copies within a display frame of hearing.
21
+ //
22
+ // A buffer is named by its IOSurface id, and the system gives a freed id to
23
+ // the next surface anyone makes, in either process. So the pane answers
24
+ // each frame once it has looked the buffer up (`seen`), the host keeps a
25
+ // buffer of a ring it retired until the frame naming it is answered
26
+ // (`HostedPlayer._freeRing`), and the pane keeps nothing a lookup found at
27
+ // another size than the frame says (`PanePlayer._lookUp`) — the pane's own
28
+ // presents, the other way round, are kept the same way
29
+ // (`CocoaPaneWindow._releaseRing`).
21
30
 
22
31
  import { PlayerState } from './player.js';
23
32
 
@@ -113,27 +122,13 @@ export class PanePlayer extends PlayerState {
113
122
  }
114
123
 
115
124
  /** The host named a buffer with a new frame in it. */
116
- _frame({ surface, width, height, time }) {
125
+ _frame({ surface, width, height, time, seq }) {
117
126
  if (this.released) return;
118
- // a ring of another size is a new ring: the old one's buffers are the
119
- // host's to free, and these handles only keep them alive
120
- if (
121
- this._bufferSize?.width !== width ||
122
- this._bufferSize?.height !== height
123
- ) {
124
- this._dropBuffers();
125
- this._bufferSize = { width, height };
126
- }
127
- let handle = this._buffers.get(surface);
128
- if (!handle) {
129
- try {
130
- handle = this._native.surfaceFromIOSurfaceID(surface, 1).handle;
131
- } catch {
132
- // freed before it got here: the next frame is in another buffer
133
- return;
134
- }
135
- this._buffers.set(surface, handle);
136
- }
127
+ const handle = this._lookUp(surface, width, height);
128
+ // this frame's lookup is done, whatever it found: the host may free the
129
+ // buffer, which it kept for it (`HostedPlayer._freeRing`)
130
+ if (seq != null) this._post({ op: 'seen', seq });
131
+ if (!handle) return;
137
132
  this._shown = { handle, width, height, time };
138
133
  this._fresh = true;
139
134
  // a playing `<video>` asks every display frame; a paused one asks when
@@ -148,6 +143,40 @@ export class PanePlayer extends PlayerState {
148
143
  }
149
144
  }
150
145
 
146
+ /**
147
+ * The buffer the host named, looked up the first time it is named; null
148
+ * where the lookup found none, or found a surface of another size than
149
+ * the frame, which is not the frame's buffer but whatever the id has gone
150
+ * to since (`HostedPlayer._freeRing` says how) — never kept, and never
151
+ * copied out of.
152
+ */
153
+ _lookUp(surface, width, height) {
154
+ // a ring of another size is a new ring: the old one's buffers are the
155
+ // host's to free, and these handles only keep them alive
156
+ if (
157
+ this._bufferSize?.width !== width ||
158
+ this._bufferSize?.height !== height
159
+ ) {
160
+ this._dropBuffers();
161
+ this._bufferSize = { width, height };
162
+ }
163
+ const known = this._buffers.get(surface);
164
+ if (known) return known;
165
+ let found;
166
+ try {
167
+ found = this._native.surfaceFromIOSurfaceID(surface, 1);
168
+ } catch {
169
+ // freed before it got here: the next frame is in another buffer
170
+ return null;
171
+ }
172
+ if (found.width !== width || found.height !== height) {
173
+ this._native.releaseSurface?.(found.handle);
174
+ return null;
175
+ }
176
+ this._buffers.set(surface, found.handle);
177
+ return found.handle;
178
+ }
179
+
151
180
  /** As `CocoaPlayer.copyFrame`: the newest frame into `surface`, or null
152
181
  * when the host has sent nothing since the last copy. */
153
182
  copyFrame(surface) {
@@ -212,6 +241,12 @@ export class HostedPlayer {
212
241
  this._next = 0;
213
242
  this._want = 0;
214
243
  this._pull = null;
244
+ // Each frame's number, the newest the pane has looked up (`seen`), and
245
+ // the buffers of retired rings a frame it has not looked up yet names:
246
+ // freed as it does (`_freeRing`).
247
+ this._seq = 0;
248
+ this._seenSeq = 0;
249
+ this._retired = [];
215
250
  this._off = this.player._subscribe((ev) => this._event(ev));
216
251
  }
217
252
 
@@ -221,6 +256,7 @@ export class HostedPlayer {
221
256
  if (player.released) return;
222
257
  if (msg.op === 'set') player.set(msg.settings);
223
258
  else if (msg.op === 'seek') player.seek(msg.seconds);
259
+ else if (msg.op === 'seen') this._seen(msg.seq);
224
260
  else if (msg.op === 'release') this.release();
225
261
  }
226
262
 
@@ -274,6 +310,9 @@ export class HostedPlayer {
274
310
  );
275
311
  if (got?.written) {
276
312
  this._next = (this._next + 1) % RING;
313
+ // the buffer is named in the channel until the pane says otherwise
314
+ // (`_freeRing`)
315
+ buffer.namedSeq = ++this._seq;
277
316
  this._send({
278
317
  type: 'pane-player-frame',
279
318
  player: this.id,
@@ -281,6 +320,7 @@ export class HostedPlayer {
281
320
  width: got.width,
282
321
  height: got.height,
283
322
  time: got.time,
323
+ seq: this._seq,
284
324
  });
285
325
  if (!player.playing) this._want = 0;
286
326
  } else if (got?.width > 0 && got?.height > 0) {
@@ -316,13 +356,46 @@ export class HostedPlayer {
316
356
  return this._ring[this._next];
317
357
  }
318
358
 
359
+ /**
360
+ * Let the ring go — all but a buffer a frame names that the pane has not
361
+ * looked up yet. The pane finds a buffer by its IOSurface id, and the
362
+ * system hands a freed id to the next surface anyone makes: the ring of
363
+ * the new size this same call is about to make, the pane's own ring,
364
+ * another process's surface. A frame still in the channel named the id,
365
+ * not the surface, so the pane copied out of whatever had it by the time
366
+ * it looked — the next ring's buffer, unwritten, shown until the next
367
+ * frame came, which for a paused player is the next seek. So that buffer
368
+ * is kept (`_retired`) until the pane says it has looked the frame up
369
+ * (`seen`, `_seen`). One, as a rule, since the pane answers as it reads,
370
+ * and never more than a ring's worth: a pane that stops answering has the
371
+ * oldest freed anyway, the one it is likeliest to have looked up already.
372
+ */
319
373
  _freeRing() {
374
+ const ring = this._ring;
375
+ this._ring = [];
320
376
  const release = this._native.releaseSurface;
321
- if (typeof release === 'function') {
322
- for (const buffer of this._ring)
323
- release.call(this._native, buffer.handle);
377
+ if (typeof release !== 'function') return;
378
+ for (const buffer of ring) {
379
+ if (buffer.namedSeq > this._seenSeq) this._retired.push(buffer);
380
+ else release.call(this._native, buffer.handle);
324
381
  }
325
- this._ring = [];
382
+ while (this._retired.length > RING) {
383
+ release.call(this._native, this._retired.shift().handle);
384
+ }
385
+ }
386
+
387
+ /** The pane has looked up every frame up to `seq`: the retired buffers
388
+ * they named go. */
389
+ _seen(seq) {
390
+ if (!(seq > this._seenSeq)) return;
391
+ this._seenSeq = seq;
392
+ if (this._retired.length === 0) return;
393
+ const release = this._native.releaseSurface;
394
+ this._retired = this._retired.filter((buffer) => {
395
+ if (buffer.namedSeq > seq) return true;
396
+ release.call(this._native, buffer.handle);
397
+ return false;
398
+ });
326
399
  }
327
400
 
328
401
  release() {
@@ -332,5 +405,9 @@ export class HostedPlayer {
332
405
  this._want = 0;
333
406
  this._syncPull();
334
407
  this._freeRing();
408
+ // and what it kept: the pane's player is gone, or the pane is
409
+ for (const buffer of this._retired.splice(0)) {
410
+ this._native.releaseSurface(buffer.handle);
411
+ }
335
412
  }
336
413
  }