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.
Files changed (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. 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
+ }