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
|
@@ -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
|
+
}
|