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
@@ -0,0 +1,640 @@
1
+ // Sample one pixel from the screen — the eyedropper, through whatever this
2
+ // machine actually has.
3
+ //
4
+ // The file dialog's ladder again (docs/filedialog.md), two rungs this time:
5
+ //
6
+ // 1. **the portal** — `org.freedesktop.portal.Screenshot.PickColor`. The
7
+ // desktop draws its own magnifier and hands back the colour, which is
8
+ // also the only route that works under a compositor that would refuse a
9
+ // root read, and the only route Wayland has at all. Needs version 2 of
10
+ // the Screenshot interface — XFCE ships none, GNOME and KDE ship 2 —
11
+ // so the gate is the interface's `version` property, not `hasService()`.
12
+ // 2. **X11** — grab the pointer with a crosshair, wait for the click,
13
+ // `GetImage` a 1×1 at it, decode by the server's own pixel layout.
14
+ // Reached under a bare WM, over ssh, on XQuartz: everywhere there is a
15
+ // display and nothing else, which is the case react-x11 exists for.
16
+ //
17
+ // There is no third rung to draw, because the thing being read — the whole
18
+ // screen — is precisely what an application cannot draw itself. So unlike
19
+ // `useFileDialog()`, `useEyedropper()` adds no rung; it adds the binding a
20
+ // component wants (`picking`, `supported`, the owner window) over the same
21
+ // two.
22
+ //
23
+ // ## The grab is the dangerous part
24
+ //
25
+ // An application that leaks a pointer grab leaves a desktop that has stopped
26
+ // answering clicks. Everything in the X11 rung is shaped by that: one
27
+ // `settle()` gate owns the cleanup, the abort listener goes on before the
28
+ // first request goes out, cancellation releases the grab before it reports,
29
+ // and losing the grab to someone else is detected (the server says so, with
30
+ // a LeaveNotify of mode Ungrab) rather than waited out.
31
+
32
+ import { pixelLayout, toStraightRgba } from 'ntk';
33
+
34
+ import { sessionBus } from './bus.js';
35
+ import { XK_ESCAPE, XK_KP_ENTER, XK_RETURN, XK_SPACE } from './keysyms.js';
36
+ import {
37
+ PORTAL_NAME,
38
+ PortalCancelledError,
39
+ RESPONSE_OK,
40
+ hasService,
41
+ parentWindowHandle,
42
+ portalRequest,
43
+ portalVersion,
44
+ } from './portal.js';
45
+ import { windowIdOf } from './windowid.js';
46
+
47
+ export const SCREENSHOT_IFACE = 'org.freedesktop.portal.Screenshot';
48
+ /** `PickColor` arrived in version 2 of the Screenshot interface. */
49
+ const PICK_COLOR_VERSION = 2;
50
+
51
+ /**
52
+ * Nothing here can sample the screen, and nothing can be drawn instead.
53
+ *
54
+ * A **typed** rejection, the `NoFileDialogError` rule: a caller hides or
55
+ * disables its eyedropper button rather than crashing — which is what
56
+ * `useEyedropper().supported` does for you.
57
+ */
58
+ export class NoScreenColorError extends Error {
59
+ constructor(message, cause) {
60
+ super(
61
+ `react-x11: ${
62
+ message ??
63
+ 'no way to sample a colour from the screen — there is no ' +
64
+ 'Screenshot portal with PickColor (interface version 2) on the ' +
65
+ 'session bus, and no X connection was given for the fallback. ' +
66
+ 'Pass `app` (from createRoot() or useApp()), or use useEyedropper().'
67
+ }`,
68
+ { cause },
69
+ );
70
+ this.name = 'NoScreenColorError';
71
+ }
72
+ }
73
+
74
+ // --------------------------------------------------------------------------
75
+ // Rung 1: the portal
76
+ // --------------------------------------------------------------------------
77
+
78
+ /**
79
+ * The portal's `(ddd)` — sRGB in [0, 1] — as `'#rrggbb'`, or null when the
80
+ * triple is not one. A string because every consumer wants to paint with it;
81
+ * the same reasoning as the appearance portal's accent colour.
82
+ */
83
+ function hexFromPortalColor(triple) {
84
+ if (!Array.isArray(triple) || triple.length < 3) return null;
85
+ const channels = triple.slice(0, 3);
86
+ if (!channels.every((c) => typeof c === 'number' && Number.isFinite(c))) {
87
+ return null;
88
+ }
89
+ return (
90
+ '#' +
91
+ channels
92
+ .map((c) =>
93
+ Math.round(Math.min(1, Math.max(0, c)) * 255)
94
+ .toString(16)
95
+ .padStart(2, '0'),
96
+ )
97
+ .join('')
98
+ );
99
+ }
100
+
101
+ async function portalPick(opts, ref) {
102
+ const { response, results } = await portalRequest(ref, {
103
+ iface: SCREENSHOT_IFACE,
104
+ member: 'PickColor',
105
+ // Not FileChooser's shape: PickColor is `(s parent_window, a{sv}
106
+ // options)` — no title — which is the whole reason portalRequest takes a
107
+ // signature now.
108
+ signature: 'sa{sv}',
109
+ args: [parentWindowHandle(windowIdOf(opts.parentWindow))],
110
+ options: {},
111
+ signal: opts.signal,
112
+ });
113
+ if (response !== RESPONSE_OK) throw new PortalCancelledError(response);
114
+ const hex = hexFromPortalColor(results?.color);
115
+ if (!hex) {
116
+ throw new Error(
117
+ 'react-x11: the Screenshot portal answered PickColor without a ' +
118
+ 'colour — expected a (ddd) triple in `results.color`, got ' +
119
+ `${JSON.stringify(results?.color)}.`,
120
+ );
121
+ }
122
+ return hex;
123
+ }
124
+
125
+ /** Portal reachable *and* recent enough for PickColor, on this ref. */
126
+ async function portalCanPick(ref) {
127
+ return (
128
+ (await hasService(PORTAL_NAME, ref)) &&
129
+ (await portalVersion(SCREENSHOT_IFACE, ref)) >= PICK_COLOR_VERSION
130
+ );
131
+ }
132
+
133
+ // --------------------------------------------------------------------------
134
+ // Rung 2: X11
135
+ // --------------------------------------------------------------------------
136
+
137
+ // x11.eventMask bits, spelled out the way xsettings.js spells its one. No
138
+ // key mask anywhere: GrabKeyboard has no event-mask argument — a keyboard
139
+ // grab delivers every key event to the grab window unconditionally.
140
+ const BUTTON_MASK = 4 | 8; // ButtonPress | ButtonRelease
141
+ const CROSSING_MASK = 0x10 | 0x20; // EnterWindow | LeaveWindow
142
+
143
+ // Core event types.
144
+ const KEY_PRESS = 2;
145
+ const BUTTON_PRESS = 4;
146
+ const LEAVE_NOTIFY = 8;
147
+
148
+ /** LeaveNotify `mode` when a grab this client held was released. */
149
+ const NOTIFY_UNGRAB = 2;
150
+
151
+ const GRAB_STATUS = {
152
+ 1:
153
+ 'another application is holding a pointer grab (a menu or a drag is ' +
154
+ 'probably open) — try again once it closes',
155
+ 2: 'the grab was refused for an out-of-order timestamp (InvalidTime)',
156
+ 3: 'the grab window is not viewable, which is a react-x11 bug',
157
+ 4: 'the pointer is frozen by another grab (GrabFrozen)',
158
+ };
159
+
160
+ /**
161
+ * One pick per connection at a time. A second `GrabPointer` from the same
162
+ * client silently *replaces* the first — two concurrent picks would settle
163
+ * one of them with the other's click, so the second is refused loudly
164
+ * instead. `useEyedropper()` never gets here: it hands the in-flight promise
165
+ * back.
166
+ */
167
+ const inflight = new WeakSet();
168
+
169
+ /**
170
+ * The keysyms a keycode can produce, from the map ntk keeps current. An
171
+ * unfilled map — possible in the first moments of a connection — answers
172
+ * `[]`, which quietly costs the keyboard shortcuts and nothing else: the
173
+ * click and the abort signal never depend on it.
174
+ */
175
+ function keysymsOf(X, keycode) {
176
+ return X.keycode2keysyms?.[keycode] ?? [];
177
+ }
178
+
179
+ /**
180
+ * GrabKeyboard, marshalled by hand — **do not use `X.GrabKeyboard` here.**
181
+ *
182
+ * node-x11 writes pointer-mode and keyboard-mode at request offsets 10/11,
183
+ * which the protocol says are the top half of the *time* field; the real
184
+ * offsets, 12/13, stay zero. So every `X.GrabKeyboard` with CurrentTime and
185
+ * asynchronous modes reaches a real server as a garbage timestamp with
186
+ * **synchronous** modes: Xorg answers InvalidTime, and a time that survived
187
+ * would freeze the keyboard. The pure-JS test server never reads those
188
+ * bytes, which is how the bug stays green headlessly — this was found by
189
+ * running the pick on a live display. Marshalled the way node-x11's own
190
+ * extension modules marshal what the core tables lack; delete when the fix
191
+ * lands upstream.
192
+ */
193
+ function grabKeyboard(X, wid, cb) {
194
+ X.seq_num++;
195
+ const b = Buffer.alloc(16);
196
+ b[0] = 31; // GrabKeyboard
197
+ b[1] = 0; // owner-events: false
198
+ b.writeUInt16LE(4, 2);
199
+ b.writeUInt32LE(wid >>> 0, 4);
200
+ b.writeUInt32LE(0, 8); // CurrentTime
201
+ b[12] = 1; // pointer-mode: Asynchronous
202
+ b[13] = 1; // keyboard-mode: Asynchronous
203
+ X.pack_stream.put(b);
204
+ // The status rides byte 1 of the reply header, which the reply dispatcher
205
+ // hands over as the unpack's second argument.
206
+ X.replies[X.seq_num] = [(buf, status) => status, cb];
207
+ X.pack_stream.submit(true);
208
+ }
209
+
210
+ /**
211
+ * Restore the grab of a `<popup grab>` the pick displaced.
212
+ *
213
+ * A grabbing popup — an open Menu, Select, or the colour panel the
214
+ * eyedropper button itself sits in — holds this client's pointer grab, and
215
+ * our `GrabPointer` **replaced** it (same client, so no AlreadyGrabbed).
216
+ * The popup is never told; nothing in X says "your grab moved". After the
217
+ * pick releases, the popup would be left open with dismiss-on-outside-click
218
+ * silently dead — so the grab is handed back here, by the renderer, which is
219
+ * the one party that can know both grabs existed.
220
+ */
221
+ function regrabPopup(app) {
222
+ let found = null;
223
+ const walk = (node) => {
224
+ if (!node) return;
225
+ if (
226
+ node.isPopup &&
227
+ node.props?.grab &&
228
+ node.window &&
229
+ !node.destroyed &&
230
+ !node._anchorLost
231
+ ) {
232
+ // Later in tree order approximates higher in the stack; with the
233
+ // renderer's own rule of one grabbing popup at a time (submenus ride
234
+ // the root menu's grab) there is at most one anyway.
235
+ found = node;
236
+ }
237
+ for (const child of node.children ?? []) walk(child);
238
+ };
239
+ for (const root of app._rootChildren ?? []) walk(root);
240
+ found?.window?.grabPointer?.({}, () => {});
241
+ }
242
+
243
+ /**
244
+ * The classic route: grab, crosshair, click, Escape cancels.
245
+ *
246
+ * Resolves `'#rrggbb'` on a click (or Return/space/KP_Enter, which pick at
247
+ * the pointer, GTK's shape), `null` on Escape, rejects on abort — with the
248
+ * grab released on every one of those paths before the promise settles.
249
+ *
250
+ * No magnifier and no live preview, deliberately: the desktops whose users
251
+ * expect a loupe have a portal that draws one.
252
+ */
253
+ function x11Pick(opts, app) {
254
+ const X = app.X;
255
+ if (inflight.has(app)) {
256
+ return Promise.reject(
257
+ new Error(
258
+ 'react-x11: pickScreenColor() is already waiting for a click on ' +
259
+ 'this connection. One eyedropper at a time — disable the button ' +
260
+ 'while `useEyedropper().picking` is true.',
261
+ ),
262
+ );
263
+ }
264
+ inflight.add(app);
265
+
266
+ return new Promise((resolve, reject) => {
267
+ const root = X.display.screen[0].root;
268
+ // The crosshair is feedback, not mechanism: an app whose ntk predates
269
+ // cursors still picks, with the arrow.
270
+ let cursor = 0;
271
+ try {
272
+ cursor = app.cursors?.get?.('crosshair') ?? 0;
273
+ } catch {
274
+ cursor = 0;
275
+ }
276
+
277
+ // The grab window. Invisible twice over — InputOnly, and offscreen —
278
+ // because a mapped InputOnly window *does* intercept input wherever it
279
+ // sits, and (0, 0) is somebody's hot corner. It exists so that grabbed
280
+ // events land on an id nothing else owns: delivering them through a real
281
+ // window of the tree would put every pick click through the renderer's
282
+ // own dispatcher, and the app would see itself being clicked.
283
+ const wid = X.AllocID();
284
+ X.CreateWindow(wid, root, -100, -100, 1, 1, 0, 0, 2 /* InputOnly */, 0, {
285
+ overrideRedirect: true,
286
+ // The grab's own event mask governs delivery of the grabbed events;
287
+ // this attribute mask is for the crossing events the *server* sends
288
+ // about the grab itself — LeaveNotify mode Ungrab is how we learn the
289
+ // grab was taken from us (see below).
290
+ eventMask: CROSSING_MASK,
291
+ });
292
+ X.MapWindow(wid);
293
+
294
+ let settled = false;
295
+ let grabSent = false;
296
+ let keyboardGrabbed = false;
297
+ let regrabs = 0;
298
+
299
+ const cleanup = () => {
300
+ X.removeListener('event', onEvent);
301
+ if (opts.signal) opts.signal.removeEventListener('abort', onAbort);
302
+ try {
303
+ if (grabSent) X.UngrabPointer(0);
304
+ if (keyboardGrabbed) X.UngrabKeyboard(0);
305
+ X.DestroyWindow(wid);
306
+ X.ReleaseID(wid);
307
+ } catch {
308
+ // The connection died mid-pick. The server released the grab with
309
+ // it, which is the outcome cleanup exists to guarantee.
310
+ }
311
+ inflight.delete(app);
312
+ // After our ungrab, never before: X holds one active grab per client,
313
+ // so handing the popup its grab back first would only have ours
314
+ // replace it again on the next line.
315
+ if (grabSent) {
316
+ try {
317
+ regrabPopup(app);
318
+ } catch {
319
+ // A half-torn-down tree mid-unmount. The popup this would have
320
+ // served is on its way out with it.
321
+ }
322
+ }
323
+ };
324
+
325
+ /** Every way out funnels through here, so the grab cannot outlive us. */
326
+ const settle = (fn) => {
327
+ if (settled) return;
328
+ settled = true;
329
+ cleanup();
330
+ fn();
331
+ };
332
+
333
+ const fail = (message, cause) =>
334
+ settle(() => reject(new Error(`react-x11: ${message}`, { cause })));
335
+
336
+ // `'#rrggbb'` from a 1×1 GetImage at root coordinates. The reply is raw
337
+ // server words; `pixelLayout`/`toStraightRgba` are the "what do these
338
+ // bytes mean" answer, asked of the display rather than assumed — the
339
+ // lesson scripts/capture.js already learned the hard way.
340
+ const sample = (drawable, x, y) => {
341
+ X.GetImage(
342
+ 2 /* ZPixmap */,
343
+ drawable,
344
+ x,
345
+ y,
346
+ 1,
347
+ 1,
348
+ 0xffffffff,
349
+ (err, img) => {
350
+ if (err) {
351
+ return reject(
352
+ new Error(
353
+ 'react-x11: could not read the picked pixel back ' +
354
+ `(GetImage at ${x},${y})`,
355
+ { cause: err },
356
+ ),
357
+ );
358
+ }
359
+ try {
360
+ const layout = pixelLayout(X.display, img.depth);
361
+ const [r, g, b] = toStraightRgba(img.data, layout, 1, 1);
362
+ resolve(
363
+ '#' +
364
+ [r, g, b].map((c) => c.toString(16).padStart(2, '0')).join(''),
365
+ );
366
+ } catch (cause) {
367
+ // A 16-bit display, in practice. Rare enough to report rather
368
+ // than unpack by hand.
369
+ reject(
370
+ new Error(
371
+ `react-x11: could not decode the picked pixel — ${cause.message}`,
372
+ { cause },
373
+ ),
374
+ );
375
+ }
376
+ },
377
+ );
378
+ };
379
+
380
+ // Ungrab first, then read: the crosshair reverts on the very click, and
381
+ // a failed read can no longer strand the grab. Safe against repaints
382
+ // because the grabbed click reached no application — nothing gained
383
+ // focus, nothing redrew.
384
+ const pickAt = (drawable, x, y) => settle(() => sample(drawable, x, y));
385
+
386
+ const onEvent = (ev) => {
387
+ if (settled || ev.wid !== wid) return;
388
+ if (ev.type === BUTTON_PRESS) {
389
+ // Button 1 picks. The rest are ignored rather than treated as
390
+ // cancel: a stray middle-click paste reflex should not silently eat
391
+ // the pick, and Escape is one key away.
392
+ if (ev.keycode === 1) pickAt(ev.root || root, ev.rootx, ev.rooty);
393
+ return;
394
+ }
395
+ if (ev.type === KEY_PRESS) {
396
+ const syms = keysymsOf(X, ev.keycode);
397
+ if (syms.includes(XK_ESCAPE)) {
398
+ settle(() => resolve(null));
399
+ } else if (
400
+ syms.includes(XK_RETURN) ||
401
+ syms.includes(XK_KP_ENTER) ||
402
+ syms.includes(XK_SPACE)
403
+ ) {
404
+ // Pick at the pointer without a click — the keyboard's half of the
405
+ // gesture, same as GTK's dropper.
406
+ X.QueryPointer(root, (err, pointer) => {
407
+ if (settled) return;
408
+ if (err || !pointer)
409
+ return fail('could not locate the pointer', err);
410
+ pickAt(pointer.root || root, pointer.rootX, pointer.rootY);
411
+ });
412
+ }
413
+ return;
414
+ }
415
+ // The grab was taken from us — LeaveNotify with mode Ungrab on the
416
+ // grab window is the one signal X gives. Not hypothetical: this
417
+ // client's own popups release the active grab in their teardown, so a
418
+ // pick started from a closing menu loses its grab a tick later. Take
419
+ // it back; the alternative is a pick that silently never resolves
420
+ // while the app answers clicks as if nothing were happening.
421
+ if (ev.type === LEAVE_NOTIFY && ev.mode === NOTIFY_UNGRAB && grabSent) {
422
+ if (++regrabs > 5) {
423
+ return fail(
424
+ 'the pointer grab kept being released out from under the ' +
425
+ 'eyedropper — something on this connection is calling ' +
426
+ 'UngrabPointer repeatedly',
427
+ );
428
+ }
429
+ X.GrabPointer(
430
+ wid,
431
+ false,
432
+ BUTTON_MASK,
433
+ 1 /* async */,
434
+ 1 /* async */,
435
+ 0,
436
+ cursor,
437
+ 0,
438
+ (err, status) => {
439
+ if (settled) return;
440
+ if (err || status !== 0) {
441
+ fail(
442
+ `lost the pointer grab and could not take it back — ${
443
+ GRAB_STATUS[status] ?? `status ${status}`
444
+ }`,
445
+ err ?? undefined,
446
+ );
447
+ }
448
+ },
449
+ );
450
+ }
451
+ };
452
+
453
+ const onAbort = () =>
454
+ settle(() => reject(opts.signal.reason ?? new PortalCancelledError()));
455
+
456
+ // Abort wiring before the first request goes out, the portalRequest
457
+ // lesson: an abort landing while the grab replies are in flight must
458
+ // still release everything.
459
+ if (opts.signal) {
460
+ if (opts.signal.aborted) {
461
+ // Nothing sent yet; unwind what this function itself created.
462
+ X.removeListener?.('event', onEvent);
463
+ try {
464
+ X.DestroyWindow(wid);
465
+ X.ReleaseID(wid);
466
+ } catch {
467
+ // never mind — the reject below is the answer
468
+ }
469
+ inflight.delete(app);
470
+ settled = true;
471
+ return reject(opts.signal.reason ?? new PortalCancelledError());
472
+ }
473
+ opts.signal.addEventListener('abort', onAbort, { once: true });
474
+ }
475
+
476
+ // The listener goes on before the grab request: events start the moment
477
+ // the grab activates, and the reply races them on a busy connection.
478
+ X.on('event', onEvent);
479
+
480
+ // ownerEvents: **false**. Every pointer event during the pick reports to
481
+ // the grab window and only there — with true, a click over the app's own
482
+ // windows would flow through the renderer's dispatcher and the app would
483
+ // handle a press that was meant for the eyedropper.
484
+ grabSent = true;
485
+ X.GrabPointer(
486
+ wid,
487
+ false,
488
+ BUTTON_MASK,
489
+ 1 /* async */,
490
+ 1 /* async */,
491
+ 0 /* no confineTo: the whole screen is the target */,
492
+ cursor,
493
+ 0 /* CurrentTime */,
494
+ (err, status) => {
495
+ if (settled) return;
496
+ if (err || status !== 0) {
497
+ return fail(
498
+ `could not grab the pointer to pick a colour — ${
499
+ GRAB_STATUS[status] ?? `status ${status}`
500
+ }`,
501
+ err ?? undefined,
502
+ );
503
+ }
504
+ // The keyboard grab is what makes Escape work wherever the focus
505
+ // happens to be. Refusal is survivable — the click and the abort
506
+ // signal still end the pick — so a failure here degrades instead of
507
+ // failing the pick.
508
+ grabKeyboard(X, wid, (kbErr, kbStatus) => {
509
+ if (!kbErr && kbStatus === 0) {
510
+ keyboardGrabbed = true;
511
+ // The pick may have settled while this reply was in flight; the
512
+ // grab it just took has to go with it.
513
+ if (settled) {
514
+ try {
515
+ X.UngrabKeyboard(0);
516
+ } catch {
517
+ // connection gone, grab gone with it
518
+ }
519
+ }
520
+ }
521
+ });
522
+ },
523
+ );
524
+ });
525
+ }
526
+
527
+ // --------------------------------------------------------------------------
528
+ // The ladder
529
+ // --------------------------------------------------------------------------
530
+
531
+ /**
532
+ * The connection behind a `parentWindow`, when it points at a live node —
533
+ * so `pickScreenColor({ parentWindow: winRef })` reaches the X11 rung
534
+ * without the caller repeating the app it is already naming a window of.
535
+ */
536
+ function appOf(target) {
537
+ if (!target || typeof target !== 'object') return null;
538
+ if ('current' in target && !target.isWindow) return appOf(target.current);
539
+ return target.app ?? target.window?.app ?? target.root?.window?.app ?? null;
540
+ }
541
+
542
+ /** The connection a pick would use, or null. */
543
+ function appFor(opts) {
544
+ return opts.app ?? appOf(opts.parentWindow);
545
+ }
546
+
547
+ /**
548
+ * Which rung this machine lands on, without grabbing anything.
549
+ *
550
+ * `'portal'` needs the Screenshot interface at version 2 — the probe reads
551
+ * the interface's `version` property, because `hasService()` cannot see
552
+ * which interfaces a portal's backends actually provide (XFCE's provides no
553
+ * Screenshot at all). `'x11'` needs a connection to answer with, so pass
554
+ * `app` (or a `parentWindow` that resolves to one); without either the
555
+ * fallback is unreachable and the honest answer is `null`.
556
+ *
557
+ * Acquires a bus reference and releases it, so it is cheap but not free —
558
+ * `useEyedropper().supported` caches it for you.
559
+ *
560
+ * @returns {Promise<'portal'|'x11'|null>}
561
+ */
562
+ export async function screenColorBackend(options = {}) {
563
+ const backend = options.backend;
564
+ if (!backend || backend === 'portal') {
565
+ const ref = await sessionBus();
566
+ if (ref) {
567
+ try {
568
+ if (await portalCanPick(ref)) return 'portal';
569
+ } finally {
570
+ await ref.release();
571
+ }
572
+ }
573
+ if (backend === 'portal') return null;
574
+ }
575
+ return appFor(options) ? 'x11' : null;
576
+ }
577
+
578
+ async function runPick(opts) {
579
+ const wantPortal = !opts.backend || opts.backend === 'portal';
580
+ if (wantPortal) {
581
+ const ref = await sessionBus();
582
+ if (ref) {
583
+ try {
584
+ if (await portalCanPick(ref)) {
585
+ return await portalPick(opts, ref);
586
+ }
587
+ } finally {
588
+ await ref.release();
589
+ }
590
+ }
591
+ if (opts.backend === 'portal') {
592
+ throw new NoScreenColorError(
593
+ 'no Screenshot portal with PickColor here — the session bus has no ' +
594
+ `${SCREENSHOT_IFACE} at version ${PICK_COLOR_VERSION} or newer — ` +
595
+ "and backend: 'portal' rules out the X11 fallback.",
596
+ );
597
+ }
598
+ }
599
+
600
+ const app = appFor(opts);
601
+ if (app) return x11Pick(opts, app);
602
+ throw new NoScreenColorError(
603
+ opts.backend === 'x11'
604
+ ? "backend: 'x11' needs a connection to grab on. Pass `app` (from " +
605
+ 'createRoot() or useApp()), or a `parentWindow` that points at a ' +
606
+ 'mounted window.'
607
+ : undefined,
608
+ );
609
+ }
610
+
611
+ /**
612
+ * Sample one pixel from the screen: the desktop's own picker where there is
613
+ * one, a crosshair grab on plain X11 everywhere else.
614
+ *
615
+ * ```js
616
+ * const hex = await pickScreenColor({ app });
617
+ * if (hex) setFill(hex); // '#rrggbb'; null means cancelled
618
+ * ```
619
+ *
620
+ * Resolves to **`'#rrggbb'`**, or `null` when the user cancelled (Escape, or
621
+ * the portal dialog's own cancel) — cancelling is an ordinary outcome and
622
+ * should not need a `try`. Rejects with {@link NoScreenColorError} when
623
+ * neither rung is reachable, which is the signal to hide the button;
624
+ * `signal` aborts the pick and releases the grab before the rejection is
625
+ * reported.
626
+ *
627
+ * In a component, reach for {@link useEyedropper} instead — it binds the
628
+ * connection and the owner window, and exposes `picking`/`supported` as
629
+ * render state.
630
+ *
631
+ * @returns {Promise<string | null>}
632
+ */
633
+ export async function pickScreenColor(options = {}) {
634
+ try {
635
+ return await runPick(options);
636
+ } catch (err) {
637
+ if (err instanceof PortalCancelledError) return null;
638
+ throw err;
639
+ }
640
+ }