react-x11 2.37.0 → 2.39.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.37.0",
3
+ "version": "2.39.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
package/src/cocoa/app.js CHANGED
@@ -39,6 +39,7 @@ import { CocoaColorSampler } from './screencolor.js';
39
39
  import { CocoaFilePanels } from './filepanels.js';
40
40
  import { CocoaFontManager } from './fonts.js';
41
41
  import { releaseImageUpload } from '../backend/context2d.js';
42
+ import { PanePlayer, canShowPanePlayers } from './paneplayer.js';
42
43
  import { CocoaPlayer, canPlay } from './player.js';
43
44
  import { CocoaSurface } from './surface.js';
44
45
  import { CocoaSymbols } from './symbols.js';
@@ -224,8 +225,17 @@ export class CocoaApp {
224
225
  // what `useSupports('mediaPlayback')` and `<video src>` ask
225
226
  // (src/mediaplayback.js), so an app over an older bridge, or a test's
226
227
  // fake without them, refuses `src` the way X11 does.
228
+ //
229
+ // A `<Frame>` pane plays nothing itself. It runs no NSApplication, so
230
+ // nothing pumps the bridge's events there: AVFoundation opened the item
231
+ // and its `player-*` events never arrived, and a page's video sat on
232
+ // its poster as a player nobody could start. A host that plays for it
233
+ // says so in the hello, and `attachPaneChannel` puts the pane's player
234
+ // here then (src/cocoa/paneplayer.js); one that does not leaves a pane
235
+ // refusing `src`, its poster shown and `onError` told.
227
236
  this._players = new Map();
228
- if (canPlay(native)) {
237
+ const pane = options.pane ?? process.env.REACT_X11_FRAME === '1';
238
+ if (canPlay(native) && !pane) {
229
239
  this.createPlayer = (src, options) => new CocoaPlayer(this, src, options);
230
240
  }
231
241
 
@@ -522,6 +532,15 @@ export class CocoaApp {
522
532
  return new CocoaPaneHost(this, wnd, { send });
523
533
  }
524
534
 
535
+ /**
536
+ * Whether this app plays a pane's `<video src>` for it — the pane host
537
+ * makes the player and sends its frames down (src/cocoa/paneplayer.js).
538
+ * What a `<Frame>` tells its pane in the hello.
539
+ */
540
+ get playsForPanes() {
541
+ return !this._paneMode && canPlay(this._native);
542
+ }
543
+
525
544
  /**
526
545
  * Whether this backend drops a control's menu as the platform's own — a
527
546
  * bridge with `popUpMenu` (@windowkit/appkit 0.20) — rather than a drawn
@@ -909,9 +928,11 @@ export class CocoaApp {
909
928
  /**
910
929
  * The pane's end of the frame channel (childmain hands it over,
911
930
  * feature-detected so the X11 pane path never notices): geometry and
912
- * input come in, pane-present and pane-cursor go out.
931
+ * input come in, pane-present and pane-cursor go out. `players` is the
932
+ * host's word that it plays the pane's `<video src>`s, which makes
933
+ * `createPlayer` here — before the pane's first render, which asks.
913
934
  */
914
- attachPaneChannel(channel) {
935
+ attachPaneChannel(channel, { players = false } = {}) {
915
936
  if (!this._paneMode) return;
916
937
  this._paneSend = (msg) => {
917
938
  try {
@@ -920,7 +941,19 @@ export class CocoaApp {
920
941
  // the host is going away; its shutdown owns the rest
921
942
  }
922
943
  };
944
+ if (players && canShowPanePlayers(this._native)) {
945
+ this.createPlayer = (src, options) => new PanePlayer(this, src, options);
946
+ }
923
947
  channel.onMessage((msg) => {
948
+ // a player is the app's, not a window's
949
+ if (msg?.type === 'pane-player-event') {
950
+ this._players.get(msg.player)?._event(msg.ev);
951
+ return;
952
+ }
953
+ if (msg?.type === 'pane-player-frame') {
954
+ this._players.get(msg.player)?._frame(msg);
955
+ return;
956
+ }
924
957
  const pane = this._paneWindow ?? [...this._windows.values()][0];
925
958
  if (!pane) return;
926
959
  if (msg?.type === 'pane-rect') {
@@ -8,6 +8,11 @@
8
8
  // sheet, a tooltip, a dialog. A pane process runs no AppKit, so each is an
9
9
  // NSWindow made here, showing the ring the pane draws it into the same way
10
10
  // (`CocoaPaneSubwindow`, src/cocoa/panewindow.js), with its input sent back.
11
+ //
12
+ // And the players its `<video src>`s ask for, which a process with no
13
+ // AppKit would never hear from: each is a player here, its events and its
14
+ // frames sent back (`HostedPlayer`, src/cocoa/paneplayer.js).
15
+ import { HostedPlayer } from './paneplayer.js';
11
16
  import { withoutActions } from './quiet.js';
12
17
 
13
18
  /** One shared surface on glass: a sublayer of a window's root layer, its
@@ -135,6 +140,39 @@ export class CocoaPaneHost extends PaneLayer {
135
140
  this._windows = new Map();
136
141
  /** @type {Map<number, () => void>} the pane's open menus, by its id */
137
142
  this._menus = new Map();
143
+ /** @type {Map<string, HostedPlayer>} the pane's players, by its id */
144
+ this._players = new Map();
145
+ }
146
+
147
+ /**
148
+ * A `pane-player` message: one of the pane's `<video src>`s made, steered
149
+ * or let go of its player. A player that cannot be made says so the way
150
+ * one that cannot play does, as a `player-error`.
151
+ */
152
+ player(msg) {
153
+ if (this.destroyed) return;
154
+ if (msg.op !== 'create') {
155
+ this._players.get(msg.player)?.handle(msg);
156
+ if (msg.op === 'release') this._players.delete(msg.player);
157
+ return;
158
+ }
159
+ if (this._players.has(msg.player)) return;
160
+ try {
161
+ this._players.set(
162
+ msg.player,
163
+ new HostedPlayer(this.app, (m) => this._send?.(m), msg),
164
+ );
165
+ } catch (err) {
166
+ this._send?.({
167
+ type: 'pane-player-event',
168
+ player: msg.player,
169
+ ev: {
170
+ type: 'player-error',
171
+ id: msg.player,
172
+ message: err?.message ?? String(err),
173
+ },
174
+ });
175
+ }
138
176
  }
139
177
 
140
178
  /**
@@ -298,12 +336,15 @@ export class CocoaPaneHost extends PaneLayer {
298
336
  }
299
337
 
300
338
  /** The pane is going, and every window it had here goes with it — a pane
301
- * that crashed with a menu open leaves no menu behind. */
339
+ * that crashed with a menu open leaves no menu behind, and one that
340
+ * crashed playing leaves no sound. */
302
341
  destroy() {
303
342
  if (this.destroyed) return;
304
343
  for (const cancel of [...this._menus.values()]) cancel();
305
344
  this._menus.clear();
306
345
  for (const entry of [...this._windows.values()]) this._drop(entry);
346
+ for (const hosted of this._players.values()) hosted.release();
347
+ this._players.clear();
307
348
  super.destroy();
308
349
  }
309
350
  }
@@ -0,0 +1,336 @@
1
+ // `<video src>` in a `<Frame>` pane on macOS. A pane process runs no
2
+ // NSApplication, so nothing there pumps the bridge's events, and a player it
3
+ // made would open the item and never hear AVFoundation say a word: no size,
4
+ // no play, no frame. So a pane's player is the host's. `PanePlayer` is what
5
+ // the pane's `<video>` holds — the same state and the same verbs as a
6
+ // `CocoaPlayer` — and it sends every verb up the frame channel; the host
7
+ // makes a real `CocoaPlayer` for it (`HostedPlayer`, made by
8
+ // src/cocoa/panehost.js) and sends its events back down.
9
+ //
10
+ // The picture comes down the way a pane's own goes up, the other way round:
11
+ // the host copies the frame showing into one of a ring of shared IOSurfaces
12
+ // (`playerCopyFrame` into a `createSurfaceIOSurface(…, true)` bitmap) and
13
+ // names it, and the pane looks it up by id and copies it into the element's
14
+ // surface when the element asks (`copyFrame`, a `blitSurface` memcpy). A
15
+ // pane has no layers to lift a part onto, so its `<video>` always draws.
16
+ //
17
+ // The ring is three deep because the two processes do not wait on each
18
+ // other: the host writes the buffer after the one it last named, so the one
19
+ // the pane is told about is not written again for two frames — some 66ms at
20
+ // 30fps, against a pane that copies within a display frame of hearing.
21
+
22
+ import { PlayerState } from './player.js';
23
+
24
+ // How often the host asks its player for a frame while it plays — the
25
+ // drawn `<video>`'s own cadence (src/nodes/video.js): a copy answers null
26
+ // for a frame that has not changed, so asking every display frame costs a
27
+ // call, and sends nothing.
28
+ const PULL_MS = 16;
29
+
30
+ // How many times the host asks a paused player for the frame a seek or the
31
+ // item's arrival shows before giving up, a display frame apart: AVFoundation
32
+ // has it a few frames after the event, and a stream that never decodes one
33
+ // should not keep a timer running.
34
+ const WANT_TRIES = 60;
35
+
36
+ const RING = 3;
37
+
38
+ let seq = 0;
39
+
40
+ /** `fn()` with a shared buffer's memory locked for the CPU, as IOSurface
41
+ * asks of anything that reads or writes its bytes; the bridge's verbs are
42
+ * optional, as they are for the pane's own ring. */
43
+ function locked(native, handle, fn) {
44
+ const lock = typeof native.surfaceLock === 'function';
45
+ if (lock) native.surfaceLock(handle);
46
+ try {
47
+ return fn();
48
+ } finally {
49
+ if (lock) native.surfaceUnlock?.(handle);
50
+ }
51
+ }
52
+
53
+ /** Can a pane's player be shown over this bridge? It looks the host's
54
+ * buffers up and copies out of them. */
55
+ export function canShowPanePlayers(native) {
56
+ return (
57
+ typeof native?.surfaceFromIOSurfaceID === 'function' &&
58
+ typeof native.blitSurface === 'function'
59
+ );
60
+ }
61
+
62
+ /**
63
+ * The pane's end: a player whose item the host plays. Its state is what
64
+ * the host's events say; the frame is whichever shared buffer the host
65
+ * named last, copied out on request.
66
+ */
67
+ export class PanePlayer extends PlayerState {
68
+ constructor(app, src, options = {}) {
69
+ super();
70
+ this.app = app;
71
+ this._native = app._native;
72
+ this.id = `pane-player-${++seq}`;
73
+ /** the host's newest frame: its buffer here, size and time */
74
+ this._shown = null;
75
+ this._fresh = false;
76
+ /** the host's buffers, looked up: IOSurface id → surface handle */
77
+ this._buffers = new Map();
78
+ this._bufferSize = null;
79
+ app._players.set(this.id, this);
80
+ this._post({
81
+ op: 'create',
82
+ src: String(src),
83
+ options: {
84
+ autoPlay: Boolean(options.autoPlay),
85
+ loop: Boolean(options.loop),
86
+ muted: Boolean(options.muted),
87
+ volume: typeof options.volume === 'number' ? options.volume : 1,
88
+ playbackRate:
89
+ typeof options.playbackRate === 'number' ? options.playbackRate : 1,
90
+ },
91
+ });
92
+ }
93
+
94
+ // held until the host is listening, as the pane's windows are
95
+ // (`CocoaApp._postWindow`): a `<video>` in the pane's first commit asks
96
+ // before the host has a pane host to answer
97
+ _post(msg) {
98
+ this.app._postWindow({ type: 'pane-player', player: this.id, ...msg });
99
+ }
100
+
101
+ set({ paused, playbackRate, volume, muted, loop } = {}) {
102
+ if (this.released) return;
103
+ this._post({
104
+ op: 'set',
105
+ settings: { paused, playbackRate, volume, muted, loop },
106
+ });
107
+ }
108
+
109
+ seek(seconds) {
110
+ if (this.released || !Number.isFinite(seconds)) return;
111
+ this.currentTime = Math.max(0, seconds);
112
+ this._post({ op: 'seek', seconds: this.currentTime });
113
+ }
114
+
115
+ /** The host named a buffer with a new frame in it. */
116
+ _frame({ surface, width, height, time }) {
117
+ 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
+ }
137
+ this._shown = { handle, width, height, time };
138
+ this._fresh = true;
139
+ // a playing `<video>` asks every display frame; a paused one asks when
140
+ // its time moves, and the frame a seek shows arrives after the time it
141
+ // landed on did — so it is told the time again, with the frame in hand
142
+ if (!this.playing) {
143
+ this._event({
144
+ type: 'player-time',
145
+ id: this.id,
146
+ currentTime: this.currentTime,
147
+ });
148
+ }
149
+ }
150
+
151
+ /** As `CocoaPlayer.copyFrame`: the newest frame into `surface`, or null
152
+ * when the host has sent nothing since the last copy. */
153
+ copyFrame(surface) {
154
+ const shown = this._shown;
155
+ if (this.released || !this._fresh || !surface?._surfaceHandle) return null;
156
+ const { width, height, time } = shown;
157
+ const fits = surface.width === width && surface.height === height;
158
+ if (fits) {
159
+ locked(this._native, shown.handle, () =>
160
+ this._native.blitSurface(
161
+ shown.handle,
162
+ 0,
163
+ 0,
164
+ width,
165
+ height,
166
+ surface._surfaceHandle,
167
+ 0,
168
+ 0,
169
+ ),
170
+ );
171
+ this._fresh = false;
172
+ }
173
+ return { width, height, time, written: fits };
174
+ }
175
+
176
+ _dropBuffers() {
177
+ const release = this._native.releaseSurface;
178
+ if (typeof release === 'function') {
179
+ for (const handle of this._buffers.values()) {
180
+ release.call(this._native, handle);
181
+ }
182
+ }
183
+ this._buffers.clear();
184
+ this._shown = null;
185
+ this._fresh = false;
186
+ }
187
+
188
+ release() {
189
+ if (this.released) return;
190
+ this.released = true;
191
+ this._listeners.clear();
192
+ this.app._players.delete(this.id);
193
+ this._dropBuffers();
194
+ this._post({ op: 'release' });
195
+ }
196
+ }
197
+
198
+ /**
199
+ * The host's end: a real player for one of the pane's, its events sent down
200
+ * as they come and its frames copied into the shared ring while it plays —
201
+ * or, paused, the one frame a seek or the item's arrival shows.
202
+ */
203
+ export class HostedPlayer {
204
+ constructor(app, send, { player, src, options }) {
205
+ this.app = app;
206
+ this._native = app._native;
207
+ this._send = send;
208
+ this.id = player;
209
+ this.player = app.createPlayer(src, options);
210
+ this._ring = [];
211
+ this._ringSize = null;
212
+ this._next = 0;
213
+ this._want = 0;
214
+ this._pull = null;
215
+ this._off = this.player._subscribe((ev) => this._event(ev));
216
+ }
217
+
218
+ /** A message from the pane about this player. */
219
+ handle(msg) {
220
+ const player = this.player;
221
+ if (player.released) return;
222
+ if (msg.op === 'set') player.set(msg.settings);
223
+ else if (msg.op === 'seek') player.seek(msg.seconds);
224
+ else if (msg.op === 'release') this.release();
225
+ }
226
+
227
+ _event(ev) {
228
+ this._send({
229
+ type: 'pane-player-event',
230
+ player: this.id,
231
+ ev: { ...ev, id: this.id },
232
+ });
233
+ const player = this.player;
234
+ // a new size is what the next frame is copied at
235
+ if (ev.type === 'player-metadata') this._ringSize = null;
236
+ // the item's first frame, and the frame a seek lands on while paused,
237
+ // are the frames nothing playing would copy
238
+ if (
239
+ ev.type === 'player-metadata' ||
240
+ (ev.type === 'player-time' && !player.playing)
241
+ ) {
242
+ this._want = WANT_TRIES;
243
+ this._copy();
244
+ }
245
+ this._syncPull();
246
+ }
247
+
248
+ _syncPull() {
249
+ const player = this.player;
250
+ const want = !player.released && (player.playing || this._want > 0);
251
+ if (want && this._pull === null) {
252
+ this._pull = setInterval(() => this._copy(), PULL_MS);
253
+ this._pull.unref?.();
254
+ } else if (!want && this._pull !== null) {
255
+ clearInterval(this._pull);
256
+ this._pull = null;
257
+ }
258
+ }
259
+
260
+ /** The frame showing into the next buffer of the ring, and the pane told
261
+ * which; nothing when the player has nothing new. */
262
+ _copy() {
263
+ const player = this.player;
264
+ if (player.released) return;
265
+ const size = this._ringSize ?? {
266
+ width: player.width,
267
+ height: player.height,
268
+ };
269
+ let got = null;
270
+ if (size.width > 0 && size.height > 0) {
271
+ const buffer = this._buffer(size.width, size.height);
272
+ got = locked(this._native, buffer.handle, () =>
273
+ this._native.playerCopyFrame(player.id, buffer.handle),
274
+ );
275
+ if (got?.written) {
276
+ this._next = (this._next + 1) % RING;
277
+ this._send({
278
+ type: 'pane-player-frame',
279
+ player: this.id,
280
+ surface: buffer.iosurfaceId,
281
+ width: got.width,
282
+ height: got.height,
283
+ time: got.time,
284
+ });
285
+ if (!player.playing) this._want = 0;
286
+ } else if (got?.width > 0 && got?.height > 0) {
287
+ // a frame of another size than the ring (a stream that switched
288
+ // renditions): the next is copied into a ring of its size
289
+ this._ringSize = { width: got.width, height: got.height };
290
+ }
291
+ }
292
+ if (!got?.written && this._want > 0) this._want -= 1;
293
+ this._syncPull();
294
+ }
295
+
296
+ /** The ring's next buffer, at `width` x `height`: a new ring at another
297
+ * size. Shared, so the pane can look each one up by its id. */
298
+ _buffer(width, height) {
299
+ if (
300
+ this._ring.length === 0 ||
301
+ this._ring[0].width !== width ||
302
+ this._ring[0].height !== height
303
+ ) {
304
+ this._freeRing();
305
+ for (let i = 0; i < RING; i += 1) {
306
+ const made = this._native.createSurfaceIOSurface(
307
+ width,
308
+ height,
309
+ 1,
310
+ true,
311
+ );
312
+ this._ring.push({ ...made, width, height });
313
+ }
314
+ this._next = 0;
315
+ }
316
+ return this._ring[this._next];
317
+ }
318
+
319
+ _freeRing() {
320
+ const release = this._native.releaseSurface;
321
+ if (typeof release === 'function') {
322
+ for (const buffer of this._ring)
323
+ release.call(this._native, buffer.handle);
324
+ }
325
+ this._ring = [];
326
+ }
327
+
328
+ release() {
329
+ if (this.player.released) return;
330
+ this._off();
331
+ this.player.release();
332
+ this._want = 0;
333
+ this._syncPull();
334
+ this._freeRing();
335
+ }
336
+ }
@@ -24,24 +24,16 @@ export function canPlay(native) {
24
24
  }
25
25
 
26
26
  /**
27
- * One file or URL, playing or not. `width`/`height` are the picture's once
28
- * the item is ready (0 before), `duration` its length in seconds (`Infinity`
29
- * for a live stream, NaN before it is known).
27
+ * What a player knows and who hears it: the state its `player-*` events
28
+ * say, the same whichever process the player is in — this one's
29
+ * (`CocoaPlayer`), or a `<Frame>` host's on a pane's behalf
30
+ * (src/cocoa/paneplayer.js). `width`/`height` are the picture's once the
31
+ * item is ready (0 before), `duration` its length in seconds (`Infinity`
32
+ * for a live stream, NaN before it is known). A subclass sends `set` and
33
+ * `seek` on.
30
34
  */
31
- export class CocoaPlayer {
32
- constructor(app, src, options = {}) {
33
- this.app = app;
34
- this._native = app._native;
35
- const made = this._native.createPlayer(String(src), {
36
- autoPlay: Boolean(options.autoPlay),
37
- loop: Boolean(options.loop),
38
- muted: Boolean(options.muted),
39
- volume: typeof options.volume === 'number' ? options.volume : 1,
40
- rate: typeof options.playbackRate === 'number' ? options.playbackRate : 1,
41
- });
42
- this.id = made.id;
43
- /** the AVPlayerLayer, a layer handle */
44
- this.layer = made.layer;
35
+ export class PlayerState {
36
+ constructor() {
45
37
  this.width = 0;
46
38
  this.height = 0;
47
39
  this.duration = NaN;
@@ -51,11 +43,9 @@ export class CocoaPlayer {
51
43
  this.error = null;
52
44
  this.released = false;
53
45
  this._listeners = new Set();
54
- app._players.set(this.id, this);
55
46
  }
56
47
 
57
- /** A `player-*` event from the bridge: the state it says, then whoever
58
- * listens. */
48
+ /** A `player-*` event: the state it says, then whoever listens. */
59
49
  _event(ev) {
60
50
  if (this.released) return;
61
51
  switch (ev.type) {
@@ -96,6 +86,36 @@ export class CocoaPlayer {
96
86
  return () => this._listeners.delete(fn);
97
87
  }
98
88
 
89
+ play() {
90
+ // HTML plays an ended item from its start
91
+ if (this.ended) this.seek(0);
92
+ this.set({ paused: false });
93
+ }
94
+
95
+ pause() {
96
+ this.set({ paused: true });
97
+ }
98
+ }
99
+
100
+ /** One file or URL, played by this process's bridge. */
101
+ export class CocoaPlayer extends PlayerState {
102
+ constructor(app, src, options = {}) {
103
+ super();
104
+ this.app = app;
105
+ this._native = app._native;
106
+ const made = this._native.createPlayer(String(src), {
107
+ autoPlay: Boolean(options.autoPlay),
108
+ loop: Boolean(options.loop),
109
+ muted: Boolean(options.muted),
110
+ volume: typeof options.volume === 'number' ? options.volume : 1,
111
+ rate: typeof options.playbackRate === 'number' ? options.playbackRate : 1,
112
+ });
113
+ this.id = made.id;
114
+ /** the AVPlayerLayer, a layer handle */
115
+ this.layer = made.layer;
116
+ app._players.set(this.id, this);
117
+ }
118
+
99
119
  /** What `paused`, `playbackRate`, `volume`, `muted` and `loop` say now;
100
120
  * what is left out stays. */
101
121
  set({ paused, playbackRate, volume, muted, loop } = {}) {
@@ -111,16 +131,6 @@ export class CocoaPlayer {
111
131
  this._native.playerSet(this.id, settings);
112
132
  }
113
133
 
114
- play() {
115
- // HTML plays an ended item from its start
116
- if (this.ended) this.seek(0);
117
- this.set({ paused: false });
118
- }
119
-
120
- pause() {
121
- this.set({ paused: true });
122
- }
123
-
124
134
  seek(seconds) {
125
135
  if (this.released || !Number.isFinite(seconds)) return;
126
136
  this.currentTime = Math.max(0, seconds);
@@ -28,7 +28,9 @@
28
28
  // square corners cuts the layer to its box, a row half out of its list as
29
29
  // much as a spinner inside a card. A scrollbar's thumb drawn over it goes
30
30
  // on a layer of its own above it instead (`_syncThumbs`). And no ancestor
31
- // may fade, because the layer would not be faded with it. Declining is always
31
+ // may fade, because the layer would not be faded with it. Nor may it be in a
32
+ // pane that is scrolling (`_scrolling`): its layer would move every frame
33
+ // the bitmap is painted anyway. Declining is always
32
34
  // safe: the frame clock runs the animation exactly as it does without this
33
35
  // file. The same test runs again every frame, so a node that becomes
34
36
  // overlapped, hidden, clipped, faded or non-plain returns to the bitmap in
@@ -36,6 +38,7 @@
36
38
  // loop the clock runs is offered again once the scene would take it
37
39
  // (`wouldTake`), where it is in its cycle. docs/macos.md §"Layer
38
40
  // promotion" is the account.
41
+ import { now } from '../nodes/animation.js';
39
42
  import { BoxNode } from '../nodes/box.js';
40
43
  import { addDamageRect, damageToPaint } from '../nodes/damage.js';
41
44
  import { intersectRects } from '../nodes/rects.js';
@@ -71,6 +74,19 @@ const THUMB_COLOUR = 'rgba(0, 0, 0, 0.25)';
71
74
  // user's attention span for a layer that is only holding still.
72
75
  const IDLE_GRACE_MS = 1000;
73
76
 
77
+ // How long after a pane last scrolled a node inside it may have a layer
78
+ // again (`_scrolling`). A wheel steps a pane sixty times a second against a
79
+ // display's 75 or 120, so a scroll has still frames in it, and a node
80
+ // promoted in one of those came off its layer again in the next. And a
81
+ // sprinkle of fades on rows scrolled under the pointer is the case it is
82
+ // for: on a 100,000-row table each hovered row kept a layer for the grace,
83
+ // asked again every frame whether it might stay and its raster synced as
84
+ // the pane moved it, and a wheel cost 29% more of every frame than painting
85
+ // the fades in the bitmap did (react-x11-components' perf sweep, round 45).
86
+ // A loop on a row comes off with the rest and is offered again once the
87
+ // pane has been still this long (`wouldTake`).
88
+ const SCROLL_QUIET_MS = 250;
89
+
74
90
  // Past this many bare-rect claims on one raster the passes cost more than
75
91
  // the repaint they save (the layer presenter's rule, per raster).
76
92
  const MAX_DIRTY_RECTS = 16;
@@ -327,6 +343,8 @@ export class CocoaPromotion {
327
343
  // layers are under
328
344
  this.thumbs = new Map(); // pane -> Map(axis -> { layer, props })
329
345
  this._thumbsWanted = null;
346
+ // when each scroll pane last scrolled, on the frame clock (`_scrolling`)
347
+ this._scrolledAt = new WeakMap(); // pane -> ms
330
348
  this._claiming = false;
331
349
  }
332
350
 
@@ -474,6 +492,9 @@ export class CocoaPromotion {
474
492
  */
475
493
  frame(root, layoutRan) {
476
494
  if (layoutRan) this.layoutGen++;
495
+ // before anything can return early: a still frame between a wheel's
496
+ // steps has to know the pane was moving in the one before
497
+ this._noteScrolls(root);
477
498
  // what elements offer, asked before anything is decided: an answer is
478
499
  // about the element's own drawing, not about the layers
479
500
  const offers = this.sprites.ask(root);
@@ -506,12 +527,16 @@ export class CocoaPromotion {
506
527
  const { node, key } = candidates[i];
507
528
  if (this.promoted.has(node) || !this.animations.has(node)) continue;
508
529
  const cut = { thumbs: new Set(), round: true };
509
- const reach = key && promotableNode(node) ? reachOf(node) : null;
530
+ const scrolling = this._scrolling(node);
531
+ const reach =
532
+ key && promotableNode(node) && !scrolling ? reachOf(node) : null;
510
533
  if (reach && this._clear(node, reach, cut)) {
511
534
  for (const pane of cut.thumbs) wanted.add(pane);
512
535
  this._promote(node, root, clipOf(cut, reach));
513
536
  } else {
514
- this.denied.set(node, this.layoutGen);
537
+ // a scene answers the same way until it is laid out again; a
538
+ // scroll is over in a moment, and lays nothing out to say so
539
+ if (!scrolling) this.denied.set(node, this.layoutGen);
515
540
  this.animations.drop(node, true);
516
541
  }
517
542
  }
@@ -552,6 +577,7 @@ export class CocoaPromotion {
552
577
  _mayStay(node, key) {
553
578
  if (!key || !promotableNode(node)) return false;
554
579
  if (this.releasing.has(node)) return false; // its grace ran out
580
+ if (this._scrolling(node)) return false;
555
581
  const cut = { thumbs: new Set(), round: true };
556
582
  const reach = reachOf(node);
557
583
  if (!reach || !this._clear(node, reach, cut)) return false;
@@ -573,6 +599,9 @@ export class CocoaPromotion {
573
599
  if (this.window.destroyed || node.destroyed) return false;
574
600
  if (this.promoted.has(node)) return true;
575
601
  if (!promotableNode(node) || !this._paintKey(node)) return false;
602
+ // asked before the frame, by the window's loop pass
603
+ this._noteScrolls(node.root);
604
+ if (this._scrolling(node)) return false;
576
605
  const reach = reachOf(node);
577
606
  if (!reach) return false;
578
607
  if (!this._clear(node, reach, { thumbs: new Set(), round: true })) {
@@ -585,6 +614,30 @@ export class CocoaPromotion {
585
614
  return true;
586
615
  }
587
616
 
617
+ /** The panes `root`'s frame scrolled: the window keeps them until it
618
+ * takes the frame's damage, which is after this. */
619
+ _noteScrolls(root) {
620
+ const pending = root?._pendingScrolls;
621
+ if (!pending?.size) return;
622
+ const t = now();
623
+ for (const pane of pending) this._scrolledAt.set(pane, t);
624
+ }
625
+
626
+ /**
627
+ * Is `node` inside a pane that is scrolling — one that moved this frame,
628
+ * or within `SCROLL_QUIET_MS` of it? Its layer would move every frame
629
+ * the pane is painted anyway, and the frame keeps the node in the bitmap,
630
+ * where a pane's scroll blit carries it.
631
+ */
632
+ _scrolling(node) {
633
+ const t = now();
634
+ for (let n = node.parent; n && !n.isWindow; n = n.parent) {
635
+ const at = this._scrolledAt.get(n);
636
+ if (at !== undefined && t - at < SCROLL_QUIET_MS) return true;
637
+ }
638
+ return false;
639
+ }
640
+
588
641
  _promote(node, root, clip = null) {
589
642
  const p = {
590
643
  visual: new Visual(this, node),
@@ -27,7 +27,10 @@
27
27
  // `@windowkit/appkit` 0.19 (`transformForms()`) along with the negative
28
28
  // delay a part joined half way through needs, so that is the bridge sprites
29
29
  // ask for: on an older one nothing is asked and every part is drawn by its
30
- // element.
30
+ // element. Or it is CSS's `matrix3d()`, a part turned out of the plane and
31
+ // seen in a perspective, which Core Animation draws through the whole
32
+ // matrix on the GPU: a part none of whose corners is behind the viewer, on a
33
+ // bridge that takes one (`matrix3d`).
31
34
  //
32
35
  // A part none of which shows — scrolled out of its pane, or outside its own
33
36
  // clip — keeps its layer, cut to show nothing, up to a budget of raster for
@@ -68,6 +71,14 @@ const AFTER_CHILDREN = Number.MAX_SAFE_INTEGER;
68
71
 
69
72
  const IDENTITY = Object.freeze([1, 0, 0, 1, 0, 0]);
70
73
 
74
+ // what a part with no bounds may reach (`_underDrawn`)
75
+ const EVERYWHERE = Object.freeze({
76
+ x: -1e9,
77
+ y: -1e9,
78
+ width: 2e9,
79
+ height: 2e9,
80
+ });
81
+
71
82
  const ORIGIN = Object.freeze({ x: 0, y: 0 });
72
83
 
73
84
  // what a part with no part lifted inside it is handed
@@ -98,7 +109,20 @@ const isRect = (r) =>
98
109
  r.width > 0 &&
99
110
  r.height > 0;
100
111
 
101
- const isMatrix = (m) => Array.isArray(m) && m.length === 6 && m.every(finite);
112
+ // CSS's `matrix(a, b, c, d, e, f)`, or its `matrix3d()`'s sixteen numbers
113
+ const isMatrix = (m) =>
114
+ Array.isArray(m) && (m.length === 6 || m.length === 16) && m.every(finite);
115
+
116
+ const isSolid = (m) => m.length === 16;
117
+
118
+ /** Does `sprite` turn out of the plane, at rest or in an animation? */
119
+ function solidSprite(sprite) {
120
+ if (sprite.transform && isSolid(sprite.transform)) return true;
121
+ for (const a of sprite.animations ?? []) {
122
+ if (a.property === 'transform' && a.values.some(isSolid)) return true;
123
+ }
124
+ return false;
125
+ }
102
126
 
103
127
  const isCurve = (t) =>
104
128
  typeof t === 'string' ||
@@ -203,12 +227,15 @@ const originOf = (sprite) =>
203
227
  y: sprite.rect.y + sprite.rect.height / 2,
204
228
  };
205
229
 
206
- /** `rect` through `m` about `origin`: the bounds of its four corners. */
230
+ /** `rect` through `m` about `origin`: the bounds of its four corners, or
231
+ * null where `m` is a matrix3d that puts a corner behind the viewer, past
232
+ * which the plane's picture has no bounds. */
207
233
  function mapRect(rect, m, origin) {
208
234
  let x0 = Infinity;
209
235
  let y0 = Infinity;
210
236
  let x1 = -Infinity;
211
237
  let y1 = -Infinity;
238
+ const solid = isSolid(m);
212
239
  for (const [px, py] of [
213
240
  [rect.x, rect.y],
214
241
  [rect.x + rect.width, rect.y],
@@ -217,8 +244,18 @@ function mapRect(rect, m, origin) {
217
244
  ]) {
218
245
  const dx = px - origin.x;
219
246
  const dy = py - origin.y;
220
- const x = m[0] * dx + m[2] * dy + m[4] + origin.x;
221
- const y = m[1] * dx + m[3] * dy + m[5] + origin.y;
247
+ let x;
248
+ let y;
249
+ if (solid) {
250
+ // column-major, as `matrix3d()` writes it: the point (dx, dy, 0, 1)
251
+ const w = m[3] * dx + m[7] * dy + m[15];
252
+ if (!(w > W_NEAR)) return null;
253
+ x = (m[0] * dx + m[4] * dy + m[12]) / w + origin.x;
254
+ y = (m[1] * dx + m[5] * dy + m[13]) / w + origin.y;
255
+ } else {
256
+ x = m[0] * dx + m[2] * dy + m[4] + origin.x;
257
+ y = m[1] * dx + m[3] * dy + m[5] + origin.y;
258
+ }
222
259
  if (x < x0) x0 = x;
223
260
  if (y < y0) y0 = y;
224
261
  if (x > x1) x1 = x;
@@ -227,6 +264,10 @@ function mapRect(rect, m, origin) {
227
264
  return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
228
265
  }
229
266
 
267
+ // A corner whose w is this close to the viewer's plane is as good as behind
268
+ // it: projected that near, it lands off any window.
269
+ const W_NEAR = 1e-6;
270
+
230
271
  function unionRect(a, b) {
231
272
  if (!a) return b;
232
273
  const x = Math.min(a.x, b.x);
@@ -242,7 +283,8 @@ function unionRect(a, b) {
242
283
  /**
243
284
  * Everywhere `sprite` can draw while its animations run: its reach through
244
285
  * its own transform and every transform it is animated through, out to
245
- * whole pixels and the antialiasing a raster is padded for. Between two
286
+ * whole pixels and the antialiasing a raster is padded for — or null, where
287
+ * one of those puts a corner of it behind the viewer. Between two
246
288
  * keyframes Core Animation interpolates the matrices by their parts, which
247
289
  * stays within a pixel of the two for the densely sampled keyframes a CSS
248
290
  * transform arrives as; an element whose keyframes are far apart, or whose
@@ -271,12 +313,18 @@ function extentOf(sprite) {
271
313
  if (about?.key !== key) {
272
314
  let box = mapRect(rel, m0, ORIGIN);
273
315
  for (const a of sprite.animations ?? []) {
274
- if (a.property !== 'transform') continue;
275
- for (const m of a.values) box = unionRect(box, mapRect(rel, m, ORIGIN));
316
+ if (a.property !== 'transform' || !box) continue;
317
+ for (const m of a.values) {
318
+ const at = mapRect(rel, m, ORIGIN);
319
+ box = at && unionRect(box, at);
320
+ if (!box) break;
321
+ }
276
322
  }
277
323
  about = { key, box };
278
324
  if (turns) EXTENTS.set(turns, about);
279
325
  }
326
+ // a corner behind the viewer, at rest or on the way: nowhere bounded
327
+ if (!about.box) return null;
280
328
  const box = {
281
329
  x: about.box.x + origin.x,
282
330
  y: about.box.y + origin.y,
@@ -311,7 +359,31 @@ function rasterRectOf(sprite) {
311
359
  }
312
360
 
313
361
  const sameMatrix = (a, b) =>
314
- a === b || (a != null && b != null && a.every((v, i) => v === b[i]));
362
+ a === b ||
363
+ (a != null &&
364
+ b != null &&
365
+ a.length === b.length &&
366
+ a.every((v, i) => v === b[i]));
367
+
368
+ /**
369
+ * A part's matrix in device pixels as the bridge takes it, in points: its
370
+ * translation over the scale and, for a matrix3d, its perspective row times
371
+ * it — S⁻¹·M·S, with S scaling x, y and z, so that a turn and a perspective
372
+ * of so many CSS pixels look the same at any scale.
373
+ */
374
+ function inPoints(m, s) {
375
+ if (!isSolid(m)) {
376
+ return { matrix: [m[0], m[1], m[2], m[3], m[4] / s, m[5] / s] };
377
+ }
378
+ const p = m.slice();
379
+ p[12] /= s;
380
+ p[13] /= s;
381
+ p[14] /= s;
382
+ p[3] *= s;
383
+ p[7] *= s;
384
+ p[11] *= s;
385
+ return { matrix3d: p };
386
+ }
315
387
 
316
388
  /**
317
389
  * The lifted parts of every element in one window: a layer each, its raster,
@@ -326,9 +398,10 @@ export class SpriteLayers {
326
398
  this.app = promotion.app;
327
399
  // a matrix and a negative delay are what a CSS animation needs from the
328
400
  // bridge, and @windowkit/appkit 0.19 is where both are
329
- this.supported = Boolean(
330
- this.native.transformForms?.()?.includes?.('matrix'),
331
- );
401
+ const forms = this.native.transformForms?.();
402
+ this.supported = Boolean(forms?.includes?.('matrix'));
403
+ // and a part turned out of the plane, its matrix3d, the same release
404
+ this.solid = Boolean(forms?.includes?.('matrix3d'));
332
405
  // and a video's frames on a layer need its video surfaces
333
406
  this.videos = canLiftVideo(this.native);
334
407
  this.hosts = new Map(); // element -> Map(key -> state)
@@ -429,7 +502,12 @@ export class SpriteLayers {
429
502
  radius: sprite.clip ? (sprite.clipRadius ?? 0) : 0,
430
503
  bounds: null,
431
504
  };
505
+ // turned out of the plane, on a bridge that takes no matrix3d
506
+ if (!this.solid && solidSprite(sprite)) continue;
432
507
  const extent = extentOf(sprite);
508
+ // a corner behind the viewer: the element draws it, which it can cut
509
+ // where the plane meets the viewer's
510
+ if (!extent) continue;
433
511
  const bounds = cut.clip ? intersectRects(extent, cut.clip) : extent;
434
512
  if (parent) {
435
513
  // the parent was asked about everywhere it can be, and its layer
@@ -544,8 +622,9 @@ export class SpriteLayers {
544
622
  drawn.push(state.extent);
545
623
  continue;
546
624
  }
547
- // the element draws it, where its own clip lets it show
548
- const extent = extentOf(sprite);
625
+ // the element draws it, where its own clip lets it show — anywhere,
626
+ // for one with a corner behind the viewer and no clip
627
+ const extent = extentOf(sprite) ?? EVERYWHERE;
549
628
  const shows = sprite.clip ? intersectRects(extent, sprite.clip) : extent;
550
629
  if (shows) drawn.push(shows);
551
630
  }
@@ -776,7 +855,7 @@ export class SpriteLayers {
776
855
  }
777
856
  }
778
857
  if (!sameMatrix(prev.matrix, m)) {
779
- out.transform = { matrix: [m[0], m[1], m[2], m[3], m[4] / s, m[5] / s] };
858
+ out.transform = inPoints(m, s);
780
859
  any = true;
781
860
  }
782
861
  if (any) this.native.setLayerProps(state.layer, out);
@@ -843,9 +922,7 @@ export class SpriteLayers {
843
922
  const opts = {
844
923
  values:
845
924
  a.property === 'transform'
846
- ? a.values.map((m) => ({
847
- matrix: [m[0], m[1], m[2], m[3], m[4] / s, m[5] / s],
848
- }))
925
+ ? a.values.map((m) => inPoints(m, s))
849
926
  : a.values,
850
927
  duration: a.duration / 1000,
851
928
  id: caId,
@@ -225,10 +225,14 @@ export async function runFrameChild(transport, options = {}) {
225
225
  // (the Cocoa one) takes the channel itself: geometry and input in,
226
226
  // presents out. Feature-detected — the X11 pane path has a real window
227
227
  // and a real server and needs none of this.
228
- root.app.attachPaneChannel?.({
229
- send: (msg) => transport.send(msg),
230
- onMessage: (cb) => transport.onMessage(cb),
231
- });
228
+ root.app.attachPaneChannel?.(
229
+ {
230
+ send: (msg) => transport.send(msg),
231
+ onMessage: (cb) => transport.onMessage(cb),
232
+ },
233
+ // what the host does for the pane that a pane cannot do itself
234
+ { players: Boolean(hello.players) },
235
+ );
232
236
 
233
237
  let closing = false;
234
238
  const close = async () => {
@@ -412,6 +412,9 @@ export function Frame({
412
412
  rect,
413
413
  props: s.table.snapshot(p),
414
414
  env: bridgedEnv(e, b),
415
+ // whether this side plays the pane's `<video src>`s for it — the
416
+ // Cocoa host does, for a pane process that cannot (paneplayer.js)
417
+ ...(paneApp?.playsForPanes && { players: true }),
415
418
  });
416
419
 
417
420
  return () => {
@@ -613,6 +616,8 @@ function PaneHostView({
613
616
  host.popUpMenu?.(msg);
614
617
  } else if (msg?.type === 'pane-menu-cancel') {
615
618
  host.cancelMenu?.(msg.menu);
619
+ } else if (msg?.type === 'pane-player') {
620
+ host.player?.(msg);
616
621
  } else if (msg?.type === 'pane-present' && msg.window != null) {
617
622
  host.presentWindow?.(msg);
618
623
  } else if (msg?.type === 'pane-cursor' && msg.window != null) {
@@ -6,7 +6,7 @@
6
6
  // Six messages, all objects with a `type`:
7
7
  //
8
8
  // parent → child
9
- // hello { protocol, src, display, rect, props, env } first, once
9
+ // hello { protocol, src, display, rect, props, env, players? } first, once
10
10
  // update { props, env } full snapshots, one per parent commit
11
11
  // unmount {} run close handlers, unmount, exit
12
12
  //
package/src/node.d.ts CHANGED
@@ -237,6 +237,23 @@ export interface PaintCachePlan {
237
237
  */
238
238
  export type SpriteMatrix = [number, number, number, number, number, number];
239
239
 
240
+ /**
241
+ * CSS's `matrix3d()`: sixteen numbers, column by column, a point
242
+ * (x, y, z, 1) going to the first three rows over the fourth — a turn out
243
+ * of the plane and the perspective it is seen in, about the sprite's
244
+ * `origin`. Its translation (the 13th to 15th) is in device pixels, and so
245
+ * is the distance its perspective row divides by. A presenter lifts a
246
+ * sprite with one only where none of its corners is behind the viewer, at
247
+ * rest or in any keyframe, and where its bridge takes one.
248
+ */
249
+ // prettier-ignore
250
+ export type SpriteMatrix3D = [
251
+ number, number, number, number,
252
+ number, number, number, number,
253
+ number, number, number, number,
254
+ number, number, number, number,
255
+ ];
256
+
240
257
  /** A cubic-bezier's control points `[x1, y1, x2, y2]`, `x1` and `x2` in
241
258
  * 0..1, or one of Core Animation's curve names. */
242
259
  export type SpriteTiming = [number, number, number, number] | string;
@@ -252,8 +269,9 @@ export interface SpriteAnimation {
252
269
  id: string;
253
270
  property: 'opacity' | 'transform';
254
271
  /** At least two: opacities, or matrices for a transform — sampled densely
255
- * where the element interpolates in a way the render server would not. */
256
- values: number[] | SpriteMatrix[];
272
+ * where the element interpolates in a way the render server would not.
273
+ * A transform's may be `matrix3d`s, in any mix with `matrix`es. */
274
+ values: number[] | (SpriteMatrix | SpriteMatrix3D)[];
257
275
  /** One per value, 0 to 1, never decreasing; evenly spaced when left out. */
258
276
  keyTimes?: number[];
259
277
  /** One curve per segment, so one fewer than `values`; linear when left
@@ -321,7 +339,7 @@ export interface Sprite {
321
339
  /** What the layer shows when no animation runs on it; 1 when left out. */
322
340
  opacity?: number;
323
341
  /** …and the transform, about `origin`; the identity when left out. */
324
- transform?: SpriteMatrix;
342
+ transform?: SpriteMatrix | SpriteMatrix3D;
325
343
  /** Device pixels, window coordinates; `rect`'s centre when left out. */
326
344
  origin?: { x: number; y: number };
327
345
  animations?: SpriteAnimation[];
@@ -25,9 +25,12 @@ export class NodeSprites {
25
25
  * - `version`: compared with `===` from frame to frame; a change paints
26
26
  * the part again. Left out, it is painted once.
27
27
  * - `opacity`, and `transform` — CSS's `matrix(a, b, c, d, e, f)` as an
28
- * array, `e` and `f` in device pixels — about `origin`, a point in
29
- * device pixels that defaults to `rect`'s centre: the values the layer
30
- * shows when no animation is running on it.
28
+ * array, `e` and `f` in device pixels, or `matrix3d()`'s sixteen
29
+ * numbers for a part turned out of the plane and seen in a perspective
30
+ * — about `origin`, a point in device pixels that defaults to `rect`'s
31
+ * centre: the values the layer shows when no animation is running on
32
+ * it. A matrix3d part is lifted only where none of its corners is behind
33
+ * the viewer, at rest or in any keyframe.
31
34
  * - `animations`: what the render server runs on it, each `{ id,
32
35
  * property: 'opacity' | 'transform', values, keyTimes, timings,
33
36
  * duration, delay, repeat, autoreverse, hold }`, with times in