react-x11 2.45.1 → 2.45.3
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/cocoa/app.js +45 -1
- package/src/cocoa/fonts.js +25 -0
- package/src/cocoa/panewindow.js +17 -0
- package/src/cocoa/relaunch.js +137 -16
- package/src/cocoa/threaded.js +2 -59
- package/src/events.js +46 -1
- package/src/frame/childmain.js +7 -2
- package/src/frame/index.js +110 -35
- package/src/nodes/window/listeners.js +40 -0
- package/src/testing/events.js +122 -16
- package/src/testing/harness.js +111 -5
- package/src/testing/index.d.ts +18 -2
- package/src/testing/queries.js +18 -5
- package/src/win32/app.js +36 -1
- package/src/win32/panewindow.js +8 -0
package/package.json
CHANGED
package/src/cocoa/app.js
CHANGED
|
@@ -982,9 +982,12 @@ export class CocoaApp {
|
|
|
982
982
|
* pane-present and pane-cursor go out. `players` is the
|
|
983
983
|
* host's word that it plays the pane's `<video src>`s, which makes
|
|
984
984
|
* `createPlayer` here — before the pane's first render, which asks.
|
|
985
|
+
* `displayScale` is the scale the host's window is drawn at, taken here
|
|
986
|
+
* for the same reason (`adoptPaneScale`).
|
|
985
987
|
*/
|
|
986
|
-
attachPaneChannel(channel, { players = false } = {}) {
|
|
988
|
+
attachPaneChannel(channel, { players = false, displayScale } = {}) {
|
|
987
989
|
if (!this._paneMode) return;
|
|
990
|
+
this.adoptPaneScale(displayScale);
|
|
988
991
|
this._paneSend = (msg) => {
|
|
989
992
|
try {
|
|
990
993
|
channel.send(msg);
|
|
@@ -1013,6 +1016,7 @@ export class CocoaApp {
|
|
|
1013
1016
|
const wnd = msg.window != null ? this._windows.get(msg.window) : pane;
|
|
1014
1017
|
wnd?._shown?.(msg.seq);
|
|
1015
1018
|
} else if (msg?.type === 'pane-rect') {
|
|
1019
|
+
this.adoptPaneScale(msg.displayScale);
|
|
1016
1020
|
pane.setPaneSize(msg.width, msg.height, msg.scale, msg.screen);
|
|
1017
1021
|
if (typeof msg.live === 'boolean') pane.setLive(msg.live);
|
|
1018
1022
|
this._flushPaneOutbox();
|
|
@@ -1039,6 +1043,46 @@ export class CocoaApp {
|
|
|
1039
1043
|
});
|
|
1040
1044
|
}
|
|
1041
1045
|
|
|
1046
|
+
/**
|
|
1047
|
+
* The scale of the window a pane is shown in, as the host says it — in
|
|
1048
|
+
* the hello and in every pane-rect — made this process's. Returns whether
|
|
1049
|
+
* it moved.
|
|
1050
|
+
*
|
|
1051
|
+
* A pane process reads the screens when it starts, and the host read
|
|
1052
|
+
* them when it did. Across a change of the desk between the two the
|
|
1053
|
+
* answers differ: a browser's tabs drawn at 2x and a page forked for a
|
|
1054
|
+
* new tab after the desk went to 1x — a mirror set for a projector — was
|
|
1055
|
+
* laid out at 1x in a frame the host showed at 2x, every length in it
|
|
1056
|
+
* half what it is beside the tabs, and every click it was sent landing
|
|
1057
|
+
* at twice the distance. A pane has no window on a screen of its own;
|
|
1058
|
+
* the host's window is its display, and its scale is the one everything
|
|
1059
|
+
* here multiplies by: the layout, the fonts, the windows the pane opens,
|
|
1060
|
+
* the screen layout their popups are placed against.
|
|
1061
|
+
*
|
|
1062
|
+
* From the hello that is all, before anything is laid out. A tree
|
|
1063
|
+
* already laid out is restyled in the new unit, as a `scale` prop that
|
|
1064
|
+
* moved restyles a subtree. The pane's own window keeps the scale its
|
|
1065
|
+
* pane-rect gives it (`setPaneSize`): that one is the frame's, a
|
|
1066
|
+
* `<box scale>` around the `<Frame>` included, and says how many device
|
|
1067
|
+
* pixels the rect is.
|
|
1068
|
+
*/
|
|
1069
|
+
adoptPaneScale(scale) {
|
|
1070
|
+
if (!this._paneMode || !(scale > 0) || !Number.isFinite(scale)) {
|
|
1071
|
+
return false;
|
|
1072
|
+
}
|
|
1073
|
+
if (scale === this.scale) return false;
|
|
1074
|
+
this.scale = scale;
|
|
1075
|
+
setScaleForTests(this, scale, 'frame');
|
|
1076
|
+
setScreensForTests(this, screenLayout(this._screens, scale));
|
|
1077
|
+
this.fonts?.setScale?.(scale);
|
|
1078
|
+
for (const wnd of this._windows.values()) {
|
|
1079
|
+
if (wnd.destroyed) continue;
|
|
1080
|
+
if (wnd !== this._paneWindow) wnd.scale = scale;
|
|
1081
|
+
wnd._reactX11Node?._rescaleSubtree();
|
|
1082
|
+
}
|
|
1083
|
+
return true;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1042
1086
|
/**
|
|
1043
1087
|
* A message about one of the pane's other windows (`CocoaPaneSubwindow`),
|
|
1044
1088
|
* held until the host's first pane-rect. The host hears a pane's messages
|
package/src/cocoa/fonts.js
CHANGED
|
@@ -771,6 +771,31 @@ export class CocoaFontManager {
|
|
|
771
771
|
this._typesetterUnits = 0;
|
|
772
772
|
}
|
|
773
773
|
|
|
774
|
+
/**
|
|
775
|
+
* Make fonts at another scale from here on — a `<Frame>` pane taking the
|
|
776
|
+
* host's (`CocoaApp.adoptPaneScale`). Every handle made at the old one is
|
|
777
|
+
* let go: a size is in device pixels, so the same size at another scale
|
|
778
|
+
* is another point size, which is another optical size of San Francisco
|
|
779
|
+
* and another tracking. What the app loaded stays loaded.
|
|
780
|
+
*/
|
|
781
|
+
setScale(scale) {
|
|
782
|
+
const next = scale > 0 && Number.isFinite(scale) ? scale : 1;
|
|
783
|
+
if (next === this._scale) return;
|
|
784
|
+
this._scale = next;
|
|
785
|
+
this._scaleArgs = next === 1 ? [] : [next];
|
|
786
|
+
this._fonts.clear();
|
|
787
|
+
this._faces.clear();
|
|
788
|
+
this._sized.clear();
|
|
789
|
+
this._layouts.clear();
|
|
790
|
+
// a face CoreText chose sizes itself by copying the handle it arrived
|
|
791
|
+
// as, and a copy keeps the scale it was made at
|
|
792
|
+
this._faceByPs.clear();
|
|
793
|
+
for (const faces of this._registered.values()) {
|
|
794
|
+
for (const reg of faces) reg.face = null;
|
|
795
|
+
}
|
|
796
|
+
this._forgetTypesetters();
|
|
797
|
+
}
|
|
798
|
+
|
|
774
799
|
/**
|
|
775
800
|
* A paragraph's typesetter, kept from a layout of the same text at any
|
|
776
801
|
* width. Two thirds of a layout is the text becoming glyphs — the
|
package/src/cocoa/panewindow.js
CHANGED
|
@@ -188,6 +188,17 @@ export class CocoaPaneWindow {
|
|
|
188
188
|
this._post({ type: 'pane-cursor', cursor: name ?? null });
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Tab off an end of the pane's order, handed to the host, which goes on
|
|
193
|
+
* from the `<Frame>`'s box as from any stop of its own
|
|
194
|
+
* (`EventManager._tabOut`). Answers whether anyone was there to take it.
|
|
195
|
+
*/
|
|
196
|
+
tabOut(backwards) {
|
|
197
|
+
if (this.destroyed || !this.app._paneSend) return false;
|
|
198
|
+
this._post({ type: 'pane-tab-out', backwards });
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
191
202
|
/** A message about this window, to the host. The pane's own names none:
|
|
192
203
|
* the host knows it as the pane (`CocoaPaneSubwindow` names itself). */
|
|
193
204
|
_post(msg) {
|
|
@@ -733,6 +744,12 @@ export class CocoaPaneSubwindow extends CocoaPaneWindow {
|
|
|
733
744
|
this.app._postWindow({ ...msg, window: this.windowId });
|
|
734
745
|
}
|
|
735
746
|
|
|
747
|
+
/** A window the pane makes besides its own is no stop in the host's
|
|
748
|
+
* order: Tab goes round inside it, as in any window. */
|
|
749
|
+
tabOut() {
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
|
|
736
753
|
_send(msg) {
|
|
737
754
|
if (this.destroyed) return;
|
|
738
755
|
this._post({ type: 'pane-window', ...msg });
|
package/src/cocoa/relaunch.js
CHANGED
|
@@ -29,18 +29,113 @@ import {
|
|
|
29
29
|
bindRelaunchState,
|
|
30
30
|
forwardSignals,
|
|
31
31
|
installStdio,
|
|
32
|
-
onWorkerEnd,
|
|
33
32
|
openThreadedChannel,
|
|
34
|
-
printUncaught,
|
|
35
33
|
RELAUNCH,
|
|
36
34
|
routeExit,
|
|
37
|
-
signalEnded,
|
|
38
35
|
} from './threaded.js';
|
|
39
36
|
|
|
40
37
|
// Set once the worker is set up, process-wide: the explicit launcher and
|
|
41
38
|
// the package's own import can both reach `bootstrapWorker`, and from two
|
|
42
39
|
// copies of the package the bridge would refuse the second `connect`.
|
|
43
|
-
const
|
|
40
|
+
const BOOTED_KEY = 'react-x11.cocoa.workerBooted';
|
|
41
|
+
const BOOTED = Symbol.for(BOOTED_KEY);
|
|
42
|
+
|
|
43
|
+
// Printed above an error that ended the worker before its import of
|
|
44
|
+
// react-x11 had run there: thrown by code the main thread had run without
|
|
45
|
+
// a complaint a moment before, which is all the developer has to go on.
|
|
46
|
+
const STOPPED_EARLY =
|
|
47
|
+
'react-x11: the app stopped on its worker thread before reaching its ' +
|
|
48
|
+
'import of react-x11 there. On macOS react-x11 runs the app on a worker ' +
|
|
49
|
+
'thread, starting the entry over on it, so what the entry imports ahead ' +
|
|
50
|
+
'of react-x11 runs twice: on the main thread, then on the worker, where ' +
|
|
51
|
+
'a call only a main thread may make — process.chdir(), say — throws. ' +
|
|
52
|
+
'Make such a call on the main thread only (isMainThread, from ' +
|
|
53
|
+
'node:worker_threads); what it changes holds for the whole process, the ' +
|
|
54
|
+
'worker included. Or keep the app on the main thread with ' +
|
|
55
|
+
'REACT_X11_THREADED=0. See docs/macos.md, "What changes for an app".';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What a worker `relaunch` starts runs before the app's entry, as the source
|
|
59
|
+
* of a function of `(process, global)`: what the worker needs from its first
|
|
60
|
+
* line, since the main thread is parked and hears nothing else from it.
|
|
61
|
+
*
|
|
62
|
+
* Its end, written into the shared state after the app's last exit
|
|
63
|
+
* listener. The main thread ends the process on it, so an app's
|
|
64
|
+
* `process.on('exit')` handler must have finished by then, as it has in a
|
|
65
|
+
* process of its own. Kept last by re-adding it whenever the app adds one:
|
|
66
|
+
* Node runs a worker's exit listeners through `process.emit` and Bun calls
|
|
67
|
+
* them directly (measured), so wrapping the emit would cover only Node.
|
|
68
|
+
*
|
|
69
|
+
* Its uncaught error, printed by itself. Node hands a worker's uncaught
|
|
70
|
+
* error to its parent as an event, and the parent here never runs its event
|
|
71
|
+
* loop again to hear it. Only when nothing handles the error: an app's own
|
|
72
|
+
* `uncaughtException` handler means it is not uncaught. Straight to fd 2,
|
|
73
|
+
* because the worker's own stderr forwards through the parked thread too
|
|
74
|
+
* until `bootstrapWorker` replaces it; a pipe the main thread made
|
|
75
|
+
* non-blocking answers EAGAIN when full, and the write waits it out.
|
|
76
|
+
*
|
|
77
|
+
* Both used to be set up by `bootstrapWorker`, when the entry reached its
|
|
78
|
+
* import of react-x11. A worker that died before then — a module imported
|
|
79
|
+
* ahead of react-x11 calling `process.chdir()`, which only a main thread
|
|
80
|
+
* may — wrote nothing into the shared state and printed nothing, and the
|
|
81
|
+
* process waited in `Atomics.wait` for ever.
|
|
82
|
+
*
|
|
83
|
+
* A string, not a function's `toString()`: a build step rewrites a function
|
|
84
|
+
* — a bundler's name helper, a coverage counter — into references the
|
|
85
|
+
* worker cannot resolve, and a throw here hangs the process the way this
|
|
86
|
+
* exists to prevent.
|
|
87
|
+
*/
|
|
88
|
+
export const WORKER_START = `(function (process, global) {
|
|
89
|
+
'use strict';
|
|
90
|
+
const { workerData } = process.getBuiltinModule('node:worker_threads');
|
|
91
|
+
const { writeSync } = process.getBuiltinModule('node:fs');
|
|
92
|
+
const { inspect } = process.getBuiltinModule('node:util');
|
|
93
|
+
const state = new Int32Array(workerData.reactX11State);
|
|
94
|
+
|
|
95
|
+
const write = (text) => {
|
|
96
|
+
const bytes = Buffer.from(text);
|
|
97
|
+
for (let off = 0; off < bytes.length; ) {
|
|
98
|
+
try {
|
|
99
|
+
off += writeSync(2, bytes, off);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err.code !== 'EAGAIN') return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
process.on('uncaughtExceptionMonitor', (err) => {
|
|
106
|
+
if (process.listenerCount('uncaughtException') > 0) return;
|
|
107
|
+
const why = global[Symbol.for(${JSON.stringify(BOOTED_KEY)})]
|
|
108
|
+
? ''
|
|
109
|
+
: ${JSON.stringify(`${STOPPED_EARLY}\n`)};
|
|
110
|
+
write(why + inspect(err) + '\\n');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const add = process.on;
|
|
114
|
+
const end = (code) => {
|
|
115
|
+
const n = Number.parseInt(code ?? process.exitCode ?? 0, 10) || 0;
|
|
116
|
+
Atomics.store(state, 1, n);
|
|
117
|
+
Atomics.store(state, 0, ${RELAUNCH.ENDED});
|
|
118
|
+
Atomics.notify(state, 0);
|
|
119
|
+
};
|
|
120
|
+
add.call(process, 'exit', end);
|
|
121
|
+
for (const name of [
|
|
122
|
+
'on',
|
|
123
|
+
'addListener',
|
|
124
|
+
'once',
|
|
125
|
+
'prependListener',
|
|
126
|
+
'prependOnceListener',
|
|
127
|
+
]) {
|
|
128
|
+
const method = process[name];
|
|
129
|
+
process[name] = function (event, listener) {
|
|
130
|
+
const out = method.call(this, event, listener);
|
|
131
|
+
if (event === 'exit' && listener !== end) {
|
|
132
|
+
process.removeListener('exit', end);
|
|
133
|
+
add.call(process, 'exit', end);
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
})`;
|
|
44
139
|
|
|
45
140
|
// How long the main thread waits, once AppKit's run is over, for the
|
|
46
141
|
// worker's own exit handlers to finish — they run to the end in a process
|
|
@@ -157,12 +252,13 @@ export function relaunchOnImport(moduleUrl) {
|
|
|
157
252
|
}
|
|
158
253
|
|
|
159
254
|
/**
|
|
160
|
-
* The worker's side, set up the moment it imports react-x11
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* the
|
|
165
|
-
*
|
|
255
|
+
* The worker's side, set up the moment it imports react-x11, on top of
|
|
256
|
+
* `WORKER_START`, which has said how the worker ends and printed what
|
|
257
|
+
* crashed it since its first line: after it the app cannot tell it is on a
|
|
258
|
+
* worker, short of asking — its logs reach the terminal, `process.exit`
|
|
259
|
+
* ends the process, and a Ctrl-C reaches its `process.on('SIGINT')`
|
|
260
|
+
* (src/cocoa/threaded.js). What the app imports before react-x11 runs
|
|
261
|
+
* before this, so a module that logs as it loads should come after it.
|
|
166
262
|
*/
|
|
167
263
|
export function bootstrapWorker(state) {
|
|
168
264
|
if (globalThis[BOOTED]) return;
|
|
@@ -170,9 +266,7 @@ export function bootstrapWorker(state) {
|
|
|
170
266
|
const native = loadNative();
|
|
171
267
|
installStdio();
|
|
172
268
|
bindRelaunchState(state);
|
|
173
|
-
printUncaught();
|
|
174
269
|
routeExit(native);
|
|
175
|
-
onWorkerEnd(signalEnded);
|
|
176
270
|
openThreadedChannel(native).subscribe(forwardSignals());
|
|
177
271
|
}
|
|
178
272
|
|
|
@@ -183,11 +277,24 @@ export function bootstrapWorker(state) {
|
|
|
183
277
|
*/
|
|
184
278
|
export function relaunch(entry, native) {
|
|
185
279
|
const state = new Int32Array(new SharedArrayBuffer(8));
|
|
186
|
-
// The worker
|
|
280
|
+
// The worker runs the app's own entry, so nothing has to ship beside it —
|
|
187
281
|
// a bundle (docs/packaging.md, tier 2) is one file, and a worker entry of
|
|
188
282
|
// the package's own would be a second one the bundler never emits. The
|
|
189
283
|
// worker sets itself up when it imports react-x11 (`bootstrapWorker`,
|
|
190
|
-
// src/bootstrap.js), recognising the shared state in its workerData
|
|
284
|
+
// src/bootstrap.js), recognising the shared state in its workerData, and
|
|
285
|
+
// runs `WORKER_START` before anything else, so that it can end the
|
|
286
|
+
// process even if it never gets that far.
|
|
287
|
+
//
|
|
288
|
+
// Node starts the worker on those lines, as `eval` code that then runs
|
|
289
|
+
// the entry the way Node runs a worker's file: `process.argv[1]` set to
|
|
290
|
+
// it, and `Module.runMain`, which runs the `--import` loaders first and
|
|
291
|
+
// makes the entry the main module, `require.main` and `import.meta.main`
|
|
292
|
+
// as before. An `import()` of the entry does neither, and an eval worker
|
|
293
|
+
// runs no `--import` of its own (measured, Node 20–26). Bun's eval worker
|
|
294
|
+
// loses both a failure of the entry and `require.main` (measured, Bun
|
|
295
|
+
// 1.4), so there the entry stays the worker's own and the lines go in as
|
|
296
|
+
// its preload, base64-encoded: percent-encoded, Bun skipped them without
|
|
297
|
+
// an error.
|
|
191
298
|
//
|
|
192
299
|
// No `execArgv`: a Worker given none inherits the node flags the process
|
|
193
300
|
// started with, and among them a loader like `--import tsx`, which is what
|
|
@@ -196,11 +303,25 @@ export function relaunch(entry, native) {
|
|
|
196
303
|
// `--expose-gc`, `--max-old-space-size`, `--title` — which the worker
|
|
197
304
|
// would have had anyway, since those hold for every thread: it gets `gc`
|
|
198
305
|
// either way (measured, Node 20–26; Bun treats the two the same).
|
|
199
|
-
|
|
306
|
+
const options = {
|
|
200
307
|
argv: process.argv.slice(2),
|
|
201
308
|
env: SHARE_ENV,
|
|
202
309
|
workerData: { reactX11State: state.buffer },
|
|
203
|
-
}
|
|
310
|
+
};
|
|
311
|
+
const start = `${WORKER_START}(process, globalThis);\n`;
|
|
312
|
+
if (globalThis.Bun) {
|
|
313
|
+
const source = Buffer.from(start).toString('base64');
|
|
314
|
+
new Worker(entry, {
|
|
315
|
+
...options,
|
|
316
|
+
preload: [`data:text/javascript;base64,${source}`],
|
|
317
|
+
});
|
|
318
|
+
} else {
|
|
319
|
+
new Worker(
|
|
320
|
+
`${start}process.argv[1] = ${JSON.stringify(entry)};\n` +
|
|
321
|
+
"process.getBuiltinModule('node:module').runMain(process.argv[1]);\n",
|
|
322
|
+
{ ...options, eval: true },
|
|
323
|
+
);
|
|
324
|
+
}
|
|
204
325
|
for (;;) {
|
|
205
326
|
Atomics.wait(state, 0, RELAUNCH.WAITING);
|
|
206
327
|
const now = Atomics.load(state, 0);
|
package/src/cocoa/threaded.js
CHANGED
|
@@ -13,13 +13,13 @@ import fs from 'node:fs';
|
|
|
13
13
|
import { constants } from 'node:os';
|
|
14
14
|
import { Writable } from 'node:stream';
|
|
15
15
|
import tty from 'node:tty';
|
|
16
|
-
import { inspect } from 'node:util';
|
|
17
16
|
|
|
18
17
|
let channel = null;
|
|
19
18
|
|
|
20
19
|
// The relaunch's shared state (src/cocoa/relaunch.js), [state, exit code]:
|
|
21
20
|
// the main thread waits on it, without AppKit, until the worker says what
|
|
22
|
-
// it needs — AppKit, or nothing more because it is done
|
|
21
|
+
// it needs — AppKit, or nothing more because it is done, which the worker
|
|
22
|
+
// says from the first line it runs (`WORKER_START`).
|
|
23
23
|
export const RELAUNCH = Object.freeze({ WAITING: 0, APPKIT: 1, ENDED: 2 });
|
|
24
24
|
let relaunchState = null;
|
|
25
25
|
|
|
@@ -44,63 +44,6 @@ export async function requestAppKit(native, state = relaunchState) {
|
|
|
44
44
|
while (!native.threaded()) await new Promise((r) => setTimeout(r, 1));
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/** The worker is done, with `code`: the main thread ends the process. */
|
|
48
|
-
export function signalEnded(code, state = relaunchState) {
|
|
49
|
-
if (!state) return;
|
|
50
|
-
Atomics.store(state, 1, Number.parseInt(code, 10) || 0);
|
|
51
|
-
Atomics.store(state, 0, RELAUNCH.ENDED);
|
|
52
|
-
Atomics.notify(state, 0);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Call `fn(code)` when the worker exits, after every exit listener the app
|
|
57
|
-
* has — its last word. The main thread ends the process on it, and an app's
|
|
58
|
-
* `process.on('exit')` handler must have finished by then, as it has in a
|
|
59
|
-
* process of its own. Kept last by re-adding it whenever the app adds one:
|
|
60
|
-
* Node runs a worker's exit listeners through `process.emit` and Bun calls
|
|
61
|
-
* them directly (measured), so wrapping the emit would cover only Node.
|
|
62
|
-
*/
|
|
63
|
-
export function onWorkerEnd(fn, proc = process) {
|
|
64
|
-
const add = proc.on;
|
|
65
|
-
const last = (code) => fn(code ?? proc.exitCode ?? 0);
|
|
66
|
-
add.call(proc, 'exit', last);
|
|
67
|
-
for (const name of [
|
|
68
|
-
'on',
|
|
69
|
-
'addListener',
|
|
70
|
-
'once',
|
|
71
|
-
'prependListener',
|
|
72
|
-
'prependOnceListener',
|
|
73
|
-
]) {
|
|
74
|
-
const method = proc[name];
|
|
75
|
-
proc[name] = function (event, listener) {
|
|
76
|
-
const out = method.call(this, event, listener);
|
|
77
|
-
if (event === 'exit' && listener !== last) {
|
|
78
|
-
proc.removeListener('exit', last);
|
|
79
|
-
add.call(proc, 'exit', last);
|
|
80
|
-
}
|
|
81
|
-
return out;
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Print an uncaught error the way Node prints one, from the worker itself.
|
|
88
|
-
* Node hands a worker's uncaught error to its parent as an event, and the
|
|
89
|
-
* parent here is parked for good — it ends the process on the worker's
|
|
90
|
-
* word and never runs its event loop again to hear it. Only when nothing
|
|
91
|
-
* handles the error: an app's own `uncaughtException` handler means it is
|
|
92
|
-
* not uncaught.
|
|
93
|
-
*/
|
|
94
|
-
export function printUncaught(
|
|
95
|
-
proc = process,
|
|
96
|
-
write = (s) => proc.stderr.write(s),
|
|
97
|
-
) {
|
|
98
|
-
proc.on('uncaughtExceptionMonitor', (err) => {
|
|
99
|
-
if (proc.listenerCount('uncaughtException') > 0) return;
|
|
100
|
-
write(`${inspect(err)}\n`);
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
47
|
/** The open channel, or null outside threaded mode. */
|
|
105
48
|
export function threadedChannel() {
|
|
106
49
|
return channel;
|
package/src/events.js
CHANGED
|
@@ -622,6 +622,9 @@ export class EventManager {
|
|
|
622
622
|
// window gets keys, and the focused node's caret/ring has to follow
|
|
623
623
|
onDiscrete('focus', (ev) => this._onWindowFocus(true, ev));
|
|
624
624
|
onDiscrete('blur', (ev) => this._onWindowFocus(false, ev));
|
|
625
|
+
// Tab arriving from outside the window: a `<Frame>`'s host moving its
|
|
626
|
+
// focus into the pane this window is (`_tabEnter`)
|
|
627
|
+
onDiscrete('tabenter', (ev) => this._tabEnter(Boolean(ev?.backwards)));
|
|
625
628
|
}
|
|
626
629
|
|
|
627
630
|
/**
|
|
@@ -2071,8 +2074,9 @@ export class EventManager {
|
|
|
2071
2074
|
const manager = this.focusManager;
|
|
2072
2075
|
if (manager !== this) return manager._cycleFocus(backwards);
|
|
2073
2076
|
const list = this._tabbables();
|
|
2074
|
-
if (list.length === 0) return;
|
|
2075
2077
|
const index = list.indexOf(this.focused);
|
|
2078
|
+
if (this._tabOut(list, index, backwards)) return;
|
|
2079
|
+
if (list.length === 0) return;
|
|
2076
2080
|
if (index === -1) {
|
|
2077
2081
|
// nothing focused, or focus sits outside the current scope: Tab enters
|
|
2078
2082
|
this.focus(backwards ? list[list.length - 1] : list[0], 'key', {
|
|
@@ -2089,6 +2093,47 @@ export class EventManager {
|
|
|
2089
2093
|
);
|
|
2090
2094
|
}
|
|
2091
2095
|
|
|
2096
|
+
/**
|
|
2097
|
+
* Tab off an end of a window that is itself a stop in another window's
|
|
2098
|
+
* order — a `<Frame>`'s pane, whose host goes on from the box the pane is
|
|
2099
|
+
* shown in. The order does not wrap there: the window hands Tab back
|
|
2100
|
+
* (`tabOut` on the backend window, which says whether anyone took it),
|
|
2101
|
+
* and lets go of its focus, as an XEmbed client does when it sends
|
|
2102
|
+
* FOCUS_NEXT, so the stop it was at shows no ring while the host's next
|
|
2103
|
+
* one does. Coming back, Tab lands at an end again (`_tabEnter`).
|
|
2104
|
+
*
|
|
2105
|
+
* Not inside a focus scope: a modal's order wraps in a pane as it does
|
|
2106
|
+
* in a window, and the dialog keeps the keyboard until it closes.
|
|
2107
|
+
*/
|
|
2108
|
+
_tabOut(list, index, backwards) {
|
|
2109
|
+
const wnd = this.node.window;
|
|
2110
|
+
if (typeof wnd?.tabOut !== 'function') return false;
|
|
2111
|
+
if (this._scopeRoot() !== this.node) return false;
|
|
2112
|
+
// nothing focused is not an end: Tab enters at one
|
|
2113
|
+
if (list.length && index !== (backwards ? 0 : list.length - 1)) {
|
|
2114
|
+
return false;
|
|
2115
|
+
}
|
|
2116
|
+
if (!wnd.tabOut(backwards)) return false;
|
|
2117
|
+
this.focus(null);
|
|
2118
|
+
return true;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
/**
|
|
2122
|
+
* Tab arriving at this window from the one it is shown in: the first stop
|
|
2123
|
+
* in its order, or the last for a back-Tab, as a Tab into a window with
|
|
2124
|
+
* nothing focused lands. A window with no stop keeps the keys where they
|
|
2125
|
+
* are, and the next Tab hands them straight back (`_tabOut`).
|
|
2126
|
+
*/
|
|
2127
|
+
_tabEnter(backwards) {
|
|
2128
|
+
const manager = this.focusManager;
|
|
2129
|
+
if (manager !== this) return manager._tabEnter(backwards);
|
|
2130
|
+
const list = this._tabbables();
|
|
2131
|
+
if (list.length === 0) return;
|
|
2132
|
+
this.focus(backwards ? list[list.length - 1] : list[0], 'key', {
|
|
2133
|
+
backwards,
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2092
2137
|
/**
|
|
2093
2138
|
* A subtree just stopped being visible: `<Suspense>` showing its fallback,
|
|
2094
2139
|
* `<Activity mode="hidden">`, or a style that turned `display: 'none'`.
|
package/src/frame/childmain.js
CHANGED
|
@@ -230,8 +230,13 @@ export async function runFrameChild(transport, options = {}) {
|
|
|
230
230
|
send: (msg) => transport.send(msg),
|
|
231
231
|
onMessage: (cb) => transport.onMessage(cb),
|
|
232
232
|
},
|
|
233
|
-
|
|
234
|
-
|
|
233
|
+
{
|
|
234
|
+
// what the host does for the pane that a pane cannot do itself
|
|
235
|
+
players: Boolean(hello.players),
|
|
236
|
+
// and the scale it is drawn at, before anything here is laid out at
|
|
237
|
+
// the one this process read for itself
|
|
238
|
+
displayScale: hello.rect?.displayScale,
|
|
239
|
+
},
|
|
235
240
|
);
|
|
236
241
|
|
|
237
242
|
let closing = false;
|
package/src/frame/index.js
CHANGED
|
@@ -46,6 +46,7 @@ import React, {
|
|
|
46
46
|
import { useAppOrNull } from '../appcontext.js';
|
|
47
47
|
import { canEmbed } from '../embedding.js';
|
|
48
48
|
import { WHEEL_NOTCH_PX } from '../events.js';
|
|
49
|
+
import { XK_TAB } from '../keysyms.js';
|
|
49
50
|
import { FrameEnv } from './env.js';
|
|
50
51
|
import { CallbackTable, PROTOCOL } from './protocol.js';
|
|
51
52
|
|
|
@@ -407,6 +408,13 @@ export function Frame({
|
|
|
407
408
|
const rect = {
|
|
408
409
|
width: Math.round((node?.abs?.width || 0) / sc) || 400,
|
|
409
410
|
height: Math.round((node?.abs?.height || 0) / sc) || 300,
|
|
411
|
+
// The display scale the host is drawn at. A pane process reads the
|
|
412
|
+
// screens when it starts, which is not when the host did: a page
|
|
413
|
+
// forked after the desk went to 1x — a mirror set for a projector —
|
|
414
|
+
// was laid out at 1x in a frame the host showed at 2x. A pane that
|
|
415
|
+
// has no window of its own takes this one instead of its own
|
|
416
|
+
// (`CocoaApp.adoptPaneScale`).
|
|
417
|
+
...(node?.root?.scale > 0 && { displayScale: node.root.scale }),
|
|
410
418
|
};
|
|
411
419
|
s.sent = { props: p, env: e, bridge: b };
|
|
412
420
|
trySend({
|
|
@@ -525,6 +533,37 @@ function PaneHostView({
|
|
|
525
533
|
const embedErrorRef = useRef(onEmbedError);
|
|
526
534
|
embedErrorRef.current = onEmbedError;
|
|
527
535
|
|
|
536
|
+
const forward = (name, data) => (ev) => {
|
|
537
|
+
const s = session.current;
|
|
538
|
+
const node = containerRef.current;
|
|
539
|
+
if (!s || !node) return;
|
|
540
|
+
const sc = node.scale ?? 1;
|
|
541
|
+
const payload = {
|
|
542
|
+
x: Math.max(0, Math.round(ev.x * sc - node.abs.x)),
|
|
543
|
+
y: Math.max(0, Math.round(ev.y * sc - node.abs.y)),
|
|
544
|
+
rootx: Math.round(ev.x * sc),
|
|
545
|
+
rooty: Math.round(ev.y * sc),
|
|
546
|
+
// The modifier mask, which a synthetic event carries on its native
|
|
547
|
+
// event alone. Read off the synthetic one, every key and press
|
|
548
|
+
// reached the pane with none: Shift+Tab went forwards there, and a
|
|
549
|
+
// Ctrl+C or a Shift+click was a C and a click.
|
|
550
|
+
buttons: ev.nativeEvent?.buttons ?? 0,
|
|
551
|
+
time: Date.now(),
|
|
552
|
+
...data(ev),
|
|
553
|
+
};
|
|
554
|
+
try {
|
|
555
|
+
s.trySend?.({ type: 'pane-event', name, ev: payload });
|
|
556
|
+
} catch {
|
|
557
|
+
// the exit path owns the failure story
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
const key = (ev) => ({
|
|
561
|
+
keysym: ev.keysym,
|
|
562
|
+
baseKeysym: ev.baseKeysym ?? ev.keysym,
|
|
563
|
+
codepoint: ev.codepoint,
|
|
564
|
+
keycode: ev.keycode ?? 0,
|
|
565
|
+
});
|
|
566
|
+
|
|
528
567
|
useEffect(() => {
|
|
529
568
|
const s = session.current;
|
|
530
569
|
const node = containerRef.current;
|
|
@@ -595,6 +634,7 @@ function PaneHostView({
|
|
|
595
634
|
width: rect.width,
|
|
596
635
|
height: rect.height,
|
|
597
636
|
scale: rect.scale,
|
|
637
|
+
displayScale: rect.displayScale,
|
|
598
638
|
live: liveNow(),
|
|
599
639
|
...(rect.screen && { screen: rect.screen }),
|
|
600
640
|
});
|
|
@@ -609,6 +649,7 @@ function PaneHostView({
|
|
|
609
649
|
a.width === b.width &&
|
|
610
650
|
a.height === b.height &&
|
|
611
651
|
a.scale === b.scale &&
|
|
652
|
+
a.displayScale === b.displayScale &&
|
|
612
653
|
a.screen?.x === b.screen?.x &&
|
|
613
654
|
a.screen?.y === b.screen?.y;
|
|
614
655
|
const sized = () => {
|
|
@@ -632,7 +673,11 @@ function PaneHostView({
|
|
|
632
673
|
y: Math.round(origin.y + node.abs.y),
|
|
633
674
|
}
|
|
634
675
|
: null;
|
|
635
|
-
|
|
676
|
+
// `scale` is the frame's, a `<box scale>` around it included, and
|
|
677
|
+
// says how many device pixels the pane's rect is; `displayScale` is
|
|
678
|
+
// the window's, the one the pane lays out at (see the hello)
|
|
679
|
+
const displayScale = node.root?.scale ?? sc;
|
|
680
|
+
const rect = { width, height, scale: sc, displayScale, screen };
|
|
636
681
|
if (sameRect(rect, pending ?? sent)) return;
|
|
637
682
|
if (inFlight) pending = rect;
|
|
638
683
|
else transmit(rect);
|
|
@@ -663,8 +708,67 @@ function PaneHostView({
|
|
|
663
708
|
}),
|
|
664
709
|
);
|
|
665
710
|
|
|
711
|
+
// Tab, which goes through the pane as it goes through any subtree. The
|
|
712
|
+
// box is a stop in this window's order, and the pane's own order hangs
|
|
713
|
+
// off it — the chain XEmbed makes for a `<foreign>` with FOCUS_FIRST,
|
|
714
|
+
// FOCUS_LAST and FOCUS_NEXT/PREV (src/foreignnodes.js). Tab arriving
|
|
715
|
+
// at the box goes on into the pane, at its first stop or, for a
|
|
716
|
+
// back-Tab, its last (`tabenter`). Tab while the box has the focus is
|
|
717
|
+
// the pane's, sent on and not cycled here. And the pane running off an
|
|
718
|
+
// end of its order hands Tab back (`pane-tab-out`), which goes on from
|
|
719
|
+
// the box. Without the three, the box took a Tab and the pane the next,
|
|
720
|
+
// which moved the pane's focus a stop and this window's out of the
|
|
721
|
+
// pane, so Tab went between one of the pane's stops and the window's.
|
|
722
|
+
//
|
|
723
|
+
// Keys go over as the box's default action, after the whole tree has
|
|
724
|
+
// had them, so a chord the application answered above the box never
|
|
725
|
+
// reaches the pane: sent from the box's handler, Ctrl+Tab switched the
|
|
726
|
+
// application's tab and moved the focus in the pane. And the pane
|
|
727
|
+
// composes what it is sent, as an embedded client does, rather than
|
|
728
|
+
// this window composing it first.
|
|
729
|
+
const tabInto = (backwards) =>
|
|
730
|
+
s.trySend?.({
|
|
731
|
+
type: 'pane-event',
|
|
732
|
+
name: 'tabenter',
|
|
733
|
+
ev: { backwards, buttons: 0, time: Date.now() },
|
|
734
|
+
});
|
|
735
|
+
const own = {
|
|
736
|
+
defaultFocus: Object.hasOwn(node, 'defaultFocus'),
|
|
737
|
+
defaultKeyDown: Object.hasOwn(node, 'defaultKeyDown'),
|
|
738
|
+
defaultKeyUp: Object.hasOwn(node, 'defaultKeyUp'),
|
|
739
|
+
composes: Object.hasOwn(node, 'composes'),
|
|
740
|
+
};
|
|
741
|
+
const was = {
|
|
742
|
+
defaultFocus: node.defaultFocus,
|
|
743
|
+
defaultKeyDown: node.defaultKeyDown,
|
|
744
|
+
defaultKeyUp: node.defaultKeyUp,
|
|
745
|
+
composes: node.composes,
|
|
746
|
+
};
|
|
747
|
+
const sendKeyDown = forward('keydown', key);
|
|
748
|
+
const sendKeyUp = forward('keyup', key);
|
|
749
|
+
node.defaultFocus = (info) => {
|
|
750
|
+
if (info?.reason === 'key') tabInto(Boolean(info.backwards));
|
|
751
|
+
};
|
|
752
|
+
node.defaultKeyDown = (ev) => {
|
|
753
|
+
sendKeyDown(ev);
|
|
754
|
+
// the pane's order answers it, and hands it back at an end
|
|
755
|
+
if (ev.keysym === XK_TAB) ev.preventDefault();
|
|
756
|
+
};
|
|
757
|
+
node.defaultKeyUp = sendKeyUp;
|
|
758
|
+
node.composes = false;
|
|
759
|
+
|
|
666
760
|
const off = s.transport.onMessage((msg) => {
|
|
667
|
-
if (msg?.type === 'pane-
|
|
761
|
+
if (msg?.type === 'pane-tab-out') {
|
|
762
|
+
// Only from focus the box holds: a pane that hands back a Tab it
|
|
763
|
+
// was not given is news that came too late.
|
|
764
|
+
const manager = node.root?.events?.focusManager;
|
|
765
|
+
if (!manager || manager.focused !== node) return;
|
|
766
|
+
const backwards = msg.backwards === true;
|
|
767
|
+
manager._cycleFocus(backwards);
|
|
768
|
+
// the window's only stop: round into the pane again, at the end
|
|
769
|
+
// that Tab comes to next
|
|
770
|
+
if (manager.focused === node) tabInto(backwards);
|
|
771
|
+
} else if (msg?.type === 'pane-window') {
|
|
668
772
|
host.paneWindow?.(msg);
|
|
669
773
|
} else if (msg?.type === 'pane-menu') {
|
|
670
774
|
host.popUpMenu?.(msg);
|
|
@@ -705,32 +809,15 @@ function PaneHostView({
|
|
|
705
809
|
node.root?.events?.refreshCursor();
|
|
706
810
|
node.absolutize = origAbsolutize;
|
|
707
811
|
if (origShift) node._shiftAbs = origShift;
|
|
812
|
+
for (const name of Object.keys(own)) {
|
|
813
|
+
if (own[name]) node[name] = was[name];
|
|
814
|
+
else delete node[name];
|
|
815
|
+
}
|
|
708
816
|
if (s.paneHost === host) s.paneHost = null;
|
|
709
817
|
host.destroy();
|
|
710
818
|
};
|
|
711
819
|
}, [app, session, containerRef]);
|
|
712
820
|
|
|
713
|
-
const forward = (name, data) => (ev) => {
|
|
714
|
-
const s = session.current;
|
|
715
|
-
const node = containerRef.current;
|
|
716
|
-
if (!s || !node) return;
|
|
717
|
-
const sc = node.scale ?? 1;
|
|
718
|
-
const payload = {
|
|
719
|
-
x: Math.max(0, Math.round(ev.x * sc - node.abs.x)),
|
|
720
|
-
y: Math.max(0, Math.round(ev.y * sc - node.abs.y)),
|
|
721
|
-
rootx: Math.round(ev.x * sc),
|
|
722
|
-
rooty: Math.round(ev.y * sc),
|
|
723
|
-
buttons: ev.buttons ?? 0,
|
|
724
|
-
time: Date.now(),
|
|
725
|
-
...data(ev),
|
|
726
|
-
};
|
|
727
|
-
try {
|
|
728
|
-
s.trySend?.({ type: 'pane-event', name, ev: payload });
|
|
729
|
-
} catch {
|
|
730
|
-
// the exit path owns the failure story
|
|
731
|
-
}
|
|
732
|
-
};
|
|
733
|
-
|
|
734
821
|
return h('box', {
|
|
735
822
|
ref: containerRef,
|
|
736
823
|
style,
|
|
@@ -754,17 +841,5 @@ function PaneHostView({
|
|
|
754
841
|
smooth: Boolean(ev.smooth),
|
|
755
842
|
source: 'forwarded',
|
|
756
843
|
})),
|
|
757
|
-
onKeyDown: forward('keydown', (ev) => ({
|
|
758
|
-
keysym: ev.keysym,
|
|
759
|
-
baseKeysym: ev.baseKeysym ?? ev.keysym,
|
|
760
|
-
codepoint: ev.codepoint,
|
|
761
|
-
keycode: ev.keycode ?? 0,
|
|
762
|
-
})),
|
|
763
|
-
onKeyUp: forward('keyup', (ev) => ({
|
|
764
|
-
keysym: ev.keysym,
|
|
765
|
-
baseKeysym: ev.baseKeysym ?? ev.keysym,
|
|
766
|
-
codepoint: ev.codepoint,
|
|
767
|
-
keycode: ev.keycode ?? 0,
|
|
768
|
-
})),
|
|
769
844
|
});
|
|
770
845
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { discrete } from '../../events.js';
|
|
5
5
|
import { createClientMessages } from '../../clientmessage.js';
|
|
6
6
|
import { callHandler } from '../../errors.js';
|
|
7
|
+
import { ntkRoot } from '../../ntkroot.js';
|
|
7
8
|
import { runWithPriority, DiscreteEventPriority } from '../../priority.js';
|
|
8
9
|
import { DEV } from '../util.js';
|
|
9
10
|
|
|
@@ -178,9 +179,48 @@ export class WindowListeners {
|
|
|
178
179
|
}),
|
|
179
180
|
);
|
|
180
181
|
}
|
|
182
|
+
if (this.props.embeddable && !parentWindow) this._initXEmbedTab(wnd);
|
|
181
183
|
this.events.attach();
|
|
182
184
|
}
|
|
183
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Tab through a window another client embeds — a `<Frame>`'s pane on X11
|
|
188
|
+
* — as XEmbed's client. The embedder is a `<foreign>`
|
|
189
|
+
* (src/foreignnodes.js): Tab arriving at it sends XEMBED_FOCUS_IN with
|
|
190
|
+
* FOCUS_FIRST or FOCUS_LAST, which is this window's `tabenter`, and
|
|
191
|
+
* XEMBED_FOCUS_NEXT or _PREV, which its `tabOut` sends, moves the
|
|
192
|
+
* embedder's focus on from it (`EventManager._tabEnter`, `_tabOut`). The
|
|
193
|
+
* Cocoa and Windows panes say the same two things over their channel.
|
|
194
|
+
* Without it, Tab went round the pane's own stops for good: nothing
|
|
195
|
+
* handed it back.
|
|
196
|
+
*
|
|
197
|
+
* ntk's `XEmbedPlug` speaks it, and publishes `_XEMBED_INFO` with
|
|
198
|
+
* XEMBED_MAPPED set — an embedder maps such a window as it takes it, as it
|
|
199
|
+
* maps one that publishes none, so the pane still never maps itself.
|
|
200
|
+
*/
|
|
201
|
+
_initXEmbedTab(wnd) {
|
|
202
|
+
const ntk = ntkRoot();
|
|
203
|
+
if (!ntk?.XEmbedPlug || typeof wnd.sendClientMessage !== 'function') {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (typeof wnd.tabOut === 'function') return;
|
|
207
|
+
const { XEMBED } = ntk;
|
|
208
|
+
const plug = new ntk.XEmbedPlug(this.app, { window: wnd });
|
|
209
|
+
plug.ready.catch(() => {});
|
|
210
|
+
plug.on('focusIn', (detail) => {
|
|
211
|
+
if (detail !== XEMBED.FOCUS_FIRST && detail !== XEMBED.FOCUS_LAST) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
wnd.emit('tabenter', { backwards: detail === XEMBED.FOCUS_LAST });
|
|
215
|
+
});
|
|
216
|
+
wnd.tabOut = (backwards) => {
|
|
217
|
+
if (!plug.embedder) return false;
|
|
218
|
+
if (backwards) plug.focusPrev();
|
|
219
|
+
else plug.focusNext();
|
|
220
|
+
return true;
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
184
224
|
/**
|
|
185
225
|
* A close request nobody handled — `onCloseRequest` is the override, this
|
|
186
226
|
* is what happens without one.
|
package/src/testing/events.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// async and `fireEvent` is not.
|
|
16
16
|
|
|
17
17
|
import { act } from './harness.js';
|
|
18
|
+
import { describeNode } from './queries.js';
|
|
18
19
|
import { keysymOf, MOD, XK_RETURN } from '../keysyms.js';
|
|
19
20
|
|
|
20
21
|
/** The window node that owns a drawn node (a node, or a window itself). */
|
|
@@ -22,15 +23,23 @@ function rootWindow(node) {
|
|
|
22
23
|
return node?.isWindow ? node : (node?.root ?? null);
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
const NOT_IN_A_WINDOW =
|
|
27
|
+
'react-x11/test: the target is not inside a realized window — did you ' +
|
|
28
|
+
'await renderX11(), or has it unmounted?';
|
|
29
|
+
|
|
25
30
|
/** A node's centre in **screen** coordinates, which is what injection takes. */
|
|
26
31
|
export function screenPointOf(node, { dx = 0, dy = 0 } = {}) {
|
|
27
32
|
const win = rootWindow(node);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
// Asked before the rect, which neither kind of node gives up: one that
|
|
34
|
+
// has unmounted, or that something hides, keeps the rect it was last laid
|
|
35
|
+
// out at, and what is at that spot now is whatever was behind it. The hit
|
|
36
|
+
// test passes over a hidden node, and an unmounted one is in no tree, so
|
|
37
|
+
// a pointer aimed there could only reach something else, and nothing
|
|
38
|
+
// would say so.
|
|
39
|
+
if (node?.destroyed || hiddenBy(node)) {
|
|
40
|
+
throw new Error(noRectMessage(node, win));
|
|
33
41
|
}
|
|
42
|
+
if (!win?.window) throw new Error(NOT_IN_A_WINDOW);
|
|
34
43
|
const origin = win.window._screenOrigin ?? {
|
|
35
44
|
x: win.window.x ?? 0,
|
|
36
45
|
y: win.window.y ?? 0,
|
|
@@ -44,10 +53,7 @@ export function screenPointOf(node, { dx = 0, dy = 0 } = {}) {
|
|
|
44
53
|
}
|
|
45
54
|
const abs = node.abs;
|
|
46
55
|
if (!abs || (abs.width === 0 && abs.height === 0)) {
|
|
47
|
-
throw new Error(
|
|
48
|
-
`react-x11/test: <${node.kind}> has no laid-out rect yet. Layout runs ` +
|
|
49
|
-
'on the frame clock, so `await act()` before pointing at it.',
|
|
50
|
-
);
|
|
56
|
+
throw new Error(noRectMessage(node, win));
|
|
51
57
|
}
|
|
52
58
|
return {
|
|
53
59
|
x: Math.round(origin.x + abs.x + abs.width / 2 + dx),
|
|
@@ -56,8 +62,101 @@ export function screenPointOf(node, { dx = 0, dy = 0 } = {}) {
|
|
|
56
62
|
};
|
|
57
63
|
}
|
|
58
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Why a node has no rect to aim at, worded as what to do about it. Only one
|
|
67
|
+
* answer is a matter of waiting, and its advice, `await act()`, is wrong for
|
|
68
|
+
* every other: no frame gives a size to a node that layout has already
|
|
69
|
+
* placed at 0×0. Asked in this order:
|
|
70
|
+
*
|
|
71
|
+
* - it has unmounted;
|
|
72
|
+
* - it is hidden: by its own `display: 'none'` or an ancestor's, by React,
|
|
73
|
+
* or in a window kept unmapped by `hidden` or by React. That comes before
|
|
74
|
+
* asking whether layout reached it, because a `<box>` with
|
|
75
|
+
* `display: 'none'` never places its children at all.
|
|
76
|
+
*
|
|
77
|
+
* These first two are asked whatever the node's rect: `screenPointOf`
|
|
78
|
+
* comes here for them before it looks, since the rect such a node keeps
|
|
79
|
+
* is where it was, not where anything is;
|
|
80
|
+
* - it has no box of its own: a span, an SVG shape;
|
|
81
|
+
* - layout has not placed it yet (`_placed`), or the window owes a pass
|
|
82
|
+
* (`needsLayout`) that may still give it a size. A pending frame is no
|
|
83
|
+
* witness: a paced window holds one with nothing scheduled;
|
|
84
|
+
* - layout placed it, at 0×0.
|
|
85
|
+
*/
|
|
86
|
+
function noRectMessage(node, win) {
|
|
87
|
+
const name = describeNode(node);
|
|
88
|
+
if (node.destroyed) {
|
|
89
|
+
return (
|
|
90
|
+
`react-x11/test: ${name} has unmounted, so it is nowhere on screen to ` +
|
|
91
|
+
'point at. If something replaced it, query for that instead.'
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const hider = hiddenBy(node);
|
|
95
|
+
if (hider) {
|
|
96
|
+
const self = hider === node;
|
|
97
|
+
const has = self ? 'has' : 'which has';
|
|
98
|
+
const how =
|
|
99
|
+
hider.isWindow && hider.props.hidden
|
|
100
|
+
? `${has} \`hidden\``
|
|
101
|
+
: hider.style?.display === 'none'
|
|
102
|
+
? `${has} \`display: 'none'\``
|
|
103
|
+
: `${self ? 'is hidden by React' : 'which React has hidden'}, in ` +
|
|
104
|
+
'an `<Activity mode="hidden">` or a `<Suspense>` showing its ' +
|
|
105
|
+
'fallback';
|
|
106
|
+
return (
|
|
107
|
+
`react-x11/test: ${name} ` +
|
|
108
|
+
(self ? how : `is inside ${describeNode(hider)}, ${how}`) +
|
|
109
|
+
', so there is nothing on screen to point at, and `await act()` will ' +
|
|
110
|
+
'not change that. ' +
|
|
111
|
+
(self
|
|
112
|
+
? 'Show it before pointing at it.'
|
|
113
|
+
: 'Show that before pointing at anything inside it.')
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (!node.yoga) {
|
|
117
|
+
let owner = node.parent;
|
|
118
|
+
while (owner && !owner.yoga) owner = owner.parent;
|
|
119
|
+
return (
|
|
120
|
+
`react-x11/test: ${name} has no box of its own: it is laid out as part ` +
|
|
121
|
+
`of ${owner ? describeNode(owner) : 'its parent'}, so there is no ` +
|
|
122
|
+
'rect to point at. Point at that instead, with `{ dx, dy }` to reach ' +
|
|
123
|
+
'a spot inside it.'
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (!node._placed || win.needsLayout) {
|
|
127
|
+
return (
|
|
128
|
+
`react-x11/test: <${node.kind}> has no laid-out rect yet. Layout runs ` +
|
|
129
|
+
'on the frame clock, so `await act()` before pointing at it.'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return (
|
|
133
|
+
`react-x11/test: ${name} was laid out at 0×0, so there is nothing on ` +
|
|
134
|
+
'screen to point at. Layout has run since it last changed, so ' +
|
|
135
|
+
'`await act()` will not give it a size: give it one, with `width` and ' +
|
|
136
|
+
'`height`, `flexGrow` or content to size it, or point at an ancestor ' +
|
|
137
|
+
'or a child that has one. If its size comes from content still on its ' +
|
|
138
|
+
'way, such as an image that is loading, wait for that with `waitFor`.'
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The node that takes `node` off the screen — itself or the nearest
|
|
143
|
+
* ancestor with `display: 'none'` or hidden by React, up to and including
|
|
144
|
+
* its window, which a `hidden` prop or React keeps unmapped — or null when
|
|
145
|
+
* nothing does. The same walk as the renderer's `_hiddenInTree`. */
|
|
146
|
+
function hiddenBy(node) {
|
|
147
|
+
for (let n = node; n; n = n.parent) {
|
|
148
|
+
if (n.hidden || n.style?.display === 'none') return n;
|
|
149
|
+
if (n.isWindow) break;
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
59
154
|
function serverOf(node) {
|
|
60
|
-
|
|
155
|
+
// what a target that is no node at all — a `queryBy*` that found nothing —
|
|
156
|
+
// is told by `screenPointOf`, for the calls that never ask it
|
|
157
|
+
const win = rootWindow(node);
|
|
158
|
+
if (!win) throw new Error(NOT_IN_A_WINDOW);
|
|
159
|
+
const server = win.app?._reactX11TestServer;
|
|
61
160
|
if (!server) {
|
|
62
161
|
throw new Error(
|
|
63
162
|
'react-x11/test: fireEvent needs the in-process X server, so it needs a ' +
|
|
@@ -142,10 +241,13 @@ export const fireEvent = {
|
|
|
142
241
|
fireEvent.mouseMove(node, options);
|
|
143
242
|
},
|
|
144
243
|
|
|
145
|
-
/**
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Move the pointer off the node, to the screen's origin, which is what
|
|
246
|
+
* produces a leave. Nothing is aimed at, so the node may since have been
|
|
247
|
+
* hidden or unmounted: it only names the connection.
|
|
248
|
+
*/
|
|
249
|
+
mouseLeave(node) {
|
|
250
|
+
serverOf(node).injectPointerMove(0, 0);
|
|
149
251
|
},
|
|
150
252
|
|
|
151
253
|
mouseDown(node, options = {}) {
|
|
@@ -157,9 +259,13 @@ export const fireEvent = {
|
|
|
157
259
|
for (const kc of modifiers.reverse()) server.injectKey(kc, false);
|
|
158
260
|
},
|
|
159
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Release the button wherever the pointer is — a `mouseMove` first puts
|
|
264
|
+
* it somewhere else. The node only names the connection, so it may be one
|
|
265
|
+
* the press hid or unmounted: the gesture can still end.
|
|
266
|
+
*/
|
|
160
267
|
mouseUp(node, options = {}) {
|
|
161
|
-
|
|
162
|
-
server.injectButton(options.button ?? 1, false);
|
|
268
|
+
serverOf(node).injectButton(options.button ?? 1, false);
|
|
163
269
|
},
|
|
164
270
|
|
|
165
271
|
/** Press and release — the renderer synthesises the click from the pair. */
|
package/src/testing/harness.js
CHANGED
|
@@ -56,23 +56,84 @@ const DEFAULT_SCREEN = { width: 1280, height: 800 };
|
|
|
56
56
|
* cheapest request with a reply, and its reply cannot arrive before every
|
|
57
57
|
* request queued ahead of it has been processed — which is what makes it a
|
|
58
58
|
* barrier rather than a sleep.
|
|
59
|
+
*
|
|
60
|
+
* **On a connection that is going, it resolves at once.** Nothing there will
|
|
61
|
+
* be answered, and a test that ends its connection still has `act()`,
|
|
62
|
+
* `waitFor` and `cleanup()` to get through — the last from an `afterEach`.
|
|
63
|
+
* node-x11 reports the two ways a connection goes very differently:
|
|
64
|
+
*
|
|
65
|
+
* - **The app closed it.** From the moment `close()` begins, every request
|
|
66
|
+
* throws `client is in closing state` — synchronously, which inside the
|
|
67
|
+
* promise below is a rejection.
|
|
68
|
+
* - **The server ended it** — it exited, or killed the client. All node-x11
|
|
69
|
+
* says is an `end` event: it goes on taking requests and never answers
|
|
70
|
+
* them, or anything that was in flight, so the round trip waits forever.
|
|
71
|
+
* That is the route a test of `onDisconnect` has to take: over the
|
|
72
|
+
* in-process stream pair, a client that closed its own connection never
|
|
73
|
+
* hears it end.
|
|
59
74
|
*/
|
|
60
75
|
export function settle(app, roundTrips = 2) {
|
|
61
76
|
const X = app?.X;
|
|
62
77
|
if (typeof X?.GetInputFocus !== 'function') return Promise.resolve();
|
|
78
|
+
// renderX11 settles as it mounts, so its connection is watched before a
|
|
79
|
+
// test can end it
|
|
80
|
+
const watch = watchEnd(X);
|
|
63
81
|
let chain = Promise.resolve();
|
|
64
82
|
for (let i = 0; i < roundTrips; i++) {
|
|
65
83
|
chain = chain.then(
|
|
66
84
|
() =>
|
|
67
85
|
new Promise((resolve) => {
|
|
68
|
-
|
|
69
|
-
|
|
86
|
+
if (connectionGone(X)) return resolve();
|
|
87
|
+
const done = () => {
|
|
88
|
+
watch.waiting.delete(done);
|
|
89
|
+
resolve();
|
|
90
|
+
};
|
|
91
|
+
watch.waiting.add(done);
|
|
92
|
+
try {
|
|
93
|
+
X.GetInputFocus(done);
|
|
94
|
+
} catch {
|
|
95
|
+
// refused by a rule the check above does not know: whatever the
|
|
96
|
+
// reason, a request that never went out has nothing to drain
|
|
97
|
+
done();
|
|
98
|
+
}
|
|
70
99
|
}),
|
|
71
100
|
);
|
|
72
101
|
}
|
|
73
102
|
return chain;
|
|
74
103
|
}
|
|
75
104
|
|
|
105
|
+
// Each connection settle() has met: whether the server has ended it, and the
|
|
106
|
+
// round trips waiting on it, which the end has to answer since no reply will.
|
|
107
|
+
const watched = new WeakMap();
|
|
108
|
+
|
|
109
|
+
function watchEnd(X) {
|
|
110
|
+
let watch = watched.get(X);
|
|
111
|
+
if (!watch) {
|
|
112
|
+
watch = { ended: false, waiting: new Set() };
|
|
113
|
+
watched.set(X, watch);
|
|
114
|
+
X.once?.('end', () => {
|
|
115
|
+
watch.ended = true;
|
|
116
|
+
for (const done of watch.waiting) done();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return watch;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Whether a request on this connection would go unanswered: the app began
|
|
124
|
+
* closing it, or its stream has ended — the test ntk makes before releasing
|
|
125
|
+
* anything (`connectionGone`, ntk's lib/cleanup.js) — or the server ended
|
|
126
|
+
* it, which only the `end` event says.
|
|
127
|
+
*/
|
|
128
|
+
function connectionGone(X) {
|
|
129
|
+
return Boolean(
|
|
130
|
+
X?._closing ||
|
|
131
|
+
X?.stream?.destroyed ||
|
|
132
|
+
X?.stream?.writableEnded ||
|
|
133
|
+
watched.get(X)?.ended,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
76
137
|
async function createXServerApp({ screen, fonts }) {
|
|
77
138
|
const [{ default: xserver }, ntk] = await Promise.all([
|
|
78
139
|
optional('x11/lib/xserver/index.js', 'is the in-process X server'),
|
|
@@ -327,10 +388,13 @@ export async function act(fn, only = null) {
|
|
|
327
388
|
// block — commit and settle. One round leaves that update in flight, and
|
|
328
389
|
// React says so out loud ("An update to Button inside a test was not
|
|
329
390
|
// wrapped in act(...)"), which is how this was found.
|
|
391
|
+
// A pointer move off the wire is still not in the tree: its window holds
|
|
392
|
+
// it for the next frame, so each round also hands over what is held.
|
|
330
393
|
for (let round = 0; round < 2; round++) {
|
|
331
394
|
await React.act(async () => {
|
|
332
395
|
await new Promise((resolve) => setImmediate(resolve));
|
|
333
396
|
for (const entry of entries) await settle(entry.app);
|
|
397
|
+
for (const entry of entries) deliverHeldEvents(entry);
|
|
334
398
|
});
|
|
335
399
|
}
|
|
336
400
|
} finally {
|
|
@@ -340,7 +404,44 @@ export async function act(fn, only = null) {
|
|
|
340
404
|
for (const entry of entries) await settle(entry.app);
|
|
341
405
|
}
|
|
342
406
|
|
|
343
|
-
/**
|
|
407
|
+
/**
|
|
408
|
+
* Dispatch the events each window in a root is holding for its next frame.
|
|
409
|
+
*
|
|
410
|
+
* ntk delivers its noisy events — pointer motion, a resize, an expose —
|
|
411
|
+
* merged, once a frame, and starts frames at least a frame interval apart.
|
|
412
|
+
* A move that lands within an interval of the last frame waits out the rest
|
|
413
|
+
* of it, and the rest ends after `act()` has returned: the second of two
|
|
414
|
+
* hovers in a row is such a move, the first one's frame having started the
|
|
415
|
+
* interval, and the assertion right after it saw the pointer where it had
|
|
416
|
+
* been. A press or a key never waits, since ntk delivers what it holds ahead
|
|
417
|
+
* of any discrete event, but a pure move has nothing behind it.
|
|
418
|
+
*
|
|
419
|
+
* Delivered as the frame would deliver them, only now, and inside
|
|
420
|
+
* `React.act`, so what the handlers set commits in the same round.
|
|
421
|
+
* `flushCoalescedEvents()` is ntk's public way to ask for that; an ntk
|
|
422
|
+
* without it has only the method its frame calls, and that branch goes once
|
|
423
|
+
* the floor is an ntk with it.
|
|
424
|
+
*/
|
|
425
|
+
function deliverHeldEvents(entry) {
|
|
426
|
+
for (const node of windowNodesOf(entry.windowNode)) {
|
|
427
|
+
const win = node.window;
|
|
428
|
+
if (typeof win?.flushCoalescedEvents === 'function') {
|
|
429
|
+
win.flushCoalescedEvents();
|
|
430
|
+
} else {
|
|
431
|
+
win?._flushCoalesced?.();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Run the frame the scheduler would have run, for every window in a root.
|
|
438
|
+
*
|
|
439
|
+
* Not skipped on a connection that has gone. The harm a frame can do there
|
|
440
|
+
* is to throw, which it would only once the app is closing, and the frame
|
|
441
|
+
* declines on such a connection before it sends a thing (`_flushFrame`); on
|
|
442
|
+
* one the server ended, its requests are taken and go nowhere. A check here
|
|
443
|
+
* would be a second copy of the frame's own.
|
|
444
|
+
*/
|
|
344
445
|
function flushFrames(entry) {
|
|
345
446
|
for (const node of windowNodesOf(entry.windowNode)) {
|
|
346
447
|
if (!node.window) continue;
|
|
@@ -359,12 +460,17 @@ async function unmountEntry(entry) {
|
|
|
359
460
|
// before the unmount, so the teardown's own hook traffic is not recorded
|
|
360
461
|
entry.at?.uninstall();
|
|
361
462
|
try {
|
|
362
|
-
|
|
463
|
+
// awaited, or the catch is never reached: unmount is async
|
|
464
|
+
await entry.root.unmount?.();
|
|
363
465
|
} catch {
|
|
364
466
|
// a root whose connection already died: nothing left to unmount
|
|
365
467
|
}
|
|
366
468
|
await settle(entry.app);
|
|
367
|
-
|
|
469
|
+
// A connection that is already going is not closed again. close() begins
|
|
470
|
+
// with a round trip, which one the test closed refuses by throwing, and
|
|
471
|
+
// which one whose stream has ended — the server's doing, or a dispose —
|
|
472
|
+
// never answers, hanging the teardown
|
|
473
|
+
if (entry.ownsApp && !connectionGone(entry.app.X)) {
|
|
368
474
|
try {
|
|
369
475
|
await entry.app.close?.();
|
|
370
476
|
} catch {
|
package/src/testing/index.d.ts
CHANGED
|
@@ -343,7 +343,11 @@ export function cleanup(): Promise<void>;
|
|
|
343
343
|
*/
|
|
344
344
|
export function setAppearance(values: PinnedAppearance): Promise<void>;
|
|
345
345
|
|
|
346
|
-
/**
|
|
346
|
+
/**
|
|
347
|
+
* Drain in-flight requests on a connection. On one that has gone — closed by
|
|
348
|
+
* the app, or ended by the server — it resolves at once, since nothing there
|
|
349
|
+
* will be answered.
|
|
350
|
+
*/
|
|
347
351
|
export function settle(app: NtkApp, roundTrips?: number): Promise<void>;
|
|
348
352
|
|
|
349
353
|
/**
|
|
@@ -461,8 +465,14 @@ export interface KeyOptions {
|
|
|
461
465
|
export const fireEvent: {
|
|
462
466
|
mouseMove(node: DrawnNode, options?: PointerOptions): void;
|
|
463
467
|
mouseEnter(node: DrawnNode, options?: PointerOptions): void;
|
|
468
|
+
/** Move the pointer to the screen's origin, which is a leave. Aims at
|
|
469
|
+
* nothing: `node` only names the connection, and may since have been
|
|
470
|
+
* hidden or unmounted. */
|
|
464
471
|
mouseLeave(node: DrawnNode, options?: PointerOptions): void;
|
|
465
472
|
mouseDown(node: DrawnNode, options?: PointerOptions): void;
|
|
473
|
+
/** Release `button` wherever the pointer is — move it first to release
|
|
474
|
+
* somewhere else. Aims at nothing: `node` only names the connection, and
|
|
475
|
+
* may be one the press hid or unmounted. */
|
|
466
476
|
mouseUp(node: DrawnNode, options?: PointerOptions): void;
|
|
467
477
|
click(node: DrawnNode, options?: PointerOptions): void;
|
|
468
478
|
doubleClick(node: DrawnNode, options?: PointerOptions): void;
|
|
@@ -491,6 +501,7 @@ export const userEvent: {
|
|
|
491
501
|
click(node: DrawnNode, options?: PointerOptions): Promise<void>;
|
|
492
502
|
doubleClick(node: DrawnNode, options?: PointerOptions): Promise<void>;
|
|
493
503
|
hover(node: DrawnNode, options?: PointerOptions): Promise<void>;
|
|
504
|
+
/** `fireEvent.mouseLeave`, flushed: aims at nothing. */
|
|
494
505
|
unhover(node: DrawnNode, options?: PointerOptions): Promise<void>;
|
|
495
506
|
/** A scroll in notches; `smooth` takes fractions of one, as a touchpad
|
|
496
507
|
* measures them. */
|
|
@@ -517,7 +528,12 @@ export const userEvent: {
|
|
|
517
528
|
}): Promise<void>;
|
|
518
529
|
};
|
|
519
530
|
|
|
520
|
-
/**
|
|
531
|
+
/**
|
|
532
|
+
* A node's centre in screen coordinates. Throws, saying why, for a node
|
|
533
|
+
* with no rect: not laid out yet, laid out at 0×0, or laid out as part of
|
|
534
|
+
* another node — and for one that is hidden or has unmounted, whatever rect
|
|
535
|
+
* it kept, which is where it was and not where anything is.
|
|
536
|
+
*/
|
|
521
537
|
export function screenPointOf(
|
|
522
538
|
node: DrawnNode,
|
|
523
539
|
options?: { dx?: number; dy?: number },
|
package/src/testing/queries.js
CHANGED
|
@@ -66,11 +66,24 @@ export function roleOf(node) {
|
|
|
66
66
|
return roleNameOf(node) ?? node.kind;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
/** A node the way an error names it: its kind, its `data-testname` and the
|
|
70
|
+
* start of its text — what a test author can find it by. A `<popup>` is a
|
|
71
|
+
* window to the renderer, and is named as the element it was written as.
|
|
72
|
+
* The text is read whether or not the node is still mounted, unlike
|
|
73
|
+
* `textOf`'s: one that has unmounted presents nothing now, and is named by
|
|
74
|
+
* what it said. A mounted node holds no unmounted child, so for one of those
|
|
75
|
+
* the two read the same. */
|
|
76
|
+
export function describeNode(node) {
|
|
77
|
+
let text = '';
|
|
78
|
+
(function read(n) {
|
|
79
|
+
if (typeof n.text === 'string') text += n.text;
|
|
80
|
+
for (const child of n.children ?? []) read(child);
|
|
81
|
+
})(node);
|
|
82
|
+
text = text.trim();
|
|
71
83
|
const name = node.props?.['data-testname'];
|
|
84
|
+
const tag = node.isPopup ? 'popup' : node.kind;
|
|
72
85
|
return (
|
|
73
|
-
`<${
|
|
86
|
+
`<${tag}${name ? ` data-testname="${name}"` : ''}>` +
|
|
74
87
|
(text ? ` ${JSON.stringify(text.slice(0, 40))}` : '')
|
|
75
88
|
);
|
|
76
89
|
}
|
|
@@ -79,7 +92,7 @@ function inventory(root, limit = 12) {
|
|
|
79
92
|
const lines = [];
|
|
80
93
|
walk(root, (n) => {
|
|
81
94
|
if (n.kind === 'textchunk' || n.kind === 'textspan') return;
|
|
82
|
-
if (lines.length < limit) lines.push(` ${
|
|
95
|
+
if (lines.length < limit) lines.push(` ${describeNode(n)}`);
|
|
83
96
|
});
|
|
84
97
|
return lines.join('\n');
|
|
85
98
|
}
|
|
@@ -93,7 +106,7 @@ function one(root, found, what, printTree = inventory) {
|
|
|
93
106
|
}
|
|
94
107
|
throw new Error(
|
|
95
108
|
`react-x11/test: found ${found.length} elements for ${what}, expected one:\n` +
|
|
96
|
-
found.map((n) => ` ${
|
|
109
|
+
found.map((n) => ` ${describeNode(n)}`).join('\n'),
|
|
97
110
|
);
|
|
98
111
|
}
|
|
99
112
|
|
package/src/win32/app.js
CHANGED
|
@@ -418,9 +418,12 @@ export class Win32App {
|
|
|
418
418
|
* The pane process's end of the frame channel: geometry and input in,
|
|
419
419
|
* presents and the cursor out. Feature-detected by the pane bootstrap
|
|
420
420
|
* (src/frame/childmain.js), and a no-op in a process that is not a pane.
|
|
421
|
+
* `displayScale` is the scale the host's window is drawn at, taken before
|
|
422
|
+
* the pane's first render (`adoptPaneScale`).
|
|
421
423
|
*/
|
|
422
|
-
attachPaneChannel(channel) {
|
|
424
|
+
attachPaneChannel(channel, { displayScale } = {}) {
|
|
423
425
|
if (!this._paneMode) return;
|
|
426
|
+
this.adoptPaneScale(displayScale);
|
|
424
427
|
this._paneSend = (msg) => {
|
|
425
428
|
try {
|
|
426
429
|
channel.send(msg);
|
|
@@ -429,6 +432,8 @@ export class Win32App {
|
|
|
429
432
|
}
|
|
430
433
|
};
|
|
431
434
|
channel.onMessage((msg) => {
|
|
435
|
+
// the app's, so heard with no window to size yet as well
|
|
436
|
+
if (msg?.type === 'pane-rect') this.adoptPaneScale(msg.displayScale);
|
|
432
437
|
const wnd = [...this._windows.values()][0];
|
|
433
438
|
if (!wnd) return;
|
|
434
439
|
if (msg?.type === 'pane-rect') {
|
|
@@ -439,6 +444,36 @@ export class Win32App {
|
|
|
439
444
|
});
|
|
440
445
|
}
|
|
441
446
|
|
|
447
|
+
/**
|
|
448
|
+
* The scale of the window a pane is shown in, as the host says it — in
|
|
449
|
+
* the hello and in every pane-rect — made this process's. Returns whether
|
|
450
|
+
* it moved.
|
|
451
|
+
*
|
|
452
|
+
* A pane process reads the screens when it starts, and the host read
|
|
453
|
+
* them when it did. Across a change of the desk between the two — a
|
|
454
|
+
* display's scaling changed, a laptop docked — the answers differ, and a
|
|
455
|
+
* pane forked after it was laid out at its own scale in a buffer the host
|
|
456
|
+
* sized at the host's: every length in it off by the ratio of the two,
|
|
457
|
+
* and every click it was sent landing that far off too. The host's window
|
|
458
|
+
* is a pane's display, and its scale is the one the tree multiplies by.
|
|
459
|
+
* The pane's buffer keeps the scale its pane-rect gives it
|
|
460
|
+
* (`Win32PaneWindow.setPaneSize`): that one is the frame's, a
|
|
461
|
+
* `<box scale>` around the `<Frame>` included. A tree already laid out
|
|
462
|
+
* is restyled in the new unit.
|
|
463
|
+
*/
|
|
464
|
+
adoptPaneScale(scale) {
|
|
465
|
+
if (!this._paneMode || !(scale > 0) || !Number.isFinite(scale)) {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
if (scale === this.scale) return false;
|
|
469
|
+
this.scale = scale;
|
|
470
|
+
setScaleForTests(this, scale, 'frame');
|
|
471
|
+
for (const wnd of this._windows.values()) {
|
|
472
|
+
if (!wnd.destroyed) wnd._reactX11Node?._rescaleSubtree();
|
|
473
|
+
}
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
|
|
442
477
|
close() {
|
|
443
478
|
if (this._closed) return Promise.resolve();
|
|
444
479
|
this._closed = true;
|
package/src/win32/panewindow.js
CHANGED
|
@@ -224,6 +224,14 @@ export class Win32PaneWindow {
|
|
|
224
224
|
this.title = title;
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
/** Tab off an end of the pane's order, handed to the host, which goes on
|
|
228
|
+
* from the `<Frame>`'s box (the Cocoa pane's `tabOut`). */
|
|
229
|
+
tabOut(backwards) {
|
|
230
|
+
if (this.destroyed || !this.app._paneSend) return false;
|
|
231
|
+
this.app._paneSend({ type: 'pane-tab-out', backwards });
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
227
235
|
/** The cursor this pane's tree names, sent to the host, which shows it:
|
|
228
236
|
* the pointer is over the host's window, not this one (the Cocoa pane's
|
|
229
237
|
* `setCursor`). */
|