react-x11 2.36.0 → 2.38.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 +1 -1
- package/src/appearance.js +48 -15
- package/src/appearancehooks.js +1 -1
- package/src/cocoa/app.js +36 -3
- package/src/cocoa/panehost.js +42 -1
- package/src/cocoa/paneplayer.js +336 -0
- package/src/cocoa/player.js +40 -30
- package/src/cocoa/promotion.js +56 -3
- package/src/frame/childmain.js +8 -4
- package/src/frame/index.js +5 -0
- package/src/frame/protocol.js +1 -1
- package/src/testing/harness.js +148 -9
- package/src/testing/index.d.ts +46 -1
- package/src/testing/index.js +1 -0
- package/src/types/appearance.d.ts +2 -2
package/package.json
CHANGED
package/src/appearance.js
CHANGED
|
@@ -135,12 +135,22 @@ const SAME = (a, b) =>
|
|
|
135
135
|
* and a re-render per announcement would be churn with nothing behind it.
|
|
136
136
|
*/
|
|
137
137
|
function publish(values, source) {
|
|
138
|
+
// **A pin is a pin.** A rung can answer long after it was asked — the
|
|
139
|
+
// ladder a test started before releasing its pin, a watcher's next line, a
|
|
140
|
+
// portal signal — and its answer lands in whatever test is running by
|
|
141
|
+
// then. Taken, that turns the next test's pixels into this machine's
|
|
142
|
+
// desktop, which is the one thing the pin exists to prevent.
|
|
143
|
+
if (owner === 'test' && source !== 'test') return;
|
|
138
144
|
const next = Object.freeze({ ...NOTHING, ...values, source });
|
|
139
145
|
if (SAME(next, snapshot)) return;
|
|
140
146
|
snapshot = next;
|
|
141
|
-
// What came *off* the disk does
|
|
142
|
-
// value must never reach a developer's
|
|
143
|
-
|
|
147
|
+
// Only what a rung answered is remembered. What came *off* the disk does
|
|
148
|
+
// not go back onto it, a pinned test value must never reach a developer's
|
|
149
|
+
// real cache file — and nor must the defaults a released pin falls back
|
|
150
|
+
// to (`source: null`), which every `cleanup()` publishes: written, they
|
|
151
|
+
// replaced the developer's remembered desktop after every test, and their
|
|
152
|
+
// next app drew its first frame in the wrong colours.
|
|
153
|
+
if (source && source !== 'cache' && source !== 'test') save(next);
|
|
144
154
|
for (const fn of [...watchers]) {
|
|
145
155
|
try {
|
|
146
156
|
fn(snapshot);
|
|
@@ -162,27 +172,42 @@ function publish(values, source) {
|
|
|
162
172
|
* Pass `null` to release the pin and let the ladder run again.
|
|
163
173
|
*/
|
|
164
174
|
export function setAppearanceForTests(values) {
|
|
165
|
-
probe = null;
|
|
166
|
-
// Never touch the developer's own remembered answer either, in either
|
|
167
|
-
// direction: `publish` will not write a pinned value, and marking the cache
|
|
168
|
-
// as already checked keeps `load()` from reading one back over it.
|
|
169
|
-
cacheChecked = true;
|
|
170
175
|
if (values === null) {
|
|
171
|
-
cacheChecked = false;
|
|
172
176
|
// Releasing undoes a *pin*, and only a pin. A real rung's answer is not
|
|
173
177
|
// this function's to throw away: `cleanup()` releases after every test,
|
|
174
178
|
// and a suite that resolved the appearance for real would otherwise find
|
|
175
|
-
// it wiped by the harness that was meant to leave it alone
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
179
|
+
// it wiped by the harness that was meant to leave it alone — or, with
|
|
180
|
+
// the cache marked unread, replaced on the next read by what the disk
|
|
181
|
+
// says, which is a verified answer turned back into a remembered one.
|
|
182
|
+
if (owner !== 'test') return;
|
|
183
|
+
owner = null;
|
|
184
|
+
probe = null;
|
|
185
|
+
// Unpinned, the remembered answer is this machine's to read again —
|
|
186
|
+
// intact, because neither the pin nor this release is ever written
|
|
187
|
+
// (`publish`).
|
|
188
|
+
cacheChecked = false;
|
|
189
|
+
publish({}, null);
|
|
180
190
|
return;
|
|
181
191
|
}
|
|
192
|
+
probe = null;
|
|
193
|
+
// Never touch the developer's own remembered answer either, in either
|
|
194
|
+
// direction: `publish` will not write a pinned value, and marking the cache
|
|
195
|
+
// as already checked keeps `load()` from reading one back over it.
|
|
196
|
+
cacheChecked = true;
|
|
182
197
|
owner = 'test';
|
|
183
198
|
publish(values, 'test');
|
|
184
199
|
}
|
|
185
200
|
|
|
201
|
+
/**
|
|
202
|
+
* What is pinned right now, or null where nothing is. `react-x11/test`'s
|
|
203
|
+
* `setAppearance` merges over it, so changing one value keeps the rest of the
|
|
204
|
+
* pin — and asks the store rather than keeping a copy, which a direct
|
|
205
|
+
* `setAppearanceForTests` call would leave stale.
|
|
206
|
+
*/
|
|
207
|
+
export function pinnedAppearanceForTests() {
|
|
208
|
+
return owner === 'test' ? snapshot : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
186
211
|
// --------------------------------------------------------------------------
|
|
187
212
|
// The last known answer
|
|
188
213
|
// --------------------------------------------------------------------------
|
|
@@ -929,6 +954,11 @@ async function runWatcher() {
|
|
|
929
954
|
answered = true;
|
|
930
955
|
publish(values, 'macos');
|
|
931
956
|
done(true);
|
|
957
|
+
// Refused, because a test pinned the store while this was starting
|
|
958
|
+
// (`publish`): nothing will hear this watcher again, and while it
|
|
959
|
+
// lives the rung starts no other — so the ladder that runs once the
|
|
960
|
+
// pin is released would find the Mac silent.
|
|
961
|
+
if (owner === 'test') proc.kill();
|
|
932
962
|
}
|
|
933
963
|
};
|
|
934
964
|
proc.stdout.setEncoding?.('utf8');
|
|
@@ -1020,7 +1050,10 @@ async function runLadder(app) {
|
|
|
1020
1050
|
answered = false;
|
|
1021
1051
|
}
|
|
1022
1052
|
if (answered) {
|
|
1023
|
-
|
|
1053
|
+
// A pin set while this rung was being asked keeps the store: the
|
|
1054
|
+
// rung's answer was refused (`publish`), and owning it would hand the
|
|
1055
|
+
// rung's live updates a store they must not write to.
|
|
1056
|
+
if (owner !== 'test') owner = name;
|
|
1024
1057
|
return snapshot;
|
|
1025
1058
|
}
|
|
1026
1059
|
}
|
package/src/appearancehooks.js
CHANGED
|
@@ -73,7 +73,7 @@ export function useAppearanceWhen(enabled) {
|
|
|
73
73
|
* | `selection` | the fill under a selected menu or list row — a darker cut of the accent on macOS — or **null** |
|
|
74
74
|
* | `contrast` | `'normal'` or `'high'` |
|
|
75
75
|
* | `reducedMotion` | `true` when the user asked for less animation |
|
|
76
|
-
* | `source` | `'portal'`, `'xsettings'`, `'macos'`, `'cache'`, or null |
|
|
76
|
+
* | `source` | `'portal'`, `'xsettings'`, `'macos'`, `'windows'`, `'cache'`, `'test'` (pinned by `react-x11/test`), or null |
|
|
77
77
|
*
|
|
78
78
|
* **`'no-preference'` means use your own default**, not "use light". And
|
|
79
79
|
* `accent` is null far more often than not, so fall back to your own brand
|
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
|
-
|
|
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') {
|
package/src/cocoa/panehost.js
CHANGED
|
@@ -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
|
+
}
|
package/src/cocoa/player.js
CHANGED
|
@@ -24,24 +24,16 @@ export function canPlay(native) {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
* the
|
|
29
|
-
*
|
|
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
|
|
32
|
-
constructor(
|
|
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
|
|
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);
|
package/src/cocoa/promotion.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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),
|
package/src/frame/childmain.js
CHANGED
|
@@ -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
|
-
|
|
230
|
-
|
|
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 () => {
|
package/src/frame/index.js
CHANGED
|
@@ -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) {
|
package/src/frame/protocol.js
CHANGED
|
@@ -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/testing/harness.js
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
import React from 'react';
|
|
11
11
|
import { createRoot } from '../Reconciler.js';
|
|
12
12
|
import { setAnimationClock } from '../nodes/animation.js';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
pinnedAppearanceForTests,
|
|
15
|
+
setAppearanceForTests,
|
|
16
|
+
} from '../appearance.js';
|
|
14
17
|
|
|
15
18
|
// A test process must not register with the desktop's live AT-SPI registry —
|
|
16
19
|
// a suite on a developer desktop would otherwise parade hundreds of phantom
|
|
@@ -140,10 +143,26 @@ export async function renderX11(element, options = {}) {
|
|
|
140
143
|
wrap = element?.type !== 'window',
|
|
141
144
|
title = 'react-x11 test',
|
|
142
145
|
colorScheme = 'light',
|
|
146
|
+
appearance = null,
|
|
143
147
|
a11y = false,
|
|
144
148
|
...rootOptions
|
|
145
149
|
} = options;
|
|
146
150
|
|
|
151
|
+
// Checked before anything is created, so a mistake costs no server
|
|
152
|
+
const pin = appearancePin(colorScheme, appearance);
|
|
153
|
+
if (setBeforeMount) {
|
|
154
|
+
const values = setBeforeMount;
|
|
155
|
+
setBeforeMount = null;
|
|
156
|
+
throw new Error(
|
|
157
|
+
'react-x11/test: setAppearance() was called before renderX11(), which ' +
|
|
158
|
+
'pins the appearance as it mounts — the values it set would be ' +
|
|
159
|
+
'replaced without a word. Pass them to the render instead:\n\n' +
|
|
160
|
+
` await renderX11(<App />, { appearance: ${JSON.stringify(values)} });\n\n` +
|
|
161
|
+
'setAppearance() is for changing them once the tree is mounted. ' +
|
|
162
|
+
'See docs/testing.md#the-desktops-appearance.',
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
147
166
|
let server = null;
|
|
148
167
|
let app = options.app ?? null;
|
|
149
168
|
if (!app) {
|
|
@@ -161,14 +180,9 @@ export async function renderX11(element, options = {}) {
|
|
|
161
180
|
// on a light desktop, red on a dark one, with nothing in the test saying
|
|
162
181
|
// so. After the app exists, so it wins over `createMockApp`'s own pin.
|
|
163
182
|
// `colorScheme: 'dark'` renders the other palette; `'system'` releases the
|
|
164
|
-
// pin, for a test that is *about* what the desktop reports
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
? null
|
|
168
|
-
: colorScheme === 'dark'
|
|
169
|
-
? { colorScheme: 'dark' }
|
|
170
|
-
: {},
|
|
171
|
-
);
|
|
183
|
+
// pin, for a test that is *about* what the desktop reports; `appearance`
|
|
184
|
+
// pins the rest of what `useSystemAppearance()` reports.
|
|
185
|
+
setAppearanceForTests(pin);
|
|
172
186
|
|
|
173
187
|
const root = await createRoot({ app, ...rootOptions });
|
|
174
188
|
|
|
@@ -374,6 +388,131 @@ export async function cleanup() {
|
|
|
374
388
|
// change, every mounted root repaints on one, and a repaint scheduled onto
|
|
375
389
|
// a connection that is about to close lands after it has.
|
|
376
390
|
setAppearanceForTests(null);
|
|
391
|
+
setBeforeMount = null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// --- the desktop's appearance ----------------------------------------------
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* What a test may pin, and what each value may be — the fields
|
|
398
|
+
* `useSystemAppearance()` reports, less `source`, which reads `'test'` for as
|
|
399
|
+
* long as anything is pinned.
|
|
400
|
+
*
|
|
401
|
+
* Checked rather than passed through, because the store takes anything: a
|
|
402
|
+
* `reduceMotion: true` would pin nothing, render the component the way it
|
|
403
|
+
* always does, and pass a test that was meant to prove the opposite.
|
|
404
|
+
*/
|
|
405
|
+
const APPEARANCE = {
|
|
406
|
+
colorScheme: [
|
|
407
|
+
(v) => v === 'light' || v === 'dark' || v === 'no-preference',
|
|
408
|
+
"'light', 'dark' or 'no-preference'",
|
|
409
|
+
],
|
|
410
|
+
accent: [colourOrNull, "a CSS colour such as '#ed5b00', or null"],
|
|
411
|
+
accentText: [colourOrNull, "a CSS colour such as '#ffffff', or null"],
|
|
412
|
+
selection: [colourOrNull, "a CSS colour such as '#c96003', or null"],
|
|
413
|
+
palette: [
|
|
414
|
+
(v) => v === null || (typeof v === 'object' && !Array.isArray(v)),
|
|
415
|
+
'an object of palette tokens, or null',
|
|
416
|
+
],
|
|
417
|
+
contrast: [(v) => v === 'normal' || v === 'high', "'normal' or 'high'"],
|
|
418
|
+
reducedMotion: [(v) => typeof v === 'boolean', 'true or false'],
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
function colourOrNull(value) {
|
|
422
|
+
return value === null || (typeof value === 'string' && value !== '');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function checkAppearance(values, where) {
|
|
426
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)) {
|
|
427
|
+
throw new TypeError(
|
|
428
|
+
`react-x11/test: ${where} takes an object of appearance values, such ` +
|
|
429
|
+
`as { reducedMotion: true } — got ${String(values)}.`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
for (const [key, value] of Object.entries(values)) {
|
|
433
|
+
if (key === 'source') {
|
|
434
|
+
throw new TypeError(
|
|
435
|
+
`react-x11/test: ${where} cannot set \`source\` — a pinned ` +
|
|
436
|
+
"appearance reports 'test', which is how a component can tell " +
|
|
437
|
+
'one from a desktop that answered.',
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
const rule = APPEARANCE[key];
|
|
441
|
+
if (!rule) {
|
|
442
|
+
throw new TypeError(
|
|
443
|
+
`react-x11/test: ${where} has no appearance value \`${key}\`. ` +
|
|
444
|
+
`The values are ${Object.keys(APPEARANCE).join(', ')} — the ` +
|
|
445
|
+
'fields useSystemAppearance() reports.',
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
if (!rule[0](value)) {
|
|
449
|
+
throw new TypeError(
|
|
450
|
+
`react-x11/test: ${where}: \`${key}\` is ${rule[1]}, not ` +
|
|
451
|
+
`${JSON.stringify(value) ?? String(value)}.`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return values;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** `renderX11`'s two appearance options → the pin, or null to release. */
|
|
459
|
+
function appearancePin(colorScheme, appearance) {
|
|
460
|
+
if (colorScheme === 'system') {
|
|
461
|
+
if (appearance) {
|
|
462
|
+
throw new TypeError(
|
|
463
|
+
"react-x11/test: renderX11 was given colorScheme: 'system', which " +
|
|
464
|
+
'follows the real desktop, and `appearance`, which pins it. Pass ' +
|
|
465
|
+
'one: pin `colorScheme` inside `appearance`, or drop `appearance` ' +
|
|
466
|
+
'to follow the desktop.',
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
if (colorScheme !== 'light' && colorScheme !== 'dark') {
|
|
472
|
+
throw new TypeError(
|
|
473
|
+
"react-x11/test: renderX11's colorScheme is 'light' (the default), " +
|
|
474
|
+
`'dark' or 'system', not ${JSON.stringify(colorScheme)}.`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
// Light is pinned as "the desktop said nothing", which resolves to the
|
|
479
|
+
// light palette and is what the mock app pins too
|
|
480
|
+
...(colorScheme === 'dark' ? { colorScheme: 'dark' } : {}),
|
|
481
|
+
...(appearance ? checkAppearance(appearance, 'renderX11 appearance') : {}),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Values set with nothing mounted, which the next `renderX11` would lose. */
|
|
486
|
+
let setBeforeMount = null;
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Change the pinned appearance under a mounted tree, and flush what it
|
|
490
|
+
* changed — the re-render of everything reading `useSystemAppearance()`, the
|
|
491
|
+
* palette's repaint, and the frame — the way `userEvent` flushes an input.
|
|
492
|
+
*
|
|
493
|
+
* Merged over what is pinned: `setAppearance({ reducedMotion: true })` under
|
|
494
|
+
* a `colorScheme: 'dark'` render stays dark. Where nothing is pinned
|
|
495
|
+
* (`colorScheme: 'system'`), it pins these values over the defaults rather
|
|
496
|
+
* than over this machine's answer, so the result is the test's alone.
|
|
497
|
+
*
|
|
498
|
+
* ```js
|
|
499
|
+
* await renderX11(<Spinner />, { appearance: { reducedMotion: true } });
|
|
500
|
+
* await setAppearance({ reducedMotion: false });
|
|
501
|
+
* ```
|
|
502
|
+
*
|
|
503
|
+
* The appearance is the desktop's, so it is one for the process: every
|
|
504
|
+
* mounted root sees the change, as every window would. `cleanup()` releases
|
|
505
|
+
* it, and a pinned value is never written to the cache the next run of a
|
|
506
|
+
* real app reads.
|
|
507
|
+
*/
|
|
508
|
+
export async function setAppearance(values) {
|
|
509
|
+
checkAppearance(values, 'setAppearance()');
|
|
510
|
+
// `source` comes along and does not matter: the pin overwrites it
|
|
511
|
+
const next = { ...pinnedAppearanceForTests(), ...values };
|
|
512
|
+
if (mounted.size === 0) setBeforeMount = { ...setBeforeMount, ...values };
|
|
513
|
+
await act(() => {
|
|
514
|
+
setAppearanceForTests(next);
|
|
515
|
+
});
|
|
377
516
|
}
|
|
378
517
|
|
|
379
518
|
// --- the animation clock ---------------------------------------------------
|
package/src/testing/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { ReactNode } from 'react';
|
|
7
7
|
import type { DrawnNode, NtkApp, NtkWindow } from '../types/nodes.js';
|
|
8
|
+
import type { SystemAppearance } from '../types/appearance.js';
|
|
8
9
|
import type { ModifierName } from '../keysyms.js';
|
|
9
10
|
|
|
10
11
|
export * from '../keysyms.js';
|
|
@@ -29,6 +30,14 @@ export interface TestServer {
|
|
|
29
30
|
/** ntk's 2d context. Pixel helpers take it; `renderX11` hands it back. */
|
|
30
31
|
export type TestContext2D = unknown;
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The desktop's appearance as a test pins it: any of the values
|
|
35
|
+
* `useSystemAppearance()` reports, except `source`, which reads `'test'` for
|
|
36
|
+
* as long as anything is pinned. A key outside these throws, so a typo cannot
|
|
37
|
+
* pin nothing and pass.
|
|
38
|
+
*/
|
|
39
|
+
export type PinnedAppearance = Partial<Omit<SystemAppearance, 'source'>>;
|
|
40
|
+
|
|
32
41
|
export interface RenderX11Options {
|
|
33
42
|
/** Window size (default 640×480). */
|
|
34
43
|
width?: number;
|
|
@@ -59,6 +68,21 @@ export interface RenderX11Options {
|
|
|
59
68
|
title?: string;
|
|
60
69
|
/** Render into a connection you already have. */
|
|
61
70
|
app?: NtkApp;
|
|
71
|
+
/**
|
|
72
|
+
* The desktop's colour scheme, **pinned** so a pixel assertion means the
|
|
73
|
+
* same on every machine. `'light'` (the default) and `'dark'` pick the
|
|
74
|
+
* built-in palette; `'system'` releases the pin, for a test that is about
|
|
75
|
+
* what the real desktop reports.
|
|
76
|
+
*/
|
|
77
|
+
colorScheme?: 'light' | 'dark' | 'system';
|
|
78
|
+
/**
|
|
79
|
+
* The rest of the desktop's appearance, pinned from the first render:
|
|
80
|
+
* reduced motion, contrast, the accent. Merged over `colorScheme`, so
|
|
81
|
+
* `appearance.colorScheme` wins where both name one; it cannot be combined
|
|
82
|
+
* with `colorScheme: 'system'`. To change it while mounted, use
|
|
83
|
+
* `setAppearance()`.
|
|
84
|
+
*/
|
|
85
|
+
appearance?: PinnedAppearance;
|
|
62
86
|
/**
|
|
63
87
|
* Install the assistive-technology spy before the mount and hand it back
|
|
64
88
|
* as `at`: an in-process log of everything a screen reader would have
|
|
@@ -288,9 +312,30 @@ export function renderX11(
|
|
|
288
312
|
*/
|
|
289
313
|
export function act(fn?: () => unknown): Promise<void>;
|
|
290
314
|
|
|
291
|
-
/**
|
|
315
|
+
/**
|
|
316
|
+
* Unmount everything, restore the animation clock, release the pinned
|
|
317
|
+
* appearance, close every server.
|
|
318
|
+
*/
|
|
292
319
|
export function cleanup(): Promise<void>;
|
|
293
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Change the pinned appearance under a mounted tree and flush what it moved:
|
|
323
|
+
* every component reading `useSystemAppearance()` re-renders, the palette
|
|
324
|
+
* repaints, and the frame lands — the way `userEvent` flushes an input.
|
|
325
|
+
*
|
|
326
|
+
* Merged over what is pinned, so `setAppearance({ reducedMotion: true })`
|
|
327
|
+
* under a `colorScheme: 'dark'` render stays dark. The appearance is the
|
|
328
|
+
* desktop's and so one per process: every mounted root sees the change.
|
|
329
|
+
* Calling it before `renderX11()` is an error at that render, which pins as
|
|
330
|
+
* it mounts — pass `appearance` to the render instead.
|
|
331
|
+
*
|
|
332
|
+
* ```ts
|
|
333
|
+
* await renderX11(<Spinner />, { appearance: { reducedMotion: true } });
|
|
334
|
+
* await setAppearance({ reducedMotion: false });
|
|
335
|
+
* ```
|
|
336
|
+
*/
|
|
337
|
+
export function setAppearance(values: PinnedAppearance): Promise<void>;
|
|
338
|
+
|
|
294
339
|
/** Drain in-flight requests on a connection. */
|
|
295
340
|
export function settle(app: NtkApp, roundTrips?: number): Promise<void>;
|
|
296
341
|
|
package/src/testing/index.js
CHANGED
|
@@ -20,10 +20,10 @@ export type ColorSchemePreference = 'system' | 'light' | 'dark';
|
|
|
20
20
|
*
|
|
21
21
|
* `'cache'` is the answer this machine gave last time, read off disk before
|
|
22
22
|
* the first render so it does not start from the defaults. A real rung
|
|
23
|
-
* replaces it a moment later.
|
|
23
|
+
* replaces it a moment later. `'test'` is a value `react-x11/test` pinned.
|
|
24
24
|
*/
|
|
25
25
|
export type AppearanceSource =
|
|
26
|
-
'portal' | 'xsettings' | 'macos' | 'cache' | null;
|
|
26
|
+
'portal' | 'xsettings' | 'macos' | 'windows' | 'cache' | 'test' | null;
|
|
27
27
|
|
|
28
28
|
export interface SystemAppearance {
|
|
29
29
|
readonly colorScheme: ColorScheme;
|