react-x11 1.2.0 → 2.0.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/README.md +330 -81
- package/package.json +132 -14
- package/src/ClickToComponent.js +9 -2
- package/src/DevToolsIntegration.js +440 -32
- package/src/Reconciler.js +463 -165
- package/src/a11y.js +1299 -0
- package/src/acceleratorhooks.js +86 -0
- package/src/accelerators.js +128 -0
- package/src/activate.js +168 -0
- package/src/anchor.js +360 -0
- package/src/appcontext.js +161 -0
- package/src/appearance.js +748 -0
- package/src/appearancehooks.js +96 -0
- package/src/apphooks.js +73 -0
- package/src/application.js +748 -0
- package/src/atspi.js +1953 -0
- package/src/bus.js +545 -0
- package/src/bushooks.js +136 -0
- package/src/clientmessage.js +140 -0
- package/src/clipboard.js +121 -0
- package/src/components/Button.js +148 -33
- package/src/components/Checkbox.js +66 -32
- package/src/components/Dialog.js +96 -29
- package/src/components/FileDialog.js +491 -0
- package/src/components/Icon.js +329 -0
- package/src/components/Menu.js +797 -133
- package/src/components/PasswordInput.js +434 -0
- package/src/components/ProgressBar.js +91 -12
- package/src/components/Radio.js +78 -35
- package/src/components/Select.js +255 -65
- package/src/components/Slider.js +102 -41
- package/src/components/SplitPane.js +206 -0
- package/src/components/Switch.js +100 -22
- package/src/components/Table.js +529 -0
- package/src/components/Tabs.js +211 -0
- package/src/components/Tooltip.js +528 -54
- package/src/components/anchor.js +154 -107
- package/src/components/change.js +34 -0
- package/src/components/dnd.js +103 -0
- package/src/components/index.js +16 -3
- package/src/components/keys.js +17 -20
- package/src/components/scribble.js +247 -0
- package/src/components/theme.js +350 -42
- package/src/components/typeahead.js +12 -2
- package/src/compose.js +868 -0
- package/src/compositing.js +224 -0
- package/src/dbusmenu.js +384 -0
- package/src/debug.d.ts +63 -0
- package/src/debug.js +642 -0
- package/src/decorations.js +486 -0
- package/src/desktopsettings.js +209 -0
- package/src/desktopsettingshooks.js +62 -0
- package/src/dnd.js +1725 -0
- package/src/editmenu.js +272 -0
- package/src/errors.js +98 -0
- package/src/events.js +1406 -162
- package/src/extensions.js +45 -0
- package/src/filedialog.js +375 -0
- package/src/filedialoghooks.js +132 -0
- package/src/fonthooks.js +64 -0
- package/src/fonts.js +301 -0
- package/src/foreignnodes.js +519 -0
- package/src/frame/child.js +33 -0
- package/src/frame/childmain.js +274 -0
- package/src/frame/env.js +140 -0
- package/src/frame/index.js +444 -0
- package/src/frame/lifecycle.js +67 -0
- package/src/frame/protocol.js +179 -0
- package/src/frames.js +73 -0
- package/src/glbackend.js +114 -0
- package/src/glnodes.js +125 -67
- package/src/globalmenu.js +683 -0
- package/src/host.d.ts +88 -0
- package/src/host.js +42 -0
- package/src/idle.js +490 -0
- package/src/idlehooks.js +100 -0
- package/src/imagesource.js +349 -0
- package/src/index.d.ts +475 -0
- package/src/index.js +83 -9
- package/src/inputtime.js +206 -0
- package/src/jsx-dev-runtime.d.ts +21 -0
- package/src/jsx-dev-runtime.js +2 -0
- package/src/jsx-runtime.d.ts +39 -0
- package/src/jsx-runtime.js +6 -0
- package/src/keyboard.js +256 -0
- package/src/keyboardstate.js +278 -0
- package/src/keyboardstatehooks.js +58 -0
- package/src/keysyms.d.ts +149 -0
- package/src/keysyms.js +270 -0
- package/src/locale.js +170 -0
- package/src/localehooks.js +47 -0
- package/src/menuitem.js +223 -0
- package/src/node.d.ts +597 -0
- package/src/node.js +44 -0
- package/src/nodes.js +9546 -690
- package/src/ntk.d.ts +44 -0
- package/src/ntk.js +25 -0
- package/src/paintcache.js +366 -0
- package/src/palette.js +380 -0
- package/src/pastestate.js +66 -0
- package/src/portal.js +461 -0
- package/src/priority.js +26 -0
- package/src/refresh/index.d.ts +40 -0
- package/src/refresh/index.js +122 -0
- package/src/refresh/loader.d.ts +37 -0
- package/src/refresh/loader.js +401 -0
- package/src/refresh/register.d.ts +5 -0
- package/src/refresh/register.js +13 -0
- package/src/registry.js +232 -0
- package/src/scale.js +626 -0
- package/src/scalehooks.js +27 -0
- package/src/screencolor.js +640 -0
- package/src/screencolorhooks.js +101 -0
- package/src/screens.js +754 -0
- package/src/screenshooks.js +137 -0
- package/src/startup.js +302 -0
- package/src/style.d.ts +126 -0
- package/src/style.js +33 -0
- package/src/styles.js +1482 -6
- package/src/svgnodes.js +306 -0
- package/src/testing/a11y.js +484 -0
- package/src/testing/components.js +414 -0
- package/src/testing/events.js +407 -0
- package/src/testing/harness.js +455 -0
- package/src/testing/index.d.ts +558 -0
- package/src/testing/index.js +85 -0
- package/src/testing/mock-app.js +463 -0
- package/src/testing/pixels.js +152 -0
- package/src/testing/queries.js +224 -0
- package/src/textrange.js +83 -0
- package/src/textselection.js +439 -0
- package/src/trace-registry.js +63 -0
- package/src/transfer.js +93 -0
- package/src/types/appearance.d.ts +84 -0
- package/src/types/application.d.ts +173 -0
- package/src/types/components.d.ts +820 -0
- package/src/types/dbus.d.ts +177 -0
- package/src/types/elements.d.ts +897 -0
- package/src/types/events.d.ts +549 -0
- package/src/types/filedialog.d.ts +260 -0
- package/src/types/fonts.d.ts +124 -0
- package/src/types/frame.d.ts +146 -0
- package/src/types/globalmenu.d.ts +42 -0
- package/src/types/nodes.d.ts +199 -0
- package/src/types/screencolor.d.ts +84 -0
- package/src/types/style.d.ts +434 -0
- package/src/types/system.d.ts +287 -0
- package/src/windowid.js +151 -0
- package/src/windowstate.js +393 -0
- package/src/xsettings.js +336 -0
- package/src/yoga.d.ts +55 -0
- package/src/yoga.js +135 -0
- package/src/components/Canvas3D.js +0 -28
- package/src/geometry3d.js +0 -223
- package/src/pointer3d.js +0 -158
- package/src/raycast3d.js +0 -146
- package/src/richnodes.js +0 -436
- package/src/scene3d.js +0 -683
package/src/screens.js
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The screen layout: how many monitors there are, where they are, and how
|
|
3
|
+
* much of each one a window may actually use.
|
|
4
|
+
*
|
|
5
|
+
* Two callers with very different needs share it, which is what shapes the
|
|
6
|
+
* whole module.
|
|
7
|
+
*
|
|
8
|
+
* ## The internal caller: how big may an auto-sized window get?
|
|
9
|
+
*
|
|
10
|
+
* `<window width="auto">` is sized from its content, and the one thing that
|
|
11
|
+
* bound cannot be is "as large as the content wants" — a paragraph with no
|
|
12
|
+
* line breaks in it is a window several metres wide. So the natural size is
|
|
13
|
+
* clamped, and the honest clamp is *the usable area of the monitor the
|
|
14
|
+
* window will open on*.
|
|
15
|
+
*
|
|
16
|
+
* Three tiers, because none of them is available everywhere:
|
|
17
|
+
*
|
|
18
|
+
* 1. **A monitor rect.** `screen.pixel_width` is the whole virtual desktop,
|
|
19
|
+
* so on a two-head setup it would let an auto window span both monitors —
|
|
20
|
+
* which nobody wants and no toolkit does. Xinerama's `QueryScreens`
|
|
21
|
+
* answers with one rect per monitor in a single round trip, and every X
|
|
22
|
+
* server that speaks RandR emulates it, so this is the cheap universal
|
|
23
|
+
* way to ask.
|
|
24
|
+
* 2. **`_NET_WORKAREA`**, which is the desktop minus the panels and docks
|
|
25
|
+
* that reserved space with `_NET_WM_STRUT`. It is defined over the whole
|
|
26
|
+
* virtual screen rather than per monitor (an EWMH weakness), so it is
|
|
27
|
+
* taken as a *per-axis* bound on top of the monitor rect rather than as a
|
|
28
|
+
* rect in its own right: on one head that is exactly the work area, and
|
|
29
|
+
* on several it still takes a top or bottom panel off the height.
|
|
30
|
+
* 3. **The screen**, which every server has.
|
|
31
|
+
*
|
|
32
|
+
* And a fourth answer, `null`, meaning "no clamp at all" — the headless mock
|
|
33
|
+
* has no display to ask, and a test window that measures 4000px wide is more
|
|
34
|
+
* useful than one silently cut to a screen that does not exist.
|
|
35
|
+
*
|
|
36
|
+
* Asked once per connection during `createRoot`, which is already async, so
|
|
37
|
+
* by the time any window realizes the answer is known *synchronously* — the
|
|
38
|
+
* natural size has to be resolved before `CreateWindow`, and there is no
|
|
39
|
+
* round trip available at that point.
|
|
40
|
+
*
|
|
41
|
+
* ## The application caller: `useScreens()`
|
|
42
|
+
*
|
|
43
|
+
* An app that remembers which monitor it was on, or offers to open a video
|
|
44
|
+
* on the other one, needs more than rects: a **name** to store, and a
|
|
45
|
+
* **primary** flag to default to. Xinerama has neither — its reply is four
|
|
46
|
+
* numbers per screen and nothing else — so that detail comes from RandR,
|
|
47
|
+
* which also carries physical size and refresh rate.
|
|
48
|
+
*
|
|
49
|
+
* **RandR is not on the startup path**, and that is the point. Xinerama
|
|
50
|
+
* answers the geometry in one round trip; the RandR walk is
|
|
51
|
+
* `GetScreenResourcesCurrent`, then a `GetOutputInfo` and a `GetCrtcInfo`
|
|
52
|
+
* per output, then `GetOutputPrimary` — ten or more round trips on an
|
|
53
|
+
* ordinary two-head desktop. Making `createRoot()` wait for that would cost
|
|
54
|
+
* every app startup latency for a question most of them never ask. So the
|
|
55
|
+
* cheap tier resolves first and the detailed one publishes over it a moment
|
|
56
|
+
* later.
|
|
57
|
+
*
|
|
58
|
+
* That is only safe because the second answer **adds to** the first rather
|
|
59
|
+
* than correcting it: Xinerama on any modern server is RandR's own
|
|
60
|
+
* emulation, so the rects agree, and what arrives late is the name, the
|
|
61
|
+
* primary flag, the millimetres and the refresh rate. A component that
|
|
62
|
+
* rendered against the early answer sees fields appear, not move.
|
|
63
|
+
*
|
|
64
|
+
* ## What this deliberately does not model
|
|
65
|
+
*
|
|
66
|
+
* **The window manager's frame.** A window sized to exactly the work-area
|
|
67
|
+
* height is taller than that once it has a titlebar, and the WM will shrink
|
|
68
|
+
* or shove it. EWMH's answer is `_NET_REQUEST_FRAME_EXTENTS`, which a client
|
|
69
|
+
* sends *before* mapping and the WM replies to by writing
|
|
70
|
+
* `_NET_FRAME_EXTENTS` — but that is a round trip in the middle of a
|
|
71
|
+
* synchronous `realize()`, and plenty of window managers never answer it.
|
|
72
|
+
* Letting the WM have the last word costs a clamped-to-the-edge window one
|
|
73
|
+
* correction it would have made anyway.
|
|
74
|
+
*
|
|
75
|
+
* **A per-monitor work area.** `_NET_WORKAREA` is one rect for the whole
|
|
76
|
+
* virtual desktop. Deriving a real per-monitor one means reading
|
|
77
|
+
* `_NET_WM_STRUT_PARTIAL` off every window on the screen and intersecting
|
|
78
|
+
* the reservations that fall on each head — a full window-tree walk, redone
|
|
79
|
+
* whenever any panel changes. `available` below is the per-axis
|
|
80
|
+
* approximation instead, and says so.
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
import { requireExtension } from './extensions.js';
|
|
84
|
+
|
|
85
|
+
const sessions = new WeakMap();
|
|
86
|
+
|
|
87
|
+
const PROPERTY_NOTIFY = 28;
|
|
88
|
+
const PROPERTY_CHANGE_MASK = 4194304; // x11.eventMask.PropertyChange
|
|
89
|
+
const WORKAREA_PROPERTY = '_NET_WORKAREA';
|
|
90
|
+
// Which entry of the work-area list applies: a desktop can lay its struts
|
|
91
|
+
// out differently per workspace.
|
|
92
|
+
const DESKTOP_PROPERTY = '_NET_CURRENT_DESKTOP';
|
|
93
|
+
|
|
94
|
+
/** RandR's `Connection` enum — an output with nothing plugged into it is
|
|
95
|
+
* reported as a resource that exists and is not connected. */
|
|
96
|
+
const RR_CONNECTED = 0;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* What a connection knows about its outputs. `monitors` is null until
|
|
100
|
+
* something has answered (or for good, where nothing ever does); `workArea`
|
|
101
|
+
* is null until the desktop has published one. Both are advisory —
|
|
102
|
+
* `availableArea()` degrades through them in order and always has the screen
|
|
103
|
+
* to fall back on.
|
|
104
|
+
*/
|
|
105
|
+
class ScreenSession {
|
|
106
|
+
constructor(app) {
|
|
107
|
+
this.app = app;
|
|
108
|
+
this.monitors = null;
|
|
109
|
+
this.workArea = null;
|
|
110
|
+
/** Which tier `monitors` came from, and the public `source`. */
|
|
111
|
+
this.source = null;
|
|
112
|
+
this.stopped = false;
|
|
113
|
+
this._workAreaAtom = null;
|
|
114
|
+
this._desktopAtom = null;
|
|
115
|
+
this._snapshot = null;
|
|
116
|
+
this._listeners = new Set();
|
|
117
|
+
/** Every `X.on('event')` handler installed here, so `stop()` can take
|
|
118
|
+
* them off again rather than leaving one per root on a shared client. */
|
|
119
|
+
this._handlers = [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
stop() {
|
|
123
|
+
this.stopped = true;
|
|
124
|
+
const X = this.app?.X;
|
|
125
|
+
if (X?.off) {
|
|
126
|
+
for (const fn of this._handlers) {
|
|
127
|
+
try {
|
|
128
|
+
X.off('event', fn);
|
|
129
|
+
} catch {
|
|
130
|
+
// an ntk old enough to hand back a client with no `off`; the
|
|
131
|
+
// `stopped` guard inside every handler is the real safety net
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
this._handlers.length = 0;
|
|
136
|
+
this._listeners.clear();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Install an X event handler that this session owns. */
|
|
140
|
+
onEvent(fn) {
|
|
141
|
+
const X = this.app?.X;
|
|
142
|
+
if (!X?.on) return;
|
|
143
|
+
X.on('event', fn);
|
|
144
|
+
this._handlers.push(fn);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The screen's own size — the tier everything falls back to. */
|
|
148
|
+
get screenRect() {
|
|
149
|
+
const screen = (this.app?.display ?? this.app?.X?.display)?.screen?.[0];
|
|
150
|
+
if (!screen?.pixel_width) return null;
|
|
151
|
+
return {
|
|
152
|
+
x: 0,
|
|
153
|
+
y: 0,
|
|
154
|
+
width: screen.pixel_width,
|
|
155
|
+
height: screen.pixel_height,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Replace what is known and tell anyone watching. The snapshot is dropped
|
|
161
|
+
* rather than rebuilt: nothing may need it, and `screensSnapshot()` is
|
|
162
|
+
* what rebuilds it on demand.
|
|
163
|
+
*/
|
|
164
|
+
publish({ monitors, workArea, source }) {
|
|
165
|
+
if (monitors !== undefined) this.monitors = monitors;
|
|
166
|
+
if (workArea !== undefined) this.workArea = workArea;
|
|
167
|
+
if (source !== undefined) this.source = source;
|
|
168
|
+
this._snapshot = null;
|
|
169
|
+
for (const fn of [...this._listeners]) {
|
|
170
|
+
try {
|
|
171
|
+
fn();
|
|
172
|
+
} catch {
|
|
173
|
+
// one subscriber throwing must not take the others with it, nor the
|
|
174
|
+
// X event loop this runs on
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
subscribe(fn) {
|
|
180
|
+
this._listeners.add(fn);
|
|
181
|
+
return () => this._listeners.delete(fn);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The monitor a point is on, or the largest one when it is on none (a
|
|
186
|
+
* window whose owner the WM has not placed yet, or coordinates from a
|
|
187
|
+
* screen layout that has since changed). */
|
|
188
|
+
function monitorAt(monitors, point) {
|
|
189
|
+
if (!monitors?.length) return null;
|
|
190
|
+
if (point) {
|
|
191
|
+
for (const m of monitors) {
|
|
192
|
+
if (
|
|
193
|
+
point.x >= m.x &&
|
|
194
|
+
point.x < m.x + m.width &&
|
|
195
|
+
point.y >= m.y &&
|
|
196
|
+
point.y < m.y + m.height
|
|
197
|
+
) {
|
|
198
|
+
return m;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
let best = monitors[0];
|
|
203
|
+
for (const m of monitors) {
|
|
204
|
+
if (m.width * m.height > best.width * best.height) best = m;
|
|
205
|
+
}
|
|
206
|
+
return best;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The monitor rect clamped per axis by `_NET_WORKAREA` — see the note on
|
|
211
|
+
* per-monitor work areas at the top of the file.
|
|
212
|
+
*
|
|
213
|
+
* Always **only** a rect. A monitor record carries a name, a primary flag and
|
|
214
|
+
* physical sizes as well, and spreading it here put all of that inside
|
|
215
|
+
* `screen.available`, where it read as a rect that had somehow grown a name.
|
|
216
|
+
*/
|
|
217
|
+
function usable(monitor, work) {
|
|
218
|
+
const rect = {
|
|
219
|
+
x: monitor.x,
|
|
220
|
+
y: monitor.y,
|
|
221
|
+
width: monitor.width,
|
|
222
|
+
height: monitor.height,
|
|
223
|
+
};
|
|
224
|
+
if (!work) return rect;
|
|
225
|
+
rect.width = Math.min(rect.width, work.width);
|
|
226
|
+
rect.height = Math.min(rect.height, work.height);
|
|
227
|
+
return rect;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The rect an auto-sized window may grow into, or `null` where there is
|
|
232
|
+
* nothing to ask. `near` is a screen-coordinate point the window will open
|
|
233
|
+
* next to — a `transientFor` owner's origin, in practice — and picks the
|
|
234
|
+
* monitor when there are several.
|
|
235
|
+
*/
|
|
236
|
+
export function availableArea(app, near = null) {
|
|
237
|
+
const session = sessions.get(app);
|
|
238
|
+
const screen = session?.screenRect ?? null;
|
|
239
|
+
if (!session) return screen;
|
|
240
|
+
const monitor = monitorAt(session.monitors, near) ?? screen;
|
|
241
|
+
if (!monitor) return null;
|
|
242
|
+
return usable(monitor, session.workArea);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --------------------------------------------------------------------------
|
|
246
|
+
// The public snapshot
|
|
247
|
+
// --------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
const EMPTY = Object.freeze({
|
|
250
|
+
screens: Object.freeze([]),
|
|
251
|
+
primary: null,
|
|
252
|
+
workArea: null,
|
|
253
|
+
virtual: null,
|
|
254
|
+
source: null,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
/** The union of every monitor rect — what the virtual screen must be at
|
|
258
|
+
* least, for a server that did not say. */
|
|
259
|
+
function union(monitors) {
|
|
260
|
+
if (!monitors?.length) return null;
|
|
261
|
+
let x0 = Infinity;
|
|
262
|
+
let y0 = Infinity;
|
|
263
|
+
let x1 = -Infinity;
|
|
264
|
+
let y1 = -Infinity;
|
|
265
|
+
for (const m of monitors) {
|
|
266
|
+
x0 = Math.min(x0, m.x);
|
|
267
|
+
y0 = Math.min(y0, m.y);
|
|
268
|
+
x1 = Math.max(x1, m.x + m.width);
|
|
269
|
+
y1 = Math.max(y1, m.y + m.height);
|
|
270
|
+
}
|
|
271
|
+
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* What is known right now, as one frozen object.
|
|
276
|
+
*
|
|
277
|
+
* Frozen and cached because `useScreens()` reads it through
|
|
278
|
+
* `useSyncExternalStore`, whose `getSnapshot` must return the *same* object
|
|
279
|
+
* until something actually changes — building a fresh array per call is what
|
|
280
|
+
* makes React loop.
|
|
281
|
+
*/
|
|
282
|
+
export function screensSnapshot(app) {
|
|
283
|
+
const session = sessions.get(app);
|
|
284
|
+
if (!session) return EMPTY;
|
|
285
|
+
if (session._snapshot) return session._snapshot;
|
|
286
|
+
|
|
287
|
+
const screen = session.screenRect;
|
|
288
|
+
// Nothing answered, but there is still a screen: one monitor covering it
|
|
289
|
+
// is a truer answer than an empty list, which reads as "no displays".
|
|
290
|
+
const rects =
|
|
291
|
+
session.monitors ??
|
|
292
|
+
(screen ? [{ ...screen, name: null, primary: true }] : null);
|
|
293
|
+
|
|
294
|
+
const screens = Object.freeze(
|
|
295
|
+
(rects ?? []).map((m) =>
|
|
296
|
+
Object.freeze({
|
|
297
|
+
name: m.name ?? null,
|
|
298
|
+
outputs: Object.freeze(m.outputs ? [...m.outputs] : []),
|
|
299
|
+
x: m.x,
|
|
300
|
+
y: m.y,
|
|
301
|
+
width: m.width,
|
|
302
|
+
height: m.height,
|
|
303
|
+
available: Object.freeze(usable(m, session.workArea)),
|
|
304
|
+
primary: m.primary === true,
|
|
305
|
+
widthMM: m.widthMM ?? null,
|
|
306
|
+
heightMM: m.heightMM ?? null,
|
|
307
|
+
refreshRate: m.refreshRate ?? null,
|
|
308
|
+
rotation: m.rotation ?? 0,
|
|
309
|
+
}),
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
session._snapshot = Object.freeze({
|
|
314
|
+
screens,
|
|
315
|
+
// No output is flagged primary on a single-head desktop that never ran
|
|
316
|
+
// `xrandr --primary`, and "the one monitor" is the useful answer there.
|
|
317
|
+
primary:
|
|
318
|
+
screens.find((s) => s.primary) ??
|
|
319
|
+
(screens.length === 1 ? screens[0] : null),
|
|
320
|
+
workArea: session.workArea ? Object.freeze({ ...session.workArea }) : null,
|
|
321
|
+
virtual: Object.freeze(screen ?? union(rects) ?? null),
|
|
322
|
+
source: session.source ?? (rects?.length ? 'screen' : null),
|
|
323
|
+
});
|
|
324
|
+
return session._snapshot;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Subscribe to the layout changing. Not public — `useScreens()` is. */
|
|
328
|
+
export function watchScreens(app, fn) {
|
|
329
|
+
const session = sessions.get(app);
|
|
330
|
+
if (!session) return () => {};
|
|
331
|
+
return session.subscribe(fn);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// --------------------------------------------------------------------------
|
|
335
|
+
// Starting up
|
|
336
|
+
// --------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Start reading the screen layout on `app`. Resolves once the *cheap* answer
|
|
340
|
+
* is in, so `createRoot` can await it and every window realized afterwards
|
|
341
|
+
* sizes against a settled value. The RandR walk that names the monitors runs
|
|
342
|
+
* behind it and is deliberately not awaited — see the note at the top.
|
|
343
|
+
*
|
|
344
|
+
* Never rejects. A server with no Xinerama, a desktop with no work area, an
|
|
345
|
+
* ntk too old to reach the raw connection and a headless mock all degrade to
|
|
346
|
+
* the next tier down and stop there.
|
|
347
|
+
*/
|
|
348
|
+
export async function beginScreens(app) {
|
|
349
|
+
let session = sessions.get(app);
|
|
350
|
+
if (session) return session;
|
|
351
|
+
session = new ScreenSession(app);
|
|
352
|
+
sessions.set(app, session);
|
|
353
|
+
|
|
354
|
+
const X = app?.X;
|
|
355
|
+
if (!X || typeof X.GetProperty !== 'function') return session; // mock app
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
// This is the longest chain on the startup path, so it is the one that
|
|
359
|
+
// sets how long the first CreateWindow waits. Neither atom name depends
|
|
360
|
+
// on anything — not on the Xinerama probe, not on each other — so all
|
|
361
|
+
// three go out together and the chain is the probe plus the two property
|
|
362
|
+
// reads that genuinely need their atoms.
|
|
363
|
+
const [monitors, workAreaAtom, desktopAtom] = await Promise.all([
|
|
364
|
+
queryMonitors(app),
|
|
365
|
+
internAtom(X, WORKAREA_PROPERTY),
|
|
366
|
+
internAtom(X, DESKTOP_PROPERTY).catch(() => null),
|
|
367
|
+
]);
|
|
368
|
+
session._workAreaAtom = workAreaAtom;
|
|
369
|
+
session._desktopAtom = desktopAtom;
|
|
370
|
+
session.publish({
|
|
371
|
+
monitors,
|
|
372
|
+
workArea: await readWorkArea(session),
|
|
373
|
+
source: monitors ? 'xinerama' : null,
|
|
374
|
+
});
|
|
375
|
+
watchLayout(session);
|
|
376
|
+
} catch {
|
|
377
|
+
// every failure here means "one tier less to work with", which is what
|
|
378
|
+
// the nulls already say
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Detail, behind the geometry. Never awaited, and its failure is not this
|
|
382
|
+
// function's failure: a server with no RandR keeps the Xinerama answer.
|
|
383
|
+
refreshOutputs(session).catch(() => {});
|
|
384
|
+
return session;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Both the layout and the work area change while an app runs — a monitor is
|
|
389
|
+
* plugged in, a panel is added or auto-hidden — and they announce themselves
|
|
390
|
+
* two different ways.
|
|
391
|
+
*
|
|
392
|
+
* `_NET_WORKAREA` is a root-window property, so a `PropertyChange` selection
|
|
393
|
+
* catches the panels. RandR's own `SelectInput` catches the rest, and it is
|
|
394
|
+
* needed rather than merely nice: a second monitor arriving beside the first
|
|
395
|
+
* without moving any dock changes no property at all, so a `_NET_WORKAREA`
|
|
396
|
+
* watch alone would never hear about it. Where RandR is missing the property
|
|
397
|
+
* watch still covers the common case, because a WM that rearranges monitors
|
|
398
|
+
* usually republishes the work area with it.
|
|
399
|
+
*/
|
|
400
|
+
function watchLayout(session) {
|
|
401
|
+
const X = session.app.X;
|
|
402
|
+
const root = X.display?.screen?.[0]?.root;
|
|
403
|
+
if (root == null) return;
|
|
404
|
+
// PropertyChange on a window we do not own. Legal and shared — every
|
|
405
|
+
// panel-aware application on the desktop selects this same event.
|
|
406
|
+
X.ChangeWindowAttributes(root, { eventMask: PROPERTY_CHANGE_MASK }, () => {});
|
|
407
|
+
session.onEvent((ev) => {
|
|
408
|
+
if (session.stopped) return;
|
|
409
|
+
if (ev.type !== PROPERTY_NOTIFY || ev.wid !== root) return;
|
|
410
|
+
if (ev.atom !== session._workAreaAtom) return;
|
|
411
|
+
relayout(session);
|
|
412
|
+
});
|
|
413
|
+
watchRandR(session);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Re-read everything the layout is built from and publish once. */
|
|
417
|
+
function relayout(session) {
|
|
418
|
+
return Promise.all([queryMonitors(session.app), readWorkArea(session)]).then(
|
|
419
|
+
([monitors, workArea]) => {
|
|
420
|
+
if (session.stopped) return;
|
|
421
|
+
session.publish({
|
|
422
|
+
// A failed re-query means "could not ask again", never "no monitors".
|
|
423
|
+
monitors: monitors ?? session.monitors,
|
|
424
|
+
workArea,
|
|
425
|
+
source: monitors ? 'xinerama' : session.source,
|
|
426
|
+
});
|
|
427
|
+
return refreshOutputs(session);
|
|
428
|
+
},
|
|
429
|
+
() => {},
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function watchRandR(session) {
|
|
434
|
+
const randr = await requireExtension(session.app, 'randr');
|
|
435
|
+
if (!randr || session.stopped) return;
|
|
436
|
+
const X = session.app.X;
|
|
437
|
+
const root = X.display?.screen?.[0]?.root;
|
|
438
|
+
if (root == null) return;
|
|
439
|
+
try {
|
|
440
|
+
// ScreenChange alone misses a monitor that arrives without resizing the
|
|
441
|
+
// virtual screen, so the CRTC and output masks go on too. node-x11 only
|
|
442
|
+
// parses ScreenChangeNotify; the rest arrive as a bare `{type, seq}`,
|
|
443
|
+
// which is all this needs — every one of them means "ask again".
|
|
444
|
+
randr.SelectInput(
|
|
445
|
+
root,
|
|
446
|
+
randr.NotifyMask.ScreenChange |
|
|
447
|
+
randr.NotifyMask.CrtcChange |
|
|
448
|
+
randr.NotifyMask.OutputChange,
|
|
449
|
+
);
|
|
450
|
+
} catch {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const first = randr.firstEvent;
|
|
454
|
+
session.onEvent((ev) => {
|
|
455
|
+
if (session.stopped) return;
|
|
456
|
+
if (ev.type !== first && ev.type !== first + 1) return;
|
|
457
|
+
relayout(session);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** One rect per monitor, or null where the server has no Xinerama — and null
|
|
462
|
+
* for the single fake screen a server with the extension present but
|
|
463
|
+
* inactive reports, which carries no more than `screenRect` already does. */
|
|
464
|
+
function queryMonitors(app) {
|
|
465
|
+
return new Promise((resolve) => {
|
|
466
|
+
try {
|
|
467
|
+
app.X.require('xinerama', (err, ext) => {
|
|
468
|
+
if (err || !ext?.QueryScreens) return resolve(null);
|
|
469
|
+
ext.QueryScreens((screensError, screens) =>
|
|
470
|
+
resolve(
|
|
471
|
+
screensError || !screens?.length
|
|
472
|
+
? null
|
|
473
|
+
: screens.map((s) => ({
|
|
474
|
+
x: s.x,
|
|
475
|
+
y: s.y,
|
|
476
|
+
width: s.width,
|
|
477
|
+
height: s.height,
|
|
478
|
+
})),
|
|
479
|
+
),
|
|
480
|
+
);
|
|
481
|
+
});
|
|
482
|
+
} catch {
|
|
483
|
+
resolve(null);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// --------------------------------------------------------------------------
|
|
489
|
+
// RandR: the names, the primary flag, the millimetres and the refresh rate
|
|
490
|
+
// --------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Turn one RandR walk into monitor records.
|
|
494
|
+
*
|
|
495
|
+
* Pure, and exported for that reason: the in-process X server used by the
|
|
496
|
+
* tests has no RandR at all, so the walk cannot be driven end to end there
|
|
497
|
+
* and this is the part worth pinning.
|
|
498
|
+
*
|
|
499
|
+
* **Keyed by CRTC, not by output.** Two outputs showing the same pixels —
|
|
500
|
+
* a laptop mirroring to a projector — share one CRTC and are one monitor,
|
|
501
|
+
* however many cables are involved. RandR 1.5's `GetMonitors` is the
|
|
502
|
+
* protocol's own answer to this and node-x11 does not implement it, so the
|
|
503
|
+
* grouping happens here; `outputs` keeps both names so a mirrored pair is
|
|
504
|
+
* still legible.
|
|
505
|
+
*/
|
|
506
|
+
export function monitorsFromRandR({ outputs, crtcs, modes, primary }) {
|
|
507
|
+
const byMode = new Map((modes ?? []).map((m) => [m.id, m]));
|
|
508
|
+
const byCrtc = new Map();
|
|
509
|
+
|
|
510
|
+
for (const output of outputs ?? []) {
|
|
511
|
+
// An output with no CRTC is a port with nothing plugged in, or a
|
|
512
|
+
// connected screen the user has switched off. Neither is a monitor.
|
|
513
|
+
if (output.connection !== RR_CONNECTED || !output.crtc) continue;
|
|
514
|
+
const crtc = crtcs?.get?.(output.crtc) ?? null;
|
|
515
|
+
if (!crtc?.width || !crtc?.height) continue;
|
|
516
|
+
|
|
517
|
+
let monitor = byCrtc.get(output.crtc);
|
|
518
|
+
if (!monitor) {
|
|
519
|
+
monitor = {
|
|
520
|
+
name: output.name || null,
|
|
521
|
+
outputs: [],
|
|
522
|
+
x: crtc.x,
|
|
523
|
+
y: crtc.y,
|
|
524
|
+
width: crtc.width,
|
|
525
|
+
height: crtc.height,
|
|
526
|
+
primary: false,
|
|
527
|
+
// Physical size is per *output*, so a mirrored pair keeps the first
|
|
528
|
+
// one's — there is no single honest answer for two panels at once.
|
|
529
|
+
widthMM: output.widthMM || null,
|
|
530
|
+
heightMM: output.heightMM || null,
|
|
531
|
+
refreshRate: refreshRateOf(byMode.get(crtc.mode)),
|
|
532
|
+
rotation: degreesOf(crtc.rotation),
|
|
533
|
+
};
|
|
534
|
+
byCrtc.set(output.crtc, monitor);
|
|
535
|
+
}
|
|
536
|
+
if (output.name) monitor.outputs.push(output.name);
|
|
537
|
+
if (primary && output.id === primary) {
|
|
538
|
+
monitor.primary = true;
|
|
539
|
+
// The primary output names the monitor even when it is not the first
|
|
540
|
+
// one the walk reached.
|
|
541
|
+
if (output.name) monitor.name = output.name;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const list = [...byCrtc.values()];
|
|
546
|
+
// Left to right, then top to bottom: the order the desktop is laid out in,
|
|
547
|
+
// rather than the order the server happened to enumerate resources in,
|
|
548
|
+
// which is arbitrary and not stable across a replug.
|
|
549
|
+
list.sort((a, b) => a.x - b.x || a.y - b.y);
|
|
550
|
+
return list;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Hz from a mode line, to two decimals — `dot_clock / (h_total * v_total)`.
|
|
555
|
+
*
|
|
556
|
+
* The rounding is not cosmetic. A 60Hz mode is 59.9986… and a caller
|
|
557
|
+
* comparing rates, or printing one, wants `59.99` rather than a float whose
|
|
558
|
+
* last digits are a property of the timing table.
|
|
559
|
+
*
|
|
560
|
+
* **A result outside a plausible range is null rather than the number.** An X
|
|
561
|
+
* server that does not drive real hardware fills the timing fields in with
|
|
562
|
+
* something rather than leaving them out: XQuartz's active mode reports a
|
|
563
|
+
* `dot_clock` of exactly `h_total * v_total`, so the arithmetic is a
|
|
564
|
+
* blameless 1 Hz — and an app showing "1 Hz" beside a monitor name looks
|
|
565
|
+
* broken in a way that showing nothing does not. No panel a desktop is drawn
|
|
566
|
+
* on refreshes below 20Hz, so a value under it is a server saying "I do not
|
|
567
|
+
* know" in the only way the protocol lets it.
|
|
568
|
+
*/
|
|
569
|
+
export function refreshRateOf(mode) {
|
|
570
|
+
if (!mode?.dot_clock || !mode.h_total || !mode.v_total) return null;
|
|
571
|
+
const hz = mode.dot_clock / (mode.h_total * mode.v_total);
|
|
572
|
+
if (!Number.isFinite(hz) || hz < 20 || hz > 1000) return null;
|
|
573
|
+
return Math.round(hz * 100) / 100;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** RandR's rotation bitmask → degrees. The reflection bits are ignored:
|
|
577
|
+
* they say the image is mirrored, not that it is turned. */
|
|
578
|
+
export function degreesOf(rotation) {
|
|
579
|
+
if (rotation & 8) return 270;
|
|
580
|
+
if (rotation & 4) return 180;
|
|
581
|
+
if (rotation & 2) return 90;
|
|
582
|
+
return 0;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Walk RandR and publish the detail over whatever Xinerama said.
|
|
587
|
+
*
|
|
588
|
+
* Resolves to false wherever the walk cannot finish — no extension, an
|
|
589
|
+
* ntk with no raw connection, a layout change mid-walk — leaving the
|
|
590
|
+
* cheaper answer standing.
|
|
591
|
+
*/
|
|
592
|
+
async function refreshOutputs(session) {
|
|
593
|
+
const app = session.app;
|
|
594
|
+
const randr = await requireExtension(app, 'randr');
|
|
595
|
+
if (!randr || session.stopped) return false;
|
|
596
|
+
const X = app.X;
|
|
597
|
+
const root = X.display?.screen?.[0]?.root;
|
|
598
|
+
if (root == null) return false;
|
|
599
|
+
|
|
600
|
+
const resources = await call(randr.GetScreenResourcesCurrent, root);
|
|
601
|
+
if (!resources || session.stopped) return false;
|
|
602
|
+
|
|
603
|
+
const primary = await call(randr.GetOutputPrimary, root);
|
|
604
|
+
|
|
605
|
+
// Every output, then every CRTC one of them names. Issued together rather
|
|
606
|
+
// than in sequence: they are independent reads on one connection, so the
|
|
607
|
+
// whole walk costs one round trip's latency rather than one per output.
|
|
608
|
+
const infos = await Promise.all(
|
|
609
|
+
(resources.outputs ?? []).map((id) =>
|
|
610
|
+
call(randr.GetOutputInfo, id, resources.config_timestamp).then(
|
|
611
|
+
(info) => (info ? { ...info, id } : null),
|
|
612
|
+
() => null,
|
|
613
|
+
),
|
|
614
|
+
),
|
|
615
|
+
);
|
|
616
|
+
if (session.stopped) return false;
|
|
617
|
+
|
|
618
|
+
const wanted = new Set(infos.filter((o) => o?.crtc).map((o) => o.crtc));
|
|
619
|
+
const crtcs = new Map();
|
|
620
|
+
await Promise.all(
|
|
621
|
+
[...wanted].map((id) =>
|
|
622
|
+
call(randr.GetCrtcInfo, id, resources.config_timestamp).then(
|
|
623
|
+
(info) => info && crtcs.set(id, info),
|
|
624
|
+
() => {},
|
|
625
|
+
),
|
|
626
|
+
),
|
|
627
|
+
);
|
|
628
|
+
if (session.stopped) return false;
|
|
629
|
+
|
|
630
|
+
const monitors = monitorsFromRandR({
|
|
631
|
+
outputs: infos.filter(Boolean).map((o) => ({
|
|
632
|
+
id: o.id,
|
|
633
|
+
name: o.name,
|
|
634
|
+
crtc: o.crtc,
|
|
635
|
+
connection: o.connection,
|
|
636
|
+
widthMM: o.mm_width,
|
|
637
|
+
heightMM: o.mm_height,
|
|
638
|
+
})),
|
|
639
|
+
crtcs,
|
|
640
|
+
modes: resources.modeinfos,
|
|
641
|
+
primary,
|
|
642
|
+
});
|
|
643
|
+
// A walk that found nothing usable is a walk that failed, not a desktop
|
|
644
|
+
// with no monitors — every head could have been unplugged between the two
|
|
645
|
+
// requests, and Xinerama's answer is better than none.
|
|
646
|
+
if (!monitors.length) return false;
|
|
647
|
+
session.publish({ monitors, source: 'randr' });
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** A node-x11 request as a promise that resolves to null on error. */
|
|
652
|
+
function call(fn, ...args) {
|
|
653
|
+
return new Promise((resolve) => {
|
|
654
|
+
try {
|
|
655
|
+
fn(...args, (err, value) => resolve(err ? null : value));
|
|
656
|
+
} catch {
|
|
657
|
+
resolve(null);
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// --------------------------------------------------------------------------
|
|
663
|
+
// _NET_WORKAREA
|
|
664
|
+
// --------------------------------------------------------------------------
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* `_NET_WORKAREA` is four CARDINALs per desktop — x, y, width, height — and
|
|
668
|
+
* the current desktop's is the one that matters. Reading only the first is
|
|
669
|
+
* the common approximation and it is wrong on a desktop that reserves space
|
|
670
|
+
* differently per workspace, so `_NET_CURRENT_DESKTOP` picks the entry.
|
|
671
|
+
*/
|
|
672
|
+
async function readWorkArea(session) {
|
|
673
|
+
const X = session.app.X;
|
|
674
|
+
const root = X.display?.screen?.[0]?.root;
|
|
675
|
+
if (root == null || session._workAreaAtom == null) return null;
|
|
676
|
+
const prop = await getProperty(X, root, session._workAreaAtom);
|
|
677
|
+
const data = prop?.data;
|
|
678
|
+
if (!data || data.length < 16) return null;
|
|
679
|
+
const desktops = Math.floor(data.length / 16);
|
|
680
|
+
const current = Math.min(await currentDesktop(session), desktops - 1);
|
|
681
|
+
const off = Math.max(0, current) * 16;
|
|
682
|
+
const width = data.readUInt32LE(off + 8);
|
|
683
|
+
const height = data.readUInt32LE(off + 12);
|
|
684
|
+
if (!(width > 0 && height > 0)) return null;
|
|
685
|
+
return {
|
|
686
|
+
x: data.readInt32LE(off),
|
|
687
|
+
y: data.readInt32LE(off + 4),
|
|
688
|
+
width,
|
|
689
|
+
height,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function currentDesktop(session) {
|
|
694
|
+
const X = session.app.X;
|
|
695
|
+
const root = X.display?.screen?.[0]?.root;
|
|
696
|
+
try {
|
|
697
|
+
// Interned alongside the work-area atom in beginScreens, because this
|
|
698
|
+
// read is only reached once the work-area reply has landed and a name
|
|
699
|
+
// lookup discovered then is a round trip nothing was waiting to learn.
|
|
700
|
+
const atom =
|
|
701
|
+
session._desktopAtom ?? (await internAtom(X, DESKTOP_PROPERTY));
|
|
702
|
+
const prop = await getProperty(X, root, atom);
|
|
703
|
+
return prop?.data?.length >= 4 ? prop.data.readUInt32LE(0) : 0;
|
|
704
|
+
} catch {
|
|
705
|
+
return 0;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function internAtom(X, name) {
|
|
710
|
+
return new Promise((resolve, reject) =>
|
|
711
|
+
X.InternAtom(false, name, (err, atom) =>
|
|
712
|
+
err ? reject(err) : resolve(atom),
|
|
713
|
+
),
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function getProperty(X, wid, atom) {
|
|
718
|
+
return new Promise((resolve) =>
|
|
719
|
+
X.GetProperty(0, wid, atom, 0, 0, 0x1fffffff, (err, prop) =>
|
|
720
|
+
resolve(err ? null : prop),
|
|
721
|
+
),
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** Tear down with the root that started it. */
|
|
726
|
+
export function endScreens(app) {
|
|
727
|
+
const session = sessions.get(app);
|
|
728
|
+
if (!session) return;
|
|
729
|
+
session.stop();
|
|
730
|
+
sessions.delete(app);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Test seam: state a screen layout without an X server, the way
|
|
735
|
+
* `setCompositingForTests` states a compositor. `null` for either argument
|
|
736
|
+
* leaves that tier unknown, which is how the fallbacks are exercised.
|
|
737
|
+
*
|
|
738
|
+
* `monitors` entries may carry the RandR fields (`name`, `primary`,
|
|
739
|
+
* `widthMM`, `heightMM`, `refreshRate`, `rotation`) as well as the rect, so
|
|
740
|
+
* a test can state a named two-head desktop without a server that has RandR.
|
|
741
|
+
*/
|
|
742
|
+
export function setScreensForTests(app, { monitors = null, workArea = null }) {
|
|
743
|
+
let session = sessions.get(app);
|
|
744
|
+
if (!session) {
|
|
745
|
+
session = new ScreenSession(app);
|
|
746
|
+
sessions.set(app, session);
|
|
747
|
+
}
|
|
748
|
+
session.publish({
|
|
749
|
+
monitors,
|
|
750
|
+
workArea,
|
|
751
|
+
source: monitors ? 'test' : null,
|
|
752
|
+
});
|
|
753
|
+
return session;
|
|
754
|
+
}
|