react-x11 2.13.0 → 2.14.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/package.json +11 -8
- package/src/Reconciler.js +34 -21
- package/src/components/Select.js +8 -2
- package/src/events.js +8 -2
- package/src/index.d.ts +5 -0
- package/src/nodes/boxpaint.js +9 -0
- package/src/nodes/preedit.js +64 -14
- package/src/scale.js +52 -22
- package/src/screencolor.js +104 -17
- package/src/wayland/app.js +560 -0
- package/src/wayland/backendwindow.js +1123 -0
- package/src/wayland/clipboard.js +326 -0
- package/src/wayland/connection.js +482 -0
- package/src/wayland/context2d.js +2133 -0
- package/src/wayland/decorations.js +476 -0
- package/src/wayland/dmabuf.js +89 -0
- package/src/wayland/dnd.js +581 -0
- package/src/wayland/fdutil.js +108 -0
- package/src/wayland/framestyle.js +257 -0
- package/src/wayland/glarea.js +371 -0
- package/src/wayland/glcontext.js +415 -0
- package/src/wayland/glyphatlas.js +237 -0
- package/src/wayland/input.js +417 -0
- package/src/wayland/keysymnames.js +35 -0
- package/src/wayland/layershell.js +363 -0
- package/src/wayland/outputs.js +601 -0
- package/src/wayland/protocols/cursor-shape-v1.json +1 -0
- package/src/wayland/protocols/ext-idle-notify-v1.json +1 -0
- package/src/wayland/protocols/ext-image-capture-source-v1.json +1 -0
- package/src/wayland/protocols/ext-image-copy-capture-v1.json +1 -0
- package/src/wayland/protocols/fractional-scale-v1.json +1 -0
- package/src/wayland/protocols/index.json +127 -0
- package/src/wayland/protocols/keyboard-shortcuts-inhibit-unstable-v1.json +1 -0
- package/src/wayland/protocols/linux-dmabuf-v1.json +1 -0
- package/src/wayland/protocols/pointer-constraints-unstable-v1.json +1 -0
- package/src/wayland/protocols/presentation-time.json +1 -0
- package/src/wayland/protocols/primary-selection-unstable-v1.json +1 -0
- package/src/wayland/protocols/relative-pointer-unstable-v1.json +1 -0
- package/src/wayland/protocols/tablet-v2.json +1 -0
- package/src/wayland/protocols/text-input-unstable-v3.json +1 -0
- package/src/wayland/protocols/viewporter.json +1 -0
- package/src/wayland/protocols/wayland.json +1 -0
- package/src/wayland/protocols/wlr-layer-shell-unstable-v1.json +1 -0
- package/src/wayland/protocols/wlr-screencopy-unstable-v1.json +1 -0
- package/src/wayland/protocols/xdg-activation-v1.json +1 -0
- package/src/wayland/protocols/xdg-decoration-unstable-v1.json +1 -0
- package/src/wayland/protocols/xdg-output-unstable-v1.json +1 -0
- package/src/wayland/protocols/xdg-shell.json +1 -0
- package/src/wayland/protocols/xdg-toplevel-icon-v1.json +1 -0
- package/src/wayland/readback.js +99 -0
- package/src/wayland/screencopy.js +584 -0
- package/src/wayland/seat.js +584 -0
- package/src/wayland/shm.js +226 -0
- package/src/wayland/ssd.js +106 -0
- package/src/wayland/surface.js +123 -0
- package/src/wayland/swapchain.js +411 -0
- package/src/wayland/tablet.js +522 -0
- package/src/wayland/target.js +263 -0
- package/src/wayland/text.js +113 -0
- package/src/wayland/textinput.js +671 -0
- package/src/wayland/touch.js +284 -0
- package/src/wayland/window.js +827 -0
- package/src/wayland/xkb.js +425 -0
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
// Reading the screen: a capture of an output into shared memory, and the
|
|
2
|
+
// eyedropper built on it.
|
|
3
|
+
//
|
|
4
|
+
// A Wayland client cannot read the screen — that is most of the point of
|
|
5
|
+
// Wayland — so what this offers depends entirely on what the compositor
|
|
6
|
+
// advertises, and it advertises one of two things or neither:
|
|
7
|
+
//
|
|
8
|
+
// - `ext_image_copy_capture_manager_v1` (the standard successor, wlroots
|
|
9
|
+
// 0.19+, KDE 6.2+): a *session* on a capture source describes the buffer
|
|
10
|
+
// the compositor wants — size and formats — and each *frame* is one
|
|
11
|
+
// capture into a buffer the client attaches.
|
|
12
|
+
// - `zwlr_screencopy_manager_v1` (wlroots since 2018, still what sway 1.10
|
|
13
|
+
// has): one object per frame; the compositor first lists the buffer
|
|
14
|
+
// formats it accepts, the client makes one and asks for the copy.
|
|
15
|
+
//
|
|
16
|
+
// Both land pixels in a `wl_shm` buffer (shm.js) and both say when it is
|
|
17
|
+
// ready; the difference is bookkeeping, and `capture()` hides it. GNOME has
|
|
18
|
+
// neither — its screenshots go through the portal, which is a D-Bus dialog
|
|
19
|
+
// and lives in src/screencolor.js's ladder above this — so on GNOME
|
|
20
|
+
// `createScreenCapture` answers null and this file is inert.
|
|
21
|
+
//
|
|
22
|
+
// The eyedropper is the part worth having a compositor for. A client cannot
|
|
23
|
+
// grab the pointer or read the pixel under it, so the trick every wlroots
|
|
24
|
+
// colour picker uses is: freeze the screen into a capture, put that capture
|
|
25
|
+
// up as a layer-shell overlay covering the output, and take the click *on the
|
|
26
|
+
// overlay*, whose surface coordinates are the output's. The pixel is read out
|
|
27
|
+
// of the capture, not the screen — the screen is showing the capture anyway —
|
|
28
|
+
// and the overlay comes down. It needs layer-shell as well as a capture
|
|
29
|
+
// protocol; `canPick` says whether both are there.
|
|
30
|
+
|
|
31
|
+
import { WaylandWindow } from './window.js';
|
|
32
|
+
import {
|
|
33
|
+
createLayerSurface,
|
|
34
|
+
LAYER,
|
|
35
|
+
ALL_EDGES,
|
|
36
|
+
KEYBOARD,
|
|
37
|
+
} from './layershell.js';
|
|
38
|
+
import {
|
|
39
|
+
ShmBuffer,
|
|
40
|
+
SHM_FORMAT,
|
|
41
|
+
flipRows,
|
|
42
|
+
isSupportedShmFormat,
|
|
43
|
+
shmPixel,
|
|
44
|
+
shmToRGBA,
|
|
45
|
+
} from './shm.js';
|
|
46
|
+
import { XK_ESCAPE, XK_KP_ENTER, XK_RETURN, XK_SPACE } from '../keysyms.js';
|
|
47
|
+
|
|
48
|
+
/** `zwlr_screencopy_frame_v1.flags`. */
|
|
49
|
+
const WLR_Y_INVERT = 1;
|
|
50
|
+
|
|
51
|
+
/** `REACT_X11_WAYLAND_TRACE=1`: the eyedropper's steps on stderr. */
|
|
52
|
+
const TRACE = Boolean(process.env.REACT_X11_WAYLAND_TRACE);
|
|
53
|
+
const trace = (line) => {
|
|
54
|
+
if (TRACE) process.stderr.write(`react-x11 wayland: eyedropper ${line}\n`);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** A frame's formats, most convenient first: no byte swap, then no alpha. */
|
|
58
|
+
const FORMAT_PREFERENCE = [
|
|
59
|
+
SHM_FORMAT.XBGR8888,
|
|
60
|
+
SHM_FORMAT.ABGR8888,
|
|
61
|
+
SHM_FORMAT.XRGB8888,
|
|
62
|
+
SHM_FORMAT.ARGB8888,
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function pickFormat(offered) {
|
|
66
|
+
for (const f of FORMAT_PREFERENCE) if (offered.includes(f)) return f;
|
|
67
|
+
return offered.find(isSupportedShmFormat) ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function abortReason(signal) {
|
|
71
|
+
return (
|
|
72
|
+
signal?.reason ??
|
|
73
|
+
new DOMException('the screen pick was aborted', 'AbortError')
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Bind what the compositor has and hand back a `ScreenCapture`, or null when
|
|
79
|
+
* it has no capture protocol at all (GNOME) — the shape of every other
|
|
80
|
+
* optional global in app.js.
|
|
81
|
+
*
|
|
82
|
+
* @param {import('./app.js').WaylandApp} app
|
|
83
|
+
*/
|
|
84
|
+
export async function createScreenCapture(app) {
|
|
85
|
+
const conn = app.conn;
|
|
86
|
+
const shm = await conn.bind('wl_shm');
|
|
87
|
+
if (!shm) return null;
|
|
88
|
+
const ext = await conn.bind('ext_image_copy_capture_manager_v1');
|
|
89
|
+
const extSources = ext
|
|
90
|
+
? await conn.bind('ext_output_image_capture_source_manager_v1')
|
|
91
|
+
: null;
|
|
92
|
+
// `bind` negotiates the newest version the definition (3) and the
|
|
93
|
+
// compositor share; `_grabWlr` reads `frame.version` for the one place
|
|
94
|
+
// the versions differ in shape.
|
|
95
|
+
const wlr = await conn.bind('zwlr_screencopy_manager_v1');
|
|
96
|
+
if (!(ext && extSources) && !wlr) return null;
|
|
97
|
+
if (!conn.has('wl_output')) return null;
|
|
98
|
+
// An output of our own rather than the shared singleton, so its geometry
|
|
99
|
+
// events arrive here (they are sent once, right after the bind). The
|
|
100
|
+
// library keeps one global per interface name, so on a multi-head desktop
|
|
101
|
+
// this is the output advertised last; `capture({ output })` takes another.
|
|
102
|
+
const output = await conn.display.bind('wl_output');
|
|
103
|
+
output.setMaxListeners?.(0);
|
|
104
|
+
const capture = new ScreenCapture({
|
|
105
|
+
conn,
|
|
106
|
+
shm,
|
|
107
|
+
output,
|
|
108
|
+
ext: ext && extSources ? { manager: ext, sources: extSources } : null,
|
|
109
|
+
wlr,
|
|
110
|
+
compositor: app.compositor,
|
|
111
|
+
viewporter: app.viewporter,
|
|
112
|
+
layerShell: app.layerShell,
|
|
113
|
+
seat: app.seat,
|
|
114
|
+
});
|
|
115
|
+
return capture;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class ScreenCapture {
|
|
119
|
+
constructor({
|
|
120
|
+
conn,
|
|
121
|
+
shm,
|
|
122
|
+
output,
|
|
123
|
+
ext,
|
|
124
|
+
wlr,
|
|
125
|
+
compositor,
|
|
126
|
+
viewporter,
|
|
127
|
+
layerShell,
|
|
128
|
+
seat,
|
|
129
|
+
}) {
|
|
130
|
+
this.conn = conn;
|
|
131
|
+
this.shm = shm;
|
|
132
|
+
this.output = output;
|
|
133
|
+
this.ext = ext;
|
|
134
|
+
this.wlr = wlr;
|
|
135
|
+
this.compositor = compositor;
|
|
136
|
+
this.viewporter = viewporter;
|
|
137
|
+
this.layerShell = layerShell;
|
|
138
|
+
this.seat = seat;
|
|
139
|
+
/** what the compositor said about `output` */
|
|
140
|
+
this.outputInfo = {
|
|
141
|
+
x: 0,
|
|
142
|
+
y: 0,
|
|
143
|
+
width: 0,
|
|
144
|
+
height: 0,
|
|
145
|
+
scale: 1,
|
|
146
|
+
transform: 0,
|
|
147
|
+
name: null,
|
|
148
|
+
};
|
|
149
|
+
this._pick = null;
|
|
150
|
+
this.destroyed = false;
|
|
151
|
+
this._wireOutput(output);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
_wireOutput(output) {
|
|
155
|
+
const info = this.outputInfo;
|
|
156
|
+
output.on('geometry', (x, y, _pw, _ph, _sub, _make, _model, transform) => {
|
|
157
|
+
info.x = x;
|
|
158
|
+
info.y = y;
|
|
159
|
+
info.transform = transform;
|
|
160
|
+
});
|
|
161
|
+
output.on('mode', (flags, width, height) => {
|
|
162
|
+
if (flags & 1 /* current */) {
|
|
163
|
+
info.width = width;
|
|
164
|
+
info.height = height;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
output.on('scale', (factor) => {
|
|
168
|
+
info.scale = factor;
|
|
169
|
+
});
|
|
170
|
+
output.on('name', (name) => {
|
|
171
|
+
info.name = name;
|
|
172
|
+
});
|
|
173
|
+
output.on('description', () => {});
|
|
174
|
+
output.on('done', () => {});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Which protocol a capture goes through: 'ext' | 'wlr'. */
|
|
178
|
+
get route() {
|
|
179
|
+
return this.ext ? 'ext' : this.wlr ? 'wlr' : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Whether the interactive pick is possible: a capture, and an overlay to click on. */
|
|
183
|
+
get canPick() {
|
|
184
|
+
return Boolean(
|
|
185
|
+
this.route && this.layerShell && this.seat && !this.destroyed,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---- capture -------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* One frame of an output, as straight RGBA, top row first — the shape
|
|
193
|
+
* `readback.js`'s `encodePNG` takes.
|
|
194
|
+
*
|
|
195
|
+
* @param {object} [opts]
|
|
196
|
+
* @param {object} [opts.output] a `wl_output` proxy; defaults to the bound one
|
|
197
|
+
* @param {boolean} [opts.cursor=false] paint the pointer into the frame
|
|
198
|
+
* @returns {Promise<{ width:number, height:number, data:Uint8Array }>}
|
|
199
|
+
*/
|
|
200
|
+
async capture(opts = {}) {
|
|
201
|
+
const frame = await this._grab(opts);
|
|
202
|
+
try {
|
|
203
|
+
return shmToRGBA({
|
|
204
|
+
bytes: frame.buffer.read(),
|
|
205
|
+
width: frame.width,
|
|
206
|
+
height: frame.height,
|
|
207
|
+
stride: frame.stride,
|
|
208
|
+
format: frame.format,
|
|
209
|
+
yInvert: frame.yInvert,
|
|
210
|
+
});
|
|
211
|
+
} finally {
|
|
212
|
+
frame.buffer.destroy();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The pixel at an output-relative point, in device pixels, as 0–255
|
|
218
|
+
* channels. A capture per call: for many pixels, `capture()` once.
|
|
219
|
+
*/
|
|
220
|
+
async pixelAt(x, y, opts = {}) {
|
|
221
|
+
const frame = await this._grab(opts);
|
|
222
|
+
try {
|
|
223
|
+
const bytes = frame.buffer.read();
|
|
224
|
+
if (frame.yInvert) flipRows(bytes, frame.stride, frame.height);
|
|
225
|
+
const px = clamp(Math.round(x), 0, frame.width - 1);
|
|
226
|
+
const py = clamp(Math.round(y), 0, frame.height - 1);
|
|
227
|
+
return shmPixel(bytes, frame, px, py);
|
|
228
|
+
} finally {
|
|
229
|
+
frame.buffer.destroy();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The capture, still in its shm buffer — the overlay attaches that buffer
|
|
235
|
+
* as it is, so the pick never converts a whole screen.
|
|
236
|
+
*
|
|
237
|
+
* @returns {Promise<{ buffer: ShmBuffer, width, height, stride, format, yInvert, transform }>}
|
|
238
|
+
*/
|
|
239
|
+
_grab({ output = this.output, cursor = false } = {}) {
|
|
240
|
+
if (this.destroyed) {
|
|
241
|
+
return Promise.reject(new Error('the screen capture was destroyed'));
|
|
242
|
+
}
|
|
243
|
+
if (this.ext) return this._grabExt(output, cursor);
|
|
244
|
+
if (this.wlr) return this._grabWlr(output, cursor);
|
|
245
|
+
return Promise.reject(
|
|
246
|
+
new Error('this compositor advertises no screen capture protocol'),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
_grabWlr(output, cursor) {
|
|
251
|
+
return new Promise((resolve, reject) => {
|
|
252
|
+
const frame = this.wlr.$.capture_output(cursor ? 1 : 0, output.id);
|
|
253
|
+
const offered = [];
|
|
254
|
+
let chosen = null;
|
|
255
|
+
let buf = null;
|
|
256
|
+
let flags = 0;
|
|
257
|
+
let settled = false;
|
|
258
|
+
const finish = (fn) => {
|
|
259
|
+
if (settled) return;
|
|
260
|
+
settled = true;
|
|
261
|
+
try {
|
|
262
|
+
frame.$.destroy();
|
|
263
|
+
} catch {
|
|
264
|
+
/* gone */
|
|
265
|
+
}
|
|
266
|
+
fn();
|
|
267
|
+
};
|
|
268
|
+
const fail = (message) => {
|
|
269
|
+
buf?.destroy();
|
|
270
|
+
finish(() => reject(new Error(`react-x11 (wayland): ${message}`)));
|
|
271
|
+
};
|
|
272
|
+
const start = () => {
|
|
273
|
+
if (chosen) return;
|
|
274
|
+
const format = pickFormat(offered.map((o) => o.format));
|
|
275
|
+
chosen = offered.find((o) => o.format === format) ?? null;
|
|
276
|
+
if (!chosen) {
|
|
277
|
+
return fail(
|
|
278
|
+
`the compositor offers no shm format this client reads (offered ${offered
|
|
279
|
+
.map((o) => '0x' + o.format.toString(16))
|
|
280
|
+
.join(', ')})`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
buf = new ShmBuffer({
|
|
285
|
+
shm: this.shm,
|
|
286
|
+
width: chosen.width,
|
|
287
|
+
height: chosen.height,
|
|
288
|
+
stride: chosen.stride,
|
|
289
|
+
format: chosen.format,
|
|
290
|
+
name: 'react-x11-screencopy',
|
|
291
|
+
});
|
|
292
|
+
} catch (err) {
|
|
293
|
+
return fail(
|
|
294
|
+
`could not make a buffer for the capture: ${err.message}`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
frame.$.copy(buf.buffer.id);
|
|
298
|
+
};
|
|
299
|
+
frame.on('buffer', (format, width, height, stride) => {
|
|
300
|
+
offered.push({ format, width, height, stride });
|
|
301
|
+
// Before version 3 there is no buffer_done: one buffer event, then copy.
|
|
302
|
+
if (frame.version < 3) queueMicrotask(start);
|
|
303
|
+
});
|
|
304
|
+
frame.on('linux_dmabuf', () => {});
|
|
305
|
+
frame.on('buffer_done', start);
|
|
306
|
+
frame.on('flags', (f) => {
|
|
307
|
+
flags = f;
|
|
308
|
+
});
|
|
309
|
+
frame.on('damage', () => {});
|
|
310
|
+
frame.on('ready', () => {
|
|
311
|
+
finish(() =>
|
|
312
|
+
resolve({
|
|
313
|
+
buffer: buf,
|
|
314
|
+
width: chosen.width,
|
|
315
|
+
height: chosen.height,
|
|
316
|
+
stride: chosen.stride,
|
|
317
|
+
format: chosen.format,
|
|
318
|
+
yInvert: Boolean(flags & WLR_Y_INVERT),
|
|
319
|
+
transform: this.outputInfo.transform,
|
|
320
|
+
}),
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
frame.on('failed', () =>
|
|
324
|
+
fail('the compositor refused the screen capture'),
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
_grabExt(output, cursor) {
|
|
330
|
+
return new Promise((resolve, reject) => {
|
|
331
|
+
const source = this.ext.sources.$.create_source(output.id);
|
|
332
|
+
const session = this.ext.manager.$.create_session(
|
|
333
|
+
source.id,
|
|
334
|
+
cursor ? 1 : 0,
|
|
335
|
+
);
|
|
336
|
+
let size = null;
|
|
337
|
+
const formats = [];
|
|
338
|
+
let frame = null;
|
|
339
|
+
let buf = null;
|
|
340
|
+
let transform = 0;
|
|
341
|
+
let settled = false;
|
|
342
|
+
const finish = (fn) => {
|
|
343
|
+
if (settled) return;
|
|
344
|
+
settled = true;
|
|
345
|
+
try {
|
|
346
|
+
frame?.$.destroy();
|
|
347
|
+
session.$.destroy();
|
|
348
|
+
source.$.destroy();
|
|
349
|
+
} catch {
|
|
350
|
+
/* gone */
|
|
351
|
+
}
|
|
352
|
+
fn();
|
|
353
|
+
};
|
|
354
|
+
const fail = (message) => {
|
|
355
|
+
buf?.destroy();
|
|
356
|
+
finish(() => reject(new Error(`react-x11 (wayland): ${message}`)));
|
|
357
|
+
};
|
|
358
|
+
session.on('buffer_size', (width, height) => {
|
|
359
|
+
size = { width, height };
|
|
360
|
+
});
|
|
361
|
+
session.on('shm_format', (format) => formats.push(format));
|
|
362
|
+
session.on('dmabuf_device', () => {});
|
|
363
|
+
session.on('dmabuf_format', () => {});
|
|
364
|
+
session.on('stopped', () => fail('the capture session was stopped'));
|
|
365
|
+
session.on('done', () => {
|
|
366
|
+
if (frame) return; // constraints changed mid-capture; this frame stands
|
|
367
|
+
const format = pickFormat(formats);
|
|
368
|
+
if (!size || format == null) {
|
|
369
|
+
return fail(
|
|
370
|
+
`the compositor offers no shm format this client reads (offered ${formats
|
|
371
|
+
.map((f) => '0x' + f.toString(16))
|
|
372
|
+
.join(', ')})`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
buf = new ShmBuffer({
|
|
377
|
+
shm: this.shm,
|
|
378
|
+
width: size.width,
|
|
379
|
+
height: size.height,
|
|
380
|
+
format,
|
|
381
|
+
name: 'react-x11-screencopy',
|
|
382
|
+
});
|
|
383
|
+
} catch (err) {
|
|
384
|
+
return fail(
|
|
385
|
+
`could not make a buffer for the capture: ${err.message}`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
frame = session.$.create_frame();
|
|
389
|
+
frame.on('transform', (t) => {
|
|
390
|
+
transform = t;
|
|
391
|
+
});
|
|
392
|
+
frame.on('damage', () => {});
|
|
393
|
+
frame.on('presentation_time', () => {});
|
|
394
|
+
frame.on('ready', () =>
|
|
395
|
+
finish(() =>
|
|
396
|
+
resolve({
|
|
397
|
+
buffer: buf,
|
|
398
|
+
width: buf.width,
|
|
399
|
+
height: buf.height,
|
|
400
|
+
stride: buf.stride,
|
|
401
|
+
format: buf.format,
|
|
402
|
+
yInvert: false,
|
|
403
|
+
transform,
|
|
404
|
+
}),
|
|
405
|
+
),
|
|
406
|
+
);
|
|
407
|
+
frame.on('failed', (reason) =>
|
|
408
|
+
fail(
|
|
409
|
+
`the compositor refused the screen capture (${
|
|
410
|
+
['unknown', 'buffer constraints', 'stopped'][reason] ?? reason
|
|
411
|
+
})`,
|
|
412
|
+
),
|
|
413
|
+
);
|
|
414
|
+
frame.$.attach_buffer(buf.buffer.id);
|
|
415
|
+
frame.$.damage_buffer(0, 0, buf.width, buf.height);
|
|
416
|
+
frame.$.capture();
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---- the eyedropper --------------------------------------------------
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Freeze the screen, show it, take a click, answer the colour under it.
|
|
425
|
+
*
|
|
426
|
+
* Resolves `{ r, g, b }` — sRGB in 0–1, the Screenshot portal's `(ddd)`
|
|
427
|
+
* shape, so src/screencolor.js converts it with the same function as the
|
|
428
|
+
* portal's and the cocoa sampler's — or **null** when the user pressed
|
|
429
|
+
* Escape or the compositor took the overlay down. Return, space and
|
|
430
|
+
* KP_Enter pick at the pointer without a click, GTK's shape. Rejects on an
|
|
431
|
+
* abort (with `signal.reason`), after the overlay is gone.
|
|
432
|
+
*
|
|
433
|
+
* One pick at a time: a second call while one is up joins it, the rule
|
|
434
|
+
* the system sampler follows.
|
|
435
|
+
*
|
|
436
|
+
* @param {object} [opts]
|
|
437
|
+
* @param {AbortSignal} [opts.signal]
|
|
438
|
+
* @param {object} [opts.output] which output to freeze; defaults to the bound one
|
|
439
|
+
*/
|
|
440
|
+
pickColor({ signal, output = this.output } = {}) {
|
|
441
|
+
if (!this.canPick) {
|
|
442
|
+
return Promise.reject(
|
|
443
|
+
new Error(
|
|
444
|
+
'react-x11 (wayland): no screen pick here — it needs a capture protocol and wlr-layer-shell',
|
|
445
|
+
),
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
if (signal?.aborted) return Promise.reject(abortReason(signal));
|
|
449
|
+
if (this._pick) return this._pick;
|
|
450
|
+
this._pick = this._runPick(signal, output).finally(() => {
|
|
451
|
+
this._pick = null;
|
|
452
|
+
});
|
|
453
|
+
return this._pick;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async _runPick(signal, output) {
|
|
457
|
+
const frame = await this._grab({ output, cursor: false });
|
|
458
|
+
if (signal?.aborted) {
|
|
459
|
+
frame.buffer.destroy();
|
|
460
|
+
throw abortReason(signal);
|
|
461
|
+
}
|
|
462
|
+
// Rows the right way up, in place: the overlay shows the buffer as it
|
|
463
|
+
// is, and a pointer row indexes straight into it.
|
|
464
|
+
const bytes = frame.buffer.read();
|
|
465
|
+
if (frame.yInvert) {
|
|
466
|
+
flipRows(bytes, frame.stride, frame.height);
|
|
467
|
+
frame.buffer.write(bytes);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const seat = this.seat;
|
|
471
|
+
const win = createLayerSurface(WaylandWindow, {
|
|
472
|
+
conn: this.conn,
|
|
473
|
+
compositor: this.compositor,
|
|
474
|
+
layerShell: this.layerShell,
|
|
475
|
+
width: frame.width,
|
|
476
|
+
height: frame.height,
|
|
477
|
+
layer: LAYER.OVERLAY,
|
|
478
|
+
anchor: ALL_EDGES,
|
|
479
|
+
exclusiveZone: -1,
|
|
480
|
+
keyboardInteractivity: KEYBOARD.EXCLUSIVE,
|
|
481
|
+
namespace: 'react-x11-eyedropper',
|
|
482
|
+
output,
|
|
483
|
+
});
|
|
484
|
+
// A viewport maps the pixel-sized capture onto whatever logical size the
|
|
485
|
+
// overlay is configured to — which is how a fractional scale comes out
|
|
486
|
+
// right. Without one the integer output scale has to do.
|
|
487
|
+
if (this.viewporter) win.useScaling({ viewporter: this.viewporter });
|
|
488
|
+
else win.scale = Math.max(1, this.outputInfo.scale | 0);
|
|
489
|
+
|
|
490
|
+
return new Promise((resolve, reject) => {
|
|
491
|
+
let settled = false;
|
|
492
|
+
const previousCursor = seat._cursor;
|
|
493
|
+
const cleanup = () => {
|
|
494
|
+
seat.off('buttonpress', onPress);
|
|
495
|
+
seat.off('keydown', onKey);
|
|
496
|
+
win.off('configure', onConfigure);
|
|
497
|
+
win.off('close', onClose);
|
|
498
|
+
signal?.removeEventListener('abort', onAbort);
|
|
499
|
+
seat.setCursor(previousCursor ?? 'default');
|
|
500
|
+
win.destroy();
|
|
501
|
+
frame.buffer.destroy();
|
|
502
|
+
};
|
|
503
|
+
const finish = (fn) => {
|
|
504
|
+
if (settled) return;
|
|
505
|
+
settled = true;
|
|
506
|
+
cleanup();
|
|
507
|
+
fn();
|
|
508
|
+
};
|
|
509
|
+
const onConfigure = () => {
|
|
510
|
+
// The configured size is the output's logical size; the buffer is
|
|
511
|
+
// its pixel size. Ack, then show the frozen frame.
|
|
512
|
+
win.ackPending();
|
|
513
|
+
const s = win.surface;
|
|
514
|
+
s.$.attach(frame.buffer.buffer.id, 0, 0);
|
|
515
|
+
if (s.version >= 4) s.$.damage_buffer(0, 0, frame.width, frame.height);
|
|
516
|
+
else s.$.damage(0, 0, win.width, win.height);
|
|
517
|
+
s.$.commit();
|
|
518
|
+
win.mapped = true;
|
|
519
|
+
trace(
|
|
520
|
+
`overlay ${win.width}x${win.height} showing a ${frame.width}x${frame.height} frame`,
|
|
521
|
+
);
|
|
522
|
+
};
|
|
523
|
+
const sample = () => {
|
|
524
|
+
const px = clamp(
|
|
525
|
+
Math.floor((seat.pointerX * frame.width) / win.width),
|
|
526
|
+
0,
|
|
527
|
+
frame.width - 1,
|
|
528
|
+
);
|
|
529
|
+
const py = clamp(
|
|
530
|
+
Math.floor((seat.pointerY * frame.height) / win.height),
|
|
531
|
+
0,
|
|
532
|
+
frame.height - 1,
|
|
533
|
+
);
|
|
534
|
+
const { r, g, b } = shmPixel(bytes, frame, px, py);
|
|
535
|
+
trace(`picked (${px}, ${py}) = rgb(${r}, ${g}, ${b})`);
|
|
536
|
+
finish(() => resolve({ r: r / 255, g: g / 255, b: b / 255 }));
|
|
537
|
+
};
|
|
538
|
+
const onPress = (ev) => {
|
|
539
|
+
trace(
|
|
540
|
+
`press button ${ev.button} on surface ${ev.surface} (overlay is ${win.surface.id})`,
|
|
541
|
+
);
|
|
542
|
+
if (ev.surface !== win.surface.id) return;
|
|
543
|
+
// Button 1 picks; the rest are ignored rather than treated as
|
|
544
|
+
// cancel, the X11 rung's rule — Escape is one key away.
|
|
545
|
+
if (ev.button === 1) sample();
|
|
546
|
+
};
|
|
547
|
+
const onKey = (ev) => {
|
|
548
|
+
if (seat.focus !== win.surface.id) return;
|
|
549
|
+
const sym = ev.keysym;
|
|
550
|
+
if (sym === XK_ESCAPE) return finish(() => resolve(null));
|
|
551
|
+
if (
|
|
552
|
+
(sym === XK_RETURN || sym === XK_KP_ENTER || sym === XK_SPACE) &&
|
|
553
|
+
seat.pointerSurface === win.surface.id
|
|
554
|
+
) {
|
|
555
|
+
sample();
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
const onClose = () => finish(() => resolve(null));
|
|
559
|
+
const onAbort = () => finish(() => reject(abortReason(signal)));
|
|
560
|
+
|
|
561
|
+
seat.on('buttonpress', onPress);
|
|
562
|
+
seat.on('keydown', onKey);
|
|
563
|
+
win.on('configure', onConfigure);
|
|
564
|
+
win.on('close', onClose);
|
|
565
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
566
|
+
// Applied on the pointer's entry into the overlay, by the seat.
|
|
567
|
+
seat.setCursor('crosshair');
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
destroy() {
|
|
572
|
+
if (this.destroyed) return;
|
|
573
|
+
this.destroyed = true;
|
|
574
|
+
try {
|
|
575
|
+
if (this.output.version >= 3) this.output.$.release();
|
|
576
|
+
} catch {
|
|
577
|
+
/* gone */
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function clamp(v, lo, hi) {
|
|
583
|
+
return Math.min(hi, Math.max(lo, v));
|
|
584
|
+
}
|