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.
Files changed (63) 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/index.d.ts +5 -0
  6. package/src/nodes/boxpaint.js +9 -0
  7. package/src/nodes/preedit.js +64 -14
  8. package/src/scale.js +52 -22
  9. package/src/screencolor.js +104 -17
  10. package/src/wayland/app.js +560 -0
  11. package/src/wayland/backendwindow.js +1123 -0
  12. package/src/wayland/clipboard.js +326 -0
  13. package/src/wayland/connection.js +482 -0
  14. package/src/wayland/context2d.js +2133 -0
  15. package/src/wayland/decorations.js +476 -0
  16. package/src/wayland/dmabuf.js +89 -0
  17. package/src/wayland/dnd.js +581 -0
  18. package/src/wayland/fdutil.js +108 -0
  19. package/src/wayland/framestyle.js +257 -0
  20. package/src/wayland/glarea.js +371 -0
  21. package/src/wayland/glcontext.js +415 -0
  22. package/src/wayland/glyphatlas.js +237 -0
  23. package/src/wayland/input.js +417 -0
  24. package/src/wayland/keysymnames.js +35 -0
  25. package/src/wayland/layershell.js +363 -0
  26. package/src/wayland/outputs.js +601 -0
  27. package/src/wayland/protocols/cursor-shape-v1.json +1 -0
  28. package/src/wayland/protocols/ext-idle-notify-v1.json +1 -0
  29. package/src/wayland/protocols/ext-image-capture-source-v1.json +1 -0
  30. package/src/wayland/protocols/ext-image-copy-capture-v1.json +1 -0
  31. package/src/wayland/protocols/fractional-scale-v1.json +1 -0
  32. package/src/wayland/protocols/index.json +127 -0
  33. package/src/wayland/protocols/keyboard-shortcuts-inhibit-unstable-v1.json +1 -0
  34. package/src/wayland/protocols/linux-dmabuf-v1.json +1 -0
  35. package/src/wayland/protocols/pointer-constraints-unstable-v1.json +1 -0
  36. package/src/wayland/protocols/presentation-time.json +1 -0
  37. package/src/wayland/protocols/primary-selection-unstable-v1.json +1 -0
  38. package/src/wayland/protocols/relative-pointer-unstable-v1.json +1 -0
  39. package/src/wayland/protocols/tablet-v2.json +1 -0
  40. package/src/wayland/protocols/text-input-unstable-v3.json +1 -0
  41. package/src/wayland/protocols/viewporter.json +1 -0
  42. package/src/wayland/protocols/wayland.json +1 -0
  43. package/src/wayland/protocols/wlr-layer-shell-unstable-v1.json +1 -0
  44. package/src/wayland/protocols/wlr-screencopy-unstable-v1.json +1 -0
  45. package/src/wayland/protocols/xdg-activation-v1.json +1 -0
  46. package/src/wayland/protocols/xdg-decoration-unstable-v1.json +1 -0
  47. package/src/wayland/protocols/xdg-output-unstable-v1.json +1 -0
  48. package/src/wayland/protocols/xdg-shell.json +1 -0
  49. package/src/wayland/protocols/xdg-toplevel-icon-v1.json +1 -0
  50. package/src/wayland/readback.js +99 -0
  51. package/src/wayland/screencopy.js +584 -0
  52. package/src/wayland/seat.js +584 -0
  53. package/src/wayland/shm.js +226 -0
  54. package/src/wayland/ssd.js +106 -0
  55. package/src/wayland/surface.js +123 -0
  56. package/src/wayland/swapchain.js +411 -0
  57. package/src/wayland/tablet.js +522 -0
  58. package/src/wayland/target.js +263 -0
  59. package/src/wayland/text.js +113 -0
  60. package/src/wayland/textinput.js +671 -0
  61. package/src/wayland/touch.js +284 -0
  62. package/src/wayland/window.js +827 -0
  63. package/src/wayland/xkb.js +425 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -20,6 +20,8 @@
20
20
  "examples:appearance": "tsx examples/appearance.jsx",
21
21
  "examples:animation": "tsx examples/animation.jsx",
22
22
  "examples:simple": "tsx examples/simple.jsx",
23
+ "examples:wayland-gl": "node examples/wayland-gl.mjs",
24
+ "examples:wayland-ui": "node examples/wayland-ui.mjs",
23
25
  "examples:simple-nojsx": "node examples/simple-nojsx.js",
24
26
  "examples:xeyes": "tsx examples/xeyes.jsx",
25
27
  "examples:dashboard": "tsx examples/dashboard.jsx",
@@ -100,20 +102,21 @@
100
102
  "ical.js": "^2.2.1",
101
103
  "linebreak": "^1.1.0",
102
104
  "ntk": "^8.7.0",
103
- "react-reconciler": "^0.33.0",
105
+ "react-reconciler": "^0.34.0",
104
106
  "yoga-layout": "^3.2.1"
105
107
  },
106
108
  "optionalDependencies": {
107
109
  "@windowkit/appkit": "^0.10.0",
110
+ "@windowkit/wayland": "^3.1.1",
108
111
  "dbus-native": "^0.15.1",
109
- "x11-dri": "^0.8.0"
112
+ "x11-dri": "^0.9.0"
110
113
  },
111
114
  "peerDependencies": {
112
115
  "@babel/core": "^8.0.0",
113
116
  "@babel/plugin-transform-react-jsx": "^8.0.0",
114
117
  "hot-module-replacement": "^4.0.1",
115
- "react": "^19.0.0",
116
- "react-refresh": "^0.18.0"
118
+ "react": "^19.3.0",
119
+ "react-refresh": "^0.19.0"
117
120
  },
118
121
  "peerDependenciesMeta": {
119
122
  "@babel/core": {
@@ -146,9 +149,9 @@
146
149
  "hot-module-replacement": "^4.0.1",
147
150
  "katex": "^0.18.1",
148
151
  "prettier": "^3.6.0",
149
- "react": "^19.2.8",
150
- "react-devtools-core": "^7.0.1",
151
- "react-refresh": "^0.18.0",
152
+ "react": "^19.3.0",
153
+ "react-devtools-core": "^8.0.0",
154
+ "react-refresh": "^0.19.0",
152
155
  "tsx": "^4.23.1",
153
156
  "typescript": "^7.0.2",
154
157
  "ws": "^8.21.1"
package/src/Reconciler.js CHANGED
@@ -453,6 +453,10 @@ const HostConfig = {
453
453
  },
454
454
  startSuspendingCommit() {},
455
455
  suspendInstance() {},
456
+ // react-reconciler 0.34 calls this on every commit whose lanes are all
457
+ // transition, retry or idle lanes, whether or not the tree has a
458
+ // <ViewTransition>. There is no view transition here to wait on.
459
+ suspendOnActiveViewTransition() {},
456
460
  waitForCommitToBeReady() {
457
461
  return null;
458
462
  },
@@ -574,11 +578,11 @@ const isNtkApp = (v) =>
574
578
  */
575
579
  function resolveBackend(options) {
576
580
  const asked = options.backend ?? process.env.REACT_X11_BACKEND ?? 'auto';
577
- if (asked === 'x11' || asked === 'cocoa') return asked;
581
+ if (asked === 'x11' || asked === 'cocoa' || asked === 'wayland') return asked;
578
582
  if (asked !== 'auto') {
579
583
  throw new Error(
580
584
  `react-x11: unknown backend ${JSON.stringify(asked)} — expected ` +
581
- "'x11', 'cocoa' or 'auto'.",
585
+ "'x11', 'cocoa', 'wayland' or 'auto'.",
582
586
  );
583
587
  }
584
588
  // Naming an X endpoint is choosing X11: a `display` or a `stream` (the
@@ -715,25 +719,34 @@ export async function createRoot(options = {}) {
715
719
  rest.backend === 'cocoa' || process.env.REACT_X11_BACKEND === 'cocoa';
716
720
  const connecting = !owned
717
721
  ? Promise.resolve(borrowed)
718
- : backend === 'cocoa'
719
- ? import('./cocoa/app.js')
720
- .then(({ createCocoaApp }) => createCocoaApp(rest))
721
- .catch((err) => {
722
- // Asked for by name, the bridge is required and its absence is
723
- // the error (it says how to install). Reached by 'auto', a mac
724
- // without it falls back to X11 so an XQuartz setup keeps
725
- // working — said once, because a silent fallback would look
726
- // like the native backend being broken rather than absent.
727
- if (cocoaAsked) throw err;
728
- if (process.env.NODE_ENV !== 'production') {
729
- console.warn(
730
- 'react-x11: no @windowkit/appkit bridge — falling back to the ' +
731
- `X11 backend. (${err.message.split('\n')[0]})`,
732
- );
733
- }
734
- return connectX11();
735
- })
736
- : connectX11();
722
+ : backend === 'wayland'
723
+ ? // Opt-in only, never through 'auto': X11 stays the default on Linux
724
+ // because the remote case (docs/remote.md) is the flagship reason
725
+ // this project exists and Wayland has no network transparency. The
726
+ // backend is opt-in; docs/wayland-backend.md says what it does
727
+ // and does not do.
728
+ import('./wayland/app.js').then(({ createWaylandApp }) =>
729
+ createWaylandApp(rest),
730
+ )
731
+ : backend === 'cocoa'
732
+ ? import('./cocoa/app.js')
733
+ .then(({ createCocoaApp }) => createCocoaApp(rest))
734
+ .catch((err) => {
735
+ // Asked for by name, the bridge is required and its absence is
736
+ // the error (it says how to install). Reached by 'auto', a mac
737
+ // without it falls back to X11 so an XQuartz setup keeps
738
+ // working — said once, because a silent fallback would look
739
+ // like the native backend being broken rather than absent.
740
+ if (cocoaAsked) throw err;
741
+ if (process.env.NODE_ENV !== 'production') {
742
+ console.warn(
743
+ 'react-x11: no @windowkit/appkit bridge — falling back to the ' +
744
+ `X11 backend. (${err.message.split('\n')[0]})`,
745
+ );
746
+ }
747
+ return connectX11();
748
+ })
749
+ : connectX11();
737
750
  const layout = loadLayout();
738
751
  const integrations = loadIntegrations(); // null when there is nothing to install
739
752
  const [app] = await Promise.all([connecting, layout, integrations]);
@@ -354,11 +354,17 @@ export function Select({
354
354
  );
355
355
  const chosenRowTop = MENU_BORDER + metrics.pad + chosen * metrics.row;
356
356
  const top = triggerCapsBottom - chosenRowTop - rowCapsBottom;
357
+ // `top` is where the menu starts against the trigger's own top, so it
358
+ // goes in as an `offset` back past the trigger's bottom — not as an `at`.
359
+ // Tracking asks whether `at` is still in view, and a menu opened over a
360
+ // trigger near the window's top edge starts above that edge: the popup
361
+ // is a real window and may, but the rect asked about was then "scrolled
362
+ // out", and the menu closed on the next unrelated layout pass (#552).
363
+ // What has to stay visible is the trigger.
357
364
  return {
358
365
  placement: 'bottom',
359
- offset: 0,
366
+ offset: top - triggerHeight,
360
367
  flip: false,
361
- at: { x: 0, y: top, width: node ? node.abs.width / scale : 0, height: 0 },
362
368
  alignOffset: -metrics.hang.left,
363
369
  height,
364
370
  width,
package/src/events.js CHANGED
@@ -1345,8 +1345,14 @@ export class EventManager {
1345
1345
  * then the element's own `defaultComposition` unless one of them called
1346
1346
  * `preventDefault()`. Same seam, same order as `defaultKeyDown`.
1347
1347
  */
1348
- _composition(phase, target, data, native) {
1349
- const ev = this.dispatch('Composition' + phase, target, native, { data });
1348
+ _composition(phase, target, data, native, extra) {
1349
+ // `extra` is what an input method knows beyond the text — the cursor
1350
+ // inside its preedit (`cursorBegin`/`cursorEnd`, src/wayland/textinput.js);
1351
+ // the client-side composer has none, and the event is the same shape
1352
+ const ev = this.dispatch('Composition' + phase, target, native, {
1353
+ data,
1354
+ ...extra,
1355
+ });
1350
1356
  if (!ev.defaultPrevented) target.defaultComposition?.(ev);
1351
1357
  return ev;
1352
1358
  }
package/src/index.d.ts CHANGED
@@ -300,6 +300,11 @@ export interface RootOptions {
300
300
  | 'off'
301
301
  | {
302
302
  mode?: 'auto' | 'direct' | 'indirect' | 'off';
303
+ /**
304
+ * Wayland: the DRM node to render on. Defaults to
305
+ * `REACT_X11_GL_DEVICE`, then the first render node, then, on a
306
+ * machine with no render node, each card node in turn.
307
+ */
303
308
  devicePath?: string | null;
304
309
  maxInFlight?: number;
305
310
  linearFallback?: boolean;
@@ -254,6 +254,15 @@ export class NodeBoxPaint {
254
254
  ctx.fill();
255
255
  continue;
256
256
  }
257
+ // A context that can feather a rounded rectangle itself — the GPU
258
+ // backend's distance-field shadow — skips the coverage surface, the
259
+ // CPU blur and the paint cache entirely. Feature-detected, like
260
+ // `presentFrame`: an ntk context has no such method and takes the
261
+ // path below.
262
+ if (typeof ctx.fillShadow === 'function') {
263
+ ctx.fillShadow(rect, r, shadow.blur, color);
264
+ continue;
265
+ }
257
266
  this._paintBlurredShadow(ctx, rect, r, shadow.blur, color);
258
267
  }
259
268
  }
@@ -43,12 +43,18 @@ export class TextInputPreedit {
43
43
 
44
44
  /** A value index in the displayed string. The caret is at the composition
45
45
  * while it is open, so it maps to the *end* of the preedit — which is
46
- * where the next keystroke of the sequence appears. */
46
+ * where the next keystroke of the sequence appears — unless the input
47
+ * method said where inside its preedit the cursor is (`_preeditCursor`,
48
+ * a compositor IME converting one segment of a longer phrase). */
47
49
  _displayIndex(index) {
48
50
  if (!this._preedit) return index;
49
- return index >= this._preeditStart()
50
- ? index + Array.from(this._preedit).length
51
- : index;
51
+ const start = this._preeditStart();
52
+ if (index < start) return index;
53
+ const length = Array.from(this._preedit).length;
54
+ if (index === start && this._preeditCursor) {
55
+ return start + Math.min(length, this._preeditCursor.begin);
56
+ }
57
+ return index + length;
52
58
  }
53
59
 
54
60
  /** The inverse, for indices that come back out of a layout. A hit inside
@@ -61,10 +67,20 @@ export class TextInputPreedit {
61
67
  return Math.max(start, index - Array.from(this._preedit).length);
62
68
  }
63
69
 
64
- _setPreedit(text) {
65
- if (text === this._preedit) return;
70
+ /**
71
+ * `cursor` is `{ begin, end }` in code points into `text` where an input
72
+ * method wants the caret inside its preedit, or a segment of it
73
+ * highlighted when the two differ — and null for the end, which is where
74
+ * the client-side composer's caret always is.
75
+ */
76
+ _setPreedit(text, cursor = null) {
77
+ const range = text && cursor ? cursor : null;
78
+ if (text === this._preedit && sameRange(range, this._preeditCursor)) {
79
+ return;
80
+ }
66
81
  if (!this._preedit) this._preeditAt = this._selection()[0];
67
82
  this._preedit = text;
83
+ this._preeditCursor = range;
68
84
  this._repaint();
69
85
  }
70
86
 
@@ -79,7 +95,18 @@ export class TextInputPreedit {
79
95
  */
80
96
  defaultComposition(ev) {
81
97
  if (ev.type !== 'compositionEnd') {
82
- this._setPreedit(ev.data);
98
+ // `cursorBegin`/`cursorEnd` ride the event only when an input method
99
+ // put them there (src/wayland/textinput.js); a dead key carries none
100
+ const begin = ev.cursorBegin;
101
+ const cursor = Number.isInteger(begin)
102
+ ? {
103
+ begin,
104
+ end: Number.isInteger(ev.cursorEnd)
105
+ ? Math.max(begin, ev.cursorEnd)
106
+ : begin,
107
+ }
108
+ : null;
109
+ this._setPreedit(ev.data, cursor);
83
110
  return;
84
111
  }
85
112
  this._setPreedit('');
@@ -114,14 +141,37 @@ export class TextInputPreedit {
114
141
  // this and a composition at a wrap point is two spans — drawn as one
115
142
  // batch, so the underline costs one request however it wraps
116
143
  const rects = [];
117
- for (let li = from.line; li <= to.line; li++) {
118
- const line = layout.lines[li];
119
- if (!line) break;
120
- const x0 = li === from.line ? from.x : line.x;
121
- const x1 = li === to.line ? to.x : line.x + line.width;
122
- if (x1 <= x0) continue;
123
- rects.push(originX + x0, originY + line.y + line.ascent + 1, x1 - x0, 1);
144
+ const underline = (from, to, thickness) => {
145
+ for (let li = from.line; li <= to.line; li++) {
146
+ const line = layout.lines[li];
147
+ if (!line) break;
148
+ const x0 = li === from.line ? from.x : line.x;
149
+ const x1 = li === to.line ? to.x : line.x + line.width;
150
+ if (x1 <= x0) continue;
151
+ rects.push(
152
+ originX + x0,
153
+ originY + line.y + line.ascent + 1,
154
+ x1 - x0,
155
+ thickness,
156
+ );
157
+ }
158
+ };
159
+ underline(from, to, 1);
160
+ // The segment an input method is converting, when it named one: the
161
+ // convention (GTK, Qt, the browsers) is a heavier line under the part of
162
+ // the phrase the candidates are for, and a plain one under the rest.
163
+ const cursor = this._preeditCursor;
164
+ if (cursor && cursor.end > cursor.begin) {
165
+ underline(
166
+ layout.caretPosition(start + cursor.begin),
167
+ layout.caretPosition(start + cursor.end),
168
+ 2,
169
+ );
124
170
  }
125
171
  if (rects.length) ctx.fillRects(rects);
126
172
  }
127
173
  }
174
+
175
+ function sameRange(a, b) {
176
+ return a === b || (!!a && !!b && a.begin === b.begin && a.end === b.end);
177
+ }
package/src/scale.js CHANGED
@@ -166,6 +166,18 @@ const VIRTUAL_EDID_VENDORS = new Set([
166
166
  ]);
167
167
  const VIRTUAL_MODEL = /qemu|virtual|vbox|vmware|parallels|bochs|bhyve/i;
168
168
 
169
+ /**
170
+ * Whether an EDID's vendor and model name software — or a compositor's
171
+ * `wl_output` make and model, which are the same two strings read off the
172
+ * same EDID (mutter says 'RHT' / 'QEMU Monitor' for a QEMU guest's screen).
173
+ */
174
+ export function isVirtualDisplay(vendor, model) {
175
+ return (
176
+ (vendor != null && VIRTUAL_EDID_VENDORS.has(vendor)) ||
177
+ (model != null && VIRTUAL_MODEL.test(model))
178
+ );
179
+ }
180
+
169
181
  /**
170
182
  * The 128-byte EDID base block → what the scale ladder wants from it:
171
183
  * vendor, model name, the physical size, and the one derived judgement —
@@ -202,9 +214,7 @@ export function parseEdid(buffer) {
202
214
  break;
203
215
  }
204
216
  }
205
- const virtual =
206
- (vendor !== null && VIRTUAL_EDID_VENDORS.has(vendor)) ||
207
- (model !== null && VIRTUAL_MODEL.test(model));
217
+ const virtual = isVirtualDisplay(vendor, model);
208
218
  return { vendor, model, mmWidth, mmHeight, virtual };
209
219
  }
210
220
 
@@ -352,13 +362,17 @@ export function monitorScaleFromMetadata(monitor, { perPanel = true } = {}) {
352
362
  }
353
363
 
354
364
  // No physical truth to reason from. The pixel grid alone still separates
355
- // "unmistakably a retina panel" from everything else: the smallest grids
356
- // this matches are 2880x1800 and 3024x1964, both shipped only as 2x
357
- // panels, and every VM window covering one lands here too. 2560-wide
358
- // grids stay at 1 on purpose 2560x1440 is the commonest *1x* desk
359
- // monitor there is, and only millimetres could tell it from a 13" retina
360
- // lid, which is exactly the data this branch does not have.
361
- if (perPanel && (Math.min(pxW, pxH) >= 1800 || Math.max(pxW, pxH) >= 3000)) {
365
+ // "unmistakably a retina panel" from everything else. 2560x1600, a 30"
366
+ // desk monitor, is the tallest grid a 1x panel ships with, and nothing is
367
+ // sold as 1x with a short side between that and 1800 — so a short side
368
+ // past 1600 is a 2x grid: the 2560x1664 and 2880x1800 laptop lids, and a
369
+ // VM *window* over a retina panel, which is all a guest sees of its host
370
+ // (2488x1668 is a UTM window on a 14" MacBook). A long side of 3000 or
371
+ // more catches the wide ones. 2560x1440 and 2560x1600 stay at 1 on
372
+ // purpose — the commonest *1x* desk monitors there are, and only
373
+ // millimetres could tell them from a 13" retina lid, which is exactly the
374
+ // data this branch does not have.
375
+ if (perPanel && (Math.min(pxW, pxH) > 1600 || Math.max(pxW, pxH) >= 3000)) {
362
376
  return {
363
377
  scale: 2,
364
378
  source: 'resolution',
@@ -379,6 +393,26 @@ export function monitorScaleFromMetadata(monitor, { perPanel = true } = {}) {
379
393
  // The desktop-configuration rungs
380
394
  // --------------------------------------------------------------------------
381
395
 
396
+ /**
397
+ * The factor a person or the app pinned, or null for `'auto'`:
398
+ * `REACT_X11_SCALE` (0.5–8) first, then a numeric `createRoot({ scale })`
399
+ * clamped to [0.5, 8]. The ladder's first word, and the only one a backend
400
+ * with a scale of its own (a Wayland compositor's) lets outrank that scale.
401
+ */
402
+ export function pinnedScale(option) {
403
+ const own = Number(process.env.REACT_X11_SCALE);
404
+ if (Number.isFinite(own) && own >= 0.5 && own <= 8) {
405
+ return { scale: own, source: 'REACT_X11_SCALE' };
406
+ }
407
+ if (typeof option === 'number' && Number.isFinite(option)) {
408
+ return { scale: Math.min(8, Math.max(0.5, option)), source: 'option' };
409
+ }
410
+ return null;
411
+ }
412
+
413
+ /** `REACT_X11_DEBUG_SCALE`'s line, for the backends that decide their own. */
414
+ export const traceScale = trace;
415
+
382
416
  function envScale() {
383
417
  const own = Number(process.env.REACT_X11_SCALE);
384
418
  // wider bounds than `snapScale` on purpose: an explicit override is a
@@ -609,18 +643,14 @@ export async function beginScale(app, option) {
609
643
  session = new ScaleSession();
610
644
  sessions.set(app, session);
611
645
 
612
- const own = Number(process.env.REACT_X11_SCALE);
613
- if (Number.isFinite(own) && own >= 0.5 && own <= 8) {
614
- session.scale = own;
615
- session.source = 'REACT_X11_SCALE';
616
- trace(`${session.scale}x from REACT_X11_SCALE`);
617
- return session;
618
- }
619
-
620
- if (typeof option === 'number' && Number.isFinite(option)) {
621
- session.scale = Math.min(8, Math.max(0.5, option));
622
- session.source = 'option';
623
- trace(`${session.scale}x from createRoot({ scale })`);
646
+ const pinned = pinnedScale(option);
647
+ if (pinned) {
648
+ session.scale = pinned.scale;
649
+ session.source = pinned.source;
650
+ trace(
651
+ `${pinned.scale}x from ` +
652
+ (pinned.source === 'option' ? 'createRoot({ scale })' : pinned.source),
653
+ );
624
654
  return session;
625
655
  }
626
656
 
@@ -1,7 +1,7 @@
1
1
  // Sample one pixel from the screen — the eyedropper, through whatever this
2
2
  // machine actually has.
3
3
  //
4
- // The file dialog's ladder again (docs/filedialog.md), three rungs:
4
+ // The file dialog's ladder again (docs/filedialog.md), four rungs:
5
5
  //
6
6
  // 1. **the system sampler** — `NSColorSampler` on the cocoa backend
7
7
  // (src/cocoa/screencolor.js). macOS draws the loupe out of process, so
@@ -11,24 +11,31 @@
11
11
  // 2. **the portal** — `org.freedesktop.portal.Screenshot.PickColor`. The
12
12
  // desktop draws its own magnifier and hands back the colour, which is
13
13
  // also the only route that works under a compositor that would refuse a
14
- // root read, and the only route Wayland has at all. Needs version 2 of
14
+ // root read, and GNOME's only route on Wayland. Needs version 2 of
15
15
  // the Screenshot interface — XFCE ships none, GNOME and KDE ship 2 —
16
16
  // so the gate is the interface's `version` property, not `hasService()`.
17
- // 3. **X11** — grab the pointer with a crosshair, wait for the click,
17
+ // 3. **the Wayland backend's own picker** — where the compositor offers a
18
+ // screen capture protocol and layer-shell (wlroots: sway, labwc, …)
19
+ // but no portal, the backend freezes the output into a capture, shows
20
+ // it as an overlay and takes the click on that
21
+ // (src/wayland/screencopy.js). Below the portal because the portal is
22
+ // the desktop's picker and user-consented; guarded by the backend's
23
+ // name because the capability is a Wayland object through and through.
24
+ // 4. **X11** — grab the pointer with a crosshair, wait for the click,
18
25
  // `GetImage` a 1×1 at it, decode by the server's own pixel layout.
19
26
  // Reached under a bare WM, over ssh, on XQuartz: everywhere there is a
20
27
  // display and nothing else, which is the case react-x11 exists for.
21
28
  //
22
- // Rungs 1 and 2 are the same shape — ask the system, it draws the picker,
23
- // it hands back an sRGB triple — which is why the sampler goes on top of the
24
- // portal rather than under the crosshair: where the OS will do this for us,
25
- // it does it better, and `hexFromPortalColor()` converts for both.
29
+ // Rungs 1 to 3 are the same shape — ask, a picker is drawn, an sRGB triple
30
+ // comes back — which is why the sampler goes on top of the portal rather
31
+ // than under the crosshair: where the OS will do this for us, it does it
32
+ // better, and `hexFromPortalColor()` converts for all three.
26
33
  //
27
34
  // There is no rung to *draw*, because the thing being read — the whole
28
35
  // screen — is precisely what an application cannot draw itself. So unlike
29
36
  // `useFileDialog()`, `useEyedropper()` adds no rung; it adds the binding a
30
37
  // component wants (`picking`, `supported`, the owner window) over these
31
- // three.
38
+ // four.
32
39
  //
33
40
  // Which means the ladder really can run out, and where it does the floor has
34
41
  // to be the typed rejection rather than a crash. A cocoa app on a bridge
@@ -83,8 +90,9 @@ export class NoScreenColorError extends Error {
83
90
  message ??
84
91
  'no way to sample a colour from the screen — no cocoa app with a ' +
85
92
  'system sampler, no Screenshot portal with PickColor (interface ' +
86
- 'version 2) on the session bus, and no X connection was given for ' +
87
- 'the fallback. Pass `app` (from createRoot() or useApp()), or use ' +
93
+ 'version 2) on the session bus, no Wayland compositor with screen ' +
94
+ 'capture and layer-shell, and no X connection was given for the ' +
95
+ 'fallback. Pass `app` (from createRoot() or useApp()), or use ' +
88
96
  'useEyedropper().'
89
97
  }`,
90
98
  { cause },
@@ -212,7 +220,14 @@ async function portalPick(opts, ref) {
212
220
  // options)` — no title — which is the whole reason portalRequest takes a
213
221
  // signature now.
214
222
  signature: 'sa{sv}',
215
- args: [parentWindowHandle(windowIdOf(opts.parentWindow))],
223
+ // The handle names an X window. A Wayland toplevel has none to name —
224
+ // an xdg-foreign export would be the equivalent — so there the dialog
225
+ // floats, which the portal allows.
226
+ args: [
227
+ appFor(opts)?.backend === 'wayland'
228
+ ? ''
229
+ : parentWindowHandle(windowIdOf(opts.parentWindow)),
230
+ ],
216
231
  options: {},
217
232
  signal: opts.signal,
218
233
  });
@@ -237,7 +252,49 @@ async function portalCanPick(ref) {
237
252
  }
238
253
 
239
254
  // --------------------------------------------------------------------------
240
- // Rung 3: X11
255
+ // Rung 3: the Wayland backend's own picker
256
+ // --------------------------------------------------------------------------
257
+
258
+ /**
259
+ * The Wayland app whose compositor can freeze and show the screen, or null.
260
+ *
261
+ * Guarded by the backend's name, unlike the sampler: `screenCapture` is the
262
+ * capability, but it is a Wayland object through and through — the pick is
263
+ * a layer-shell overlay over a screencopy frame (src/wayland/screencopy.js)
264
+ * — and `canPick` is the compositor's answer, not the app's. GNOME's is no:
265
+ * it advertises neither protocol, and the portal above is its route.
266
+ */
267
+ function waylandApp(opts) {
268
+ const app = appFor(opts);
269
+ return app?.backend === 'wayland' && app.screenCapture?.canPick ? app : null;
270
+ }
271
+
272
+ /**
273
+ * Freeze, show, click — the sampler's contract: `'#rrggbb'`, null on Escape,
274
+ * a rejection on abort. Unlike the sampler the overlay is ours, and it is
275
+ * down before the promise settles either way, so an abort leaves nothing on
276
+ * screen.
277
+ */
278
+ function waylandPick(opts, app) {
279
+ const signal = opts.signal;
280
+ if (signal?.aborted) {
281
+ return Promise.reject(signal.reason ?? new PortalCancelledError());
282
+ }
283
+ return app.screenCapture.pickColor({ signal }).then((color) => {
284
+ if (color == null) return null;
285
+ const hex = hexFromPortalColor([color.r, color.g, color.b]);
286
+ if (!hex) {
287
+ throw new Error(
288
+ 'react-x11: the Wayland screen pick answered without a colour — ' +
289
+ `expected sRGB { r, g, b } in 0–1, got ${JSON.stringify(color)}.`,
290
+ );
291
+ }
292
+ return hex;
293
+ });
294
+ }
295
+
296
+ // --------------------------------------------------------------------------
297
+ // Rung 4: X11
241
298
  // --------------------------------------------------------------------------
242
299
 
243
300
  // x11.eventMask bits, spelled out the way xsettings.js spells its one. No
@@ -680,6 +737,16 @@ function canGrabOn(app) {
680
737
  * fixes, and only one of them is the caller's.
681
738
  */
682
739
  function noX11Reason(app, backend) {
740
+ if (app?.backend === 'wayland') {
741
+ return (
742
+ 'this tree renders through the Wayland backend, and its compositor ' +
743
+ 'offers neither a Screenshot portal with PickColor nor a screen ' +
744
+ 'capture protocol (wlr-screencopy, ext-image-copy-capture) together ' +
745
+ 'with wlr-layer-shell to draw the picker on. ' +
746
+ '`useEyedropper().supported` is false, which is the signal to leave ' +
747
+ 'the eyedropper button undrawn (docs/wayland-backend.md).'
748
+ );
749
+ }
683
750
  if (app) {
684
751
  return (
685
752
  'this tree does not render through an X connection — a cocoa-backend ' +
@@ -710,15 +777,17 @@ function noX11Reason(app, backend) {
710
777
  * Screenshot interface at version 2 — the probe reads the interface's
711
778
  * `version` property, because `hasService()` cannot see which interfaces a
712
779
  * portal's backends actually provide (XFCE's provides no Screenshot at
713
- * all). `'x11'` needs a connection to answer with, so pass `app` (or a
714
- * `parentWindow` that resolves to one) and one that can actually grab,
715
- * which a cocoa-backend app cannot. With none of the three the honest
716
- * answer is `null`.
780
+ * all). `'wayland'` is a Wayland-backend app (`app`, or a `parentWindow`
781
+ * in one) whose compositor advertises a screen capture protocol and
782
+ * layer-shell wlroots without a portal. `'x11'` needs a connection to
783
+ * answer with, so pass `app` (or a `parentWindow` that resolves to one) —
784
+ * and one that can actually grab, which a cocoa-backend app cannot. With
785
+ * none of the four the honest answer is `null`.
717
786
  *
718
787
  * Acquires a bus reference and releases it, so it is cheap but not free —
719
788
  * `useEyedropper().supported` caches it for you.
720
789
  *
721
- * @returns {Promise<'cocoa'|'portal'|'x11'|null>}
790
+ * @returns {Promise<'cocoa'|'portal'|'wayland'|'x11'|null>}
722
791
  */
723
792
  export async function screenColorBackend(options = {}) {
724
793
  const backend = options.backend;
@@ -737,6 +806,10 @@ export async function screenColorBackend(options = {}) {
737
806
  }
738
807
  if (backend === 'portal') return null;
739
808
  }
809
+ if (!backend || backend === 'wayland') {
810
+ if (waylandApp(options)) return 'wayland';
811
+ if (backend === 'wayland') return null;
812
+ }
740
813
  return canGrabOn(appFor(options)) ? 'x11' : null;
741
814
  }
742
815
 
@@ -775,6 +848,20 @@ async function runPick(opts) {
775
848
  }
776
849
  }
777
850
 
851
+ const wantWayland = !opts.backend || opts.backend === 'wayland';
852
+ if (wantWayland) {
853
+ const app = waylandApp(opts);
854
+ if (app) return await waylandPick(opts, app);
855
+ if (opts.backend === 'wayland') {
856
+ throw new NoScreenColorError(
857
+ "backend: 'wayland' — no screen pick here: this tree does not " +
858
+ 'render through the Wayland backend, or its compositor offers ' +
859
+ 'neither a screen capture protocol (wlr-screencopy, ' +
860
+ 'ext-image-copy-capture) nor wlr-layer-shell.',
861
+ );
862
+ }
863
+ }
864
+
778
865
  const app = appFor(opts);
779
866
  if (canGrabOn(app)) return x11Pick(opts, app);
780
867
  throw new NoScreenColorError(noX11Reason(app, opts.backend));