react-x11 2.13.0 → 2.15.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 (70) hide show
  1. package/package.json +11 -8
  2. package/src/Reconciler.js +34 -21
  3. package/src/components/Select.js +8 -2
  4. package/src/events.js +8 -2
  5. package/src/glnodes.js +15 -3
  6. package/src/index.d.ts +5 -0
  7. package/src/nodes/boxpaint.js +9 -0
  8. package/src/nodes/preedit.js +64 -14
  9. package/src/ntk.js +10 -1
  10. package/src/scale.js +52 -22
  11. package/src/screencolor.js +104 -17
  12. package/src/types/system.d.ts +14 -1
  13. package/src/wayland/app.js +574 -0
  14. package/src/wayland/backendwindow.js +1199 -0
  15. package/src/wayland/clipboard.js +326 -0
  16. package/src/wayland/connection.js +485 -0
  17. package/src/wayland/context2d.js +2290 -0
  18. package/src/wayland/decorations.js +476 -0
  19. package/src/wayland/device.js +55 -0
  20. package/src/wayland/dmabuf.js +89 -0
  21. package/src/wayland/dnd.js +581 -0
  22. package/src/wayland/fdutil.js +108 -0
  23. package/src/wayland/framestyle.js +257 -0
  24. package/src/wayland/framewatch.js +190 -0
  25. package/src/wayland/glarea.js +372 -0
  26. package/src/wayland/glcontext.js +415 -0
  27. package/src/wayland/glyphatlas.js +237 -0
  28. package/src/wayland/input.js +417 -0
  29. package/src/wayland/keysymnames.js +35 -0
  30. package/src/wayland/layershell.js +363 -0
  31. package/src/wayland/outputs.js +601 -0
  32. package/src/wayland/protocols/cursor-shape-v1.json +1 -0
  33. package/src/wayland/protocols/ext-idle-notify-v1.json +1 -0
  34. package/src/wayland/protocols/ext-image-capture-source-v1.json +1 -0
  35. package/src/wayland/protocols/ext-image-copy-capture-v1.json +1 -0
  36. package/src/wayland/protocols/fractional-scale-v1.json +1 -0
  37. package/src/wayland/protocols/index.json +131 -0
  38. package/src/wayland/protocols/kde-server-decoration.json +1 -0
  39. package/src/wayland/protocols/keyboard-shortcuts-inhibit-unstable-v1.json +1 -0
  40. package/src/wayland/protocols/linux-dmabuf-v1.json +1 -0
  41. package/src/wayland/protocols/pointer-constraints-unstable-v1.json +1 -0
  42. package/src/wayland/protocols/presentation-time.json +1 -0
  43. package/src/wayland/protocols/primary-selection-unstable-v1.json +1 -0
  44. package/src/wayland/protocols/relative-pointer-unstable-v1.json +1 -0
  45. package/src/wayland/protocols/tablet-v2.json +1 -0
  46. package/src/wayland/protocols/text-input-unstable-v3.json +1 -0
  47. package/src/wayland/protocols/viewporter.json +1 -0
  48. package/src/wayland/protocols/wayland.json +1 -0
  49. package/src/wayland/protocols/wlr-layer-shell-unstable-v1.json +1 -0
  50. package/src/wayland/protocols/wlr-screencopy-unstable-v1.json +1 -0
  51. package/src/wayland/protocols/xdg-activation-v1.json +1 -0
  52. package/src/wayland/protocols/xdg-decoration-unstable-v1.json +1 -0
  53. package/src/wayland/protocols/xdg-output-unstable-v1.json +1 -0
  54. package/src/wayland/protocols/xdg-shell.json +1 -0
  55. package/src/wayland/protocols/xdg-toplevel-icon-v1.json +1 -0
  56. package/src/wayland/readback.js +99 -0
  57. package/src/wayland/screencopy.js +584 -0
  58. package/src/wayland/seat.js +584 -0
  59. package/src/wayland/shm.js +226 -0
  60. package/src/wayland/ssd.js +234 -0
  61. package/src/wayland/surface.js +141 -0
  62. package/src/wayland/swapchain.js +411 -0
  63. package/src/wayland/tablet.js +522 -0
  64. package/src/wayland/target.js +269 -0
  65. package/src/wayland/text.js +113 -0
  66. package/src/wayland/textinput.js +671 -0
  67. package/src/wayland/touch.js +284 -0
  68. package/src/wayland/window.js +854 -0
  69. package/src/wayland/xkb.js +425 -0
  70. package/src/windowstate.js +41 -1
@@ -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
+ }