react-x11 2.10.2 → 2.11.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.10.2",
3
+ "version": "2.11.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -100,7 +100,7 @@
100
100
  "yoga-layout": "^3.2.1"
101
101
  },
102
102
  "optionalDependencies": {
103
- "@windowkit/appkit": "^0.8.0",
103
+ "@windowkit/appkit": "^0.9.0",
104
104
  "dbus-native": "^0.15.1",
105
105
  "x11-dri": "^0.7.0"
106
106
  },
package/src/cocoa/app.js CHANGED
@@ -30,6 +30,7 @@ import { CocoaNotifications } from './notifications.js';
30
30
  import { CocoaPaneHost } from './panehost.js';
31
31
  import { CocoaPermissions } from './permissions.js';
32
32
  import { CocoaPaneWindow } from './panewindow.js';
33
+ import { CocoaColorSampler } from './screencolor.js';
33
34
  import { CocoaFilePanels } from './filepanels.js';
34
35
  import { CocoaFontManager } from './fonts.js';
35
36
  import { CocoaSurface } from './surface.js';
@@ -189,6 +190,17 @@ export class CocoaApp {
189
190
  ? new CocoaCalendars(this)
190
191
  : null;
191
192
 
193
+ // The system colour sampler, `NSColorSampler` (src/cocoa/screencolor.js).
194
+ // Present exactly when the bridge has it (>= 0.9), and its presence is
195
+ // the top rung of src/screencolor.js's ladder for this app — an older
196
+ // bridge leaves that ladder where it was, which on this backend is no
197
+ // rung at all: `useEyedropper().supported` stays false and a picker
198
+ // draws no dropper button.
199
+ this.colorSampler =
200
+ typeof native.sampleScreenColor === 'function'
201
+ ? new CocoaColorSampler(native)
202
+ : null;
203
+
192
204
  // The GL policy, glbackend.js's shape. No GLX exists here, so the
193
205
  // default is 'auto' (the direct backend where the runtime loads);
194
206
  // useSupports('shaders') stays false until the first <glarea> resolves
@@ -0,0 +1,62 @@
1
+ // One colour off the screen on the cocoa backend — `NSColorSampler` through
2
+ // @windowkit/appkit (>= 0.9), the top rung of src/screencolor.js's ladder on
3
+ // this backend. `CocoaApp.colorSampler` is this object and its *presence* is
4
+ // the capability, the rule `filePanels`, `permissions` and `calendars`
5
+ // follow, so the ladder never names a backend.
6
+ //
7
+ // It is the portal's shape rather than X11's, which is why it goes on *top*
8
+ // of the ladder and not under it: the system draws the loupe, out of
9
+ // process, so this app needs no Screen Recording grant of its own and the
10
+ // user gets the magnifier every other Mac colour picker shows them. There is
11
+ // no grab here, no root window read, and nothing for the crosshair rung to
12
+ // fall back to — the cocoa `X` is a stub (src/cocoa/app.js), which is why
13
+ // this file existing is the difference between an eyedropper button on macOS
14
+ // and none.
15
+ //
16
+ // Two things about the framework shape what is *not* here:
17
+ //
18
+ // - **Nothing dismisses the sampler from code.** AppKit offers no
19
+ // counterpart to `cancelPanel` — the session ends when the user picks or
20
+ // presses Escape — so there is no abort inside this wrapper the way
21
+ // `CocoaFilePanels.show` has one. A caller's `signal` can only stop *us*
22
+ // waiting; that is the rung's business, and it is spelled out there.
23
+ // - **One session, not one per call.** `NSColorSampler` "begins or attaches
24
+ // to an existing color sampling session", so a second sample while a loupe
25
+ // is up joins it and both callers get the same colour. The bridge keeps
26
+ // that promise; nothing here needs the X11 rung's loud refusal, which
27
+ // exists because a second `GrabPointer` silently replaces the first.
28
+
29
+ export class CocoaColorSampler {
30
+ constructor(native) {
31
+ this._native = native;
32
+ }
33
+
34
+ /**
35
+ * Show the system sampler and answer once.
36
+ *
37
+ * Resolves `{ r, g, b }` — sRGB, 0–1 floats, the Screenshot portal's
38
+ * `(ddd)` shape, which is what lets both rungs share one conversion — or
39
+ * **`null`** when the user dismissed the sampler without picking. A
40
+ * cancel is an ordinary outcome, not an error, on every rung of this
41
+ * ladder; a rejection here means a colour that could not be read at all
42
+ * (a pattern colour, which has no sRGB form).
43
+ *
44
+ * The answer arrives on the main thread, so the app has to be pumping —
45
+ * which a mounted react-x11 tree is by definition.
46
+ *
47
+ * @returns {Promise<{ r: number, g: number, b: number } | null>}
48
+ */
49
+ sample() {
50
+ return new Promise((resolve, reject) => {
51
+ try {
52
+ this._native.sampleScreenColor((err, color) =>
53
+ err ? reject(err) : resolve(color ?? null),
54
+ );
55
+ } catch (err) {
56
+ // A bad argument shape is a TypeError out of the bridge, before
57
+ // anything is shown. Rejecting keeps every failure on one channel.
58
+ reject(err);
59
+ }
60
+ });
61
+ }
62
+ }
@@ -1,24 +1,44 @@
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), two rungs this time:
4
+ // The file dialog's ladder again (docs/filedialog.md), three rungs:
5
5
  //
6
- // 1. **the portal** — `org.freedesktop.portal.Screenshot.PickColor`. The
6
+ // 1. **the system sampler** — `NSColorSampler` on the cocoa backend
7
+ // (src/cocoa/screencolor.js). macOS draws the loupe out of process, so
8
+ // the app needs no Screen Recording grant of its own and the user gets
9
+ // the magnifier every other Mac colour picker shows them. Found by the
10
+ // app carrying `colorSampler`, never by naming a backend here.
11
+ // 2. **the portal** — `org.freedesktop.portal.Screenshot.PickColor`. The
7
12
  // desktop draws its own magnifier and hands back the colour, which is
8
13
  // also the only route that works under a compositor that would refuse a
9
14
  // root read, and the only route Wayland has at all. Needs version 2 of
10
15
  // the Screenshot interface — XFCE ships none, GNOME and KDE ship 2 —
11
16
  // so the gate is the interface's `version` property, not `hasService()`.
12
- // 2. **X11** — grab the pointer with a crosshair, wait for the click,
17
+ // 3. **X11** — grab the pointer with a crosshair, wait for the click,
13
18
  // `GetImage` a 1×1 at it, decode by the server's own pixel layout.
14
19
  // Reached under a bare WM, over ssh, on XQuartz: everywhere there is a
15
20
  // display and nothing else, which is the case react-x11 exists for.
16
21
  //
17
- // There is no third rung to draw, because the thing being read the whole
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.
26
+ //
27
+ // There is no rung to *draw*, because the thing being read — the whole
18
28
  // screen — is precisely what an application cannot draw itself. So unlike
19
29
  // `useFileDialog()`, `useEyedropper()` adds no rung; it adds the binding a
20
- // component wants (`picking`, `supported`, the owner window) over the same
21
- // two.
30
+ // component wants (`picking`, `supported`, the owner window) over these
31
+ // three.
32
+ //
33
+ // Which means the ladder really can run out, and where it does the floor has
34
+ // to be the typed rejection rather than a crash. A cocoa app on a bridge
35
+ // older than 0.9 is that place: its `app.X` is a stub with just enough on it
36
+ // for the modules that keep an X escape hatch to no-op (src/cocoa/app.js),
37
+ // so an app object is not by itself a connection that can grab a pointer and
38
+ // read a root window. Rung 3 is gated on the requests it is built out of,
39
+ // not on there being an app — the feature-detection rule `requireExtension()`
40
+ // already follows, and the rule rung 1 follows too, one bridge verb instead
41
+ // of three X requests.
22
42
  //
23
43
  // ## The grab is the dangerous part
24
44
  //
@@ -42,6 +62,7 @@ import {
42
62
  portalRequest,
43
63
  portalVersion,
44
64
  } from './portal.js';
65
+ import { liveApps } from './trace-registry.js';
45
66
  import { windowIdOf } from './windowid.js';
46
67
 
47
68
  export const SCREENSHOT_IFACE = 'org.freedesktop.portal.Screenshot';
@@ -60,10 +81,11 @@ export class NoScreenColorError extends Error {
60
81
  super(
61
82
  `react-x11: ${
62
83
  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().'
84
+ 'no way to sample a colour from the screen — no cocoa app with a ' +
85
+ '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 ' +
88
+ 'useEyedropper().'
67
89
  }`,
68
90
  { cause },
69
91
  );
@@ -72,7 +94,91 @@ export class NoScreenColorError extends Error {
72
94
  }
73
95
 
74
96
  // --------------------------------------------------------------------------
75
- // Rung 1: the portal
97
+ // Rung 1: the system sampler, on the cocoa backend
98
+ // --------------------------------------------------------------------------
99
+
100
+ /**
101
+ * The app whose system sampler a pick should use, or null.
102
+ *
103
+ * Never a backend check: an app that can sample the screen says so by
104
+ * carrying `colorSampler` (src/cocoa/screencolor.js), and this asks the app
105
+ * the caller named — `app`, or a `parentWindow` that points at a mounted
106
+ * node — before asking the connections the renderer is drawing through, the
107
+ * rule `filePanels` and `calendars` follow. Several of those with only one
108
+ * showing a window is the next case (a borrowed connection stays registered
109
+ * after its root unmounts); genuinely several is a real null, since the
110
+ * sampler belongs to one process's NSApplication.
111
+ */
112
+ function samplerApp(opts) {
113
+ const named = appFor(opts);
114
+ if (named) return named.colorSampler ? named : null;
115
+ const apps = liveApps().filter((one) => one.colorSampler);
116
+ if (apps.length <= 1) return apps[0] ?? null;
117
+ const showing = apps.filter((one) => (one._rootChildren ?? []).length > 0);
118
+ return showing.length === 1 ? showing[0] : null;
119
+ }
120
+
121
+ /**
122
+ * Show `NSColorSampler` and wait for it.
123
+ *
124
+ * Resolves `'#rrggbb'` on a pick and `null` on a dismissal — the two
125
+ * outcomes every rung answers with — through the *portal's* conversion,
126
+ * because the bridge answers in the portal's units: sRGB in 0–1, gamut
127
+ * mapped from whatever space the display is in. Rejects on an abort, and on
128
+ * a colour with no sRGB form at all (a pattern colour), which is the
129
+ * bridge's one error.
130
+ *
131
+ * **An abort ends our wait, not the sampler.** AppKit has no verb to
132
+ * dismiss it — the session ends when the user picks or presses Escape — so
133
+ * where the portal rung Closes its request and the X11 rung releases its
134
+ * grab, this one can only stop listening: the loupe stays up, the colour
135
+ * that arrives afterwards is dropped, and until then the pending sample
136
+ * holds the event loop open the way pending I/O does. Nothing is left
137
+ * grabbed, which is what the abort exists to guarantee.
138
+ */
139
+ function cocoaPick(opts, app) {
140
+ const signal = opts.signal;
141
+ if (signal?.aborted) {
142
+ return Promise.reject(signal.reason ?? new PortalCancelledError());
143
+ }
144
+ return new Promise((resolve, reject) => {
145
+ const onAbort = () => reject(signal.reason ?? new PortalCancelledError());
146
+ signal?.addEventListener('abort', onAbort, { once: true });
147
+ // No `settle()` gate, unlike the X11 rung: that one exists because a
148
+ // grab must be released exactly once on every path out, and an aborted
149
+ // pick here holds nothing — the loupe is the user's, not ours. The
150
+ // colour that lands after an abort reaches an already-rejected promise
151
+ // and is dropped by the promise itself.
152
+ const done = () => signal?.removeEventListener('abort', onAbort);
153
+
154
+ app.colorSampler.sample().then(
155
+ (color) => {
156
+ done();
157
+ // A dismissal is an ordinary outcome, not a throw — Escape on the
158
+ // X11 rung, the dialog's own cancel on the portal, this here.
159
+ if (color == null) return resolve(null);
160
+ const hex = hexFromPortalColor([color.r, color.g, color.b]);
161
+ if (!hex) {
162
+ return reject(
163
+ new Error(
164
+ 'react-x11: the system colour sampler answered without a ' +
165
+ 'colour — expected sRGB { r, g, b } in 0–1, got ' +
166
+ `${JSON.stringify(color)}.`,
167
+ ),
168
+ );
169
+ }
170
+ resolve(hex);
171
+ },
172
+ (err) => {
173
+ done();
174
+ reject(err);
175
+ },
176
+ );
177
+ });
178
+ }
179
+
180
+ // --------------------------------------------------------------------------
181
+ // Rung 2: the portal
76
182
  // --------------------------------------------------------------------------
77
183
 
78
184
  /**
@@ -131,7 +237,7 @@ async function portalCanPick(ref) {
131
237
  }
132
238
 
133
239
  // --------------------------------------------------------------------------
134
- // Rung 2: X11
240
+ // Rung 3: X11
135
241
  // --------------------------------------------------------------------------
136
242
 
137
243
  // x11.eventMask bits, spelled out the way xsettings.js spells its one. No
@@ -539,28 +645,87 @@ function appOf(target) {
539
645
  return target.app ?? target.window?.app ?? target.root?.window?.app ?? null;
540
646
  }
541
647
 
542
- /** The connection a pick would use, or null. */
648
+ /** The app a pick would use, or null. Not yet: a *connection*. */
543
649
  function appFor(opts) {
544
650
  return opts.app ?? appOf(opts.parentWindow);
545
651
  }
546
652
 
547
653
  /**
548
- * Which rung this machine lands on, without grabbing anything.
654
+ * Can this app run the X11 rung is its `X` a connection at all?
549
655
  *
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`.
656
+ * The cocoa backend hands the renderer an app whose `X` is a stub
657
+ * (src/cocoa/app.js): `InternAtom` and `on` so the modules with an X escape
658
+ * hatch no-op cleanly, and nothing else. Reaching the rung through it used
659
+ * to throw `X.AllocID is not a function` out of the promise — a crash where
660
+ * the ladder's whole contract is a typed "not here", and one an app cannot
661
+ * hide a button on.
662
+ *
663
+ * So the gate is the three requests the rung is actually built out of, asked
664
+ * of the object rather than of `process.platform` or the backend's name:
665
+ * `requireExtension()`'s rule, and the one that keeps the next backend from
666
+ * landing here by default too.
667
+ */
668
+ function canGrabOn(app) {
669
+ const X = app?.X;
670
+ return (
671
+ typeof X?.AllocID === 'function' &&
672
+ typeof X?.GrabPointer === 'function' &&
673
+ typeof X?.GetImage === 'function'
674
+ );
675
+ }
676
+
677
+ /**
678
+ * Why the X11 rung is out of reach, for the typed rejection — an app that
679
+ * cannot grab and no app at all are different mistakes with different
680
+ * fixes, and only one of them is the caller's.
681
+ */
682
+ function noX11Reason(app, backend) {
683
+ if (app) {
684
+ return (
685
+ 'this tree does not render through an X connection — a cocoa-backend ' +
686
+ 'app, for instance, whose `X` cannot grab the pointer or read a root ' +
687
+ "window. macOS's own sampler is the rung above, and this app does not " +
688
+ 'carry it: `@windowkit/appkit` is older than 0.9, which has no ' +
689
+ '`sampleScreenColor`. Until it is updated there is no rung here — ' +
690
+ '`useEyedropper().supported` is false, which is the signal to leave ' +
691
+ 'the eyedropper button undrawn (docs/macos.md).'
692
+ );
693
+ }
694
+ if (backend === 'x11') {
695
+ return (
696
+ "backend: 'x11' needs a connection to grab on. Pass `app` (from " +
697
+ 'createRoot() or useApp()), or a `parentWindow` that points at a ' +
698
+ 'mounted window.'
699
+ );
700
+ }
701
+ return undefined;
702
+ }
703
+
704
+ /**
705
+ * Which rung this machine lands on, without showing or grabbing anything.
706
+ *
707
+ * `'cocoa'` is the app carrying `colorSampler` — a cocoa-backend tree on
708
+ * `@windowkit/appkit` >= 0.9 — and it is asked first, so a Mac never falls
709
+ * through to a rung that would draw a worse picker. `'portal'` needs the
710
+ * Screenshot interface at version 2 — the probe reads the interface's
711
+ * `version` property, because `hasService()` cannot see which interfaces a
712
+ * 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`.
556
717
  *
557
718
  * Acquires a bus reference and releases it, so it is cheap but not free —
558
719
  * `useEyedropper().supported` caches it for you.
559
720
  *
560
- * @returns {Promise<'portal'|'x11'|null>}
721
+ * @returns {Promise<'cocoa'|'portal'|'x11'|null>}
561
722
  */
562
723
  export async function screenColorBackend(options = {}) {
563
724
  const backend = options.backend;
725
+ if (!backend || backend === 'cocoa') {
726
+ if (samplerApp(options)) return 'cocoa';
727
+ if (backend === 'cocoa') return null;
728
+ }
564
729
  if (!backend || backend === 'portal') {
565
730
  const ref = await sessionBus();
566
731
  if (ref) {
@@ -572,10 +737,23 @@ export async function screenColorBackend(options = {}) {
572
737
  }
573
738
  if (backend === 'portal') return null;
574
739
  }
575
- return appFor(options) ? 'x11' : null;
740
+ return canGrabOn(appFor(options)) ? 'x11' : null;
576
741
  }
577
742
 
578
743
  async function runPick(opts) {
744
+ const wantCocoa = !opts.backend || opts.backend === 'cocoa';
745
+ if (wantCocoa) {
746
+ const app = samplerApp(opts);
747
+ if (app) return await cocoaPick(opts, app);
748
+ if (opts.backend === 'cocoa') {
749
+ throw new NoScreenColorError(
750
+ "backend: 'cocoa' — no system colour sampler here: this tree does " +
751
+ 'not render through the cocoa backend, or its `@windowkit/appkit` ' +
752
+ 'is older than 0.9.',
753
+ );
754
+ }
755
+ }
756
+
579
757
  const wantPortal = !opts.backend || opts.backend === 'portal';
580
758
  if (wantPortal) {
581
759
  const ref = await sessionBus();
@@ -598,31 +776,27 @@ async function runPick(opts) {
598
776
  }
599
777
 
600
778
  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
- );
779
+ if (canGrabOn(app)) return x11Pick(opts, app);
780
+ throw new NoScreenColorError(noX11Reason(app, opts.backend));
609
781
  }
610
782
 
611
783
  /**
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.
784
+ * Sample one pixel from the screen: the system's own picker where there is
785
+ * one — `NSColorSampler` on macOS, the Screenshot portal on a desktop that
786
+ * has it — and a crosshair grab on plain X11 everywhere else.
614
787
  *
615
788
  * ```js
616
789
  * const hex = await pickScreenColor({ app });
617
790
  * if (hex) setFill(hex); // '#rrggbb'; null means cancelled
618
791
  * ```
619
792
  *
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.
793
+ * Resolves to **`'#rrggbb'`**, or `null` when the user cancelled (Escape,
794
+ * the portal dialog's own cancel, dismissing the sampler) — cancelling is an
795
+ * ordinary outcome and should not need a `try`. Rejects with
796
+ * {@link NoScreenColorError} when no rung is reachable, which is the signal
797
+ * to hide the button; `signal` aborts the pick, releasing the X11 grab or
798
+ * closing the portal request before the rejection is reported (the system
799
+ * sampler cannot be dismissed from code — see docs/eyedropper.md).
626
800
  *
627
801
  * In a component, reach for {@link useEyedropper} instead — it binds the
628
802
  * connection and the owner window, and exposes `picking`/`supported` as
@@ -33,12 +33,16 @@ import { useTopLevelWindow } from './windowid.js';
33
33
  *
34
34
  * `pick()` resolves to `'#rrggbb'`, or `null` when the user cancelled. It
35
35
  * never rejects for lack of a backend on an X11 tree — the connection this
36
- * tree renders through *is* the fallback rung — so `supported` is about the
37
- * forced-backend and future-platform cases, not a check most apps must make.
36
+ * tree renders through *is* the fallback rung — nor on a cocoa tree whose
37
+ * bridge has the system sampler (`@windowkit/appkit` >= 0.9). Where neither
38
+ * is true the flag is the answer: `supported` is false, and a picker that
39
+ * gates its eyedropper button on it simply does not draw one.
38
40
  *
39
41
  * The portal dialog is parented to the window this component is in, the
40
42
  * `useFileDialog()` way: resolved at the moment the pick starts, with
41
43
  * `parentWindow` as the override for a tree with several top-level windows.
44
+ * On the cocoa rung that window is what names the app whose sampler runs;
45
+ * the system draws the loupe over the whole screen, owned by no window.
42
46
  *
43
47
  * While a pick is in flight, `picking` is true and another `pick()` returns
44
48
  * **the same promise** — a double-clicked button must not queue a second
@@ -18,8 +18,17 @@ export interface DrawnNode {
18
18
  * was registered under. What queries and paint order match on:
19
19
  * `screen.all((n) => n.kind === 'gauge')`. */
20
20
  readonly kind: string;
21
- /** Position and size within the owning window, valid after layout. */
21
+ /** Position and size within the owning window, valid after layout. In
22
+ * **device pixels**, like everything painted — a mouse event's `x`/`y`
23
+ * are logical, `scale` apart (docs/scale.md). `ev.nativeEvent.x`/`y` are
24
+ * the same point as `ev.x`/`y` already in this unit, which is what a
25
+ * pointer measured against a laid-out box wants. */
22
26
  readonly abs: Rect;
27
+ /** Device pixels per logical pixel for this node: the display's scale
28
+ * times any `scale` prop above it, resolved once and constant for the
29
+ * node's life. The number that converts between `abs` and the logical
30
+ * unit an app writes styles and reads events in. */
31
+ readonly scale: number;
23
32
  readonly parent: DrawnNode | null;
24
33
  readonly children: readonly DrawnNode[];
25
34
  /** Take the keyboard focus, if this node is focusable. Returns the node,
@@ -42,14 +51,18 @@ export interface DrawnNode {
42
51
  readonly direction: 'ltr' | 'rtl';
43
52
  /** Whether `node` is this node or a descendant of it (DOM `contains`). */
44
53
  contains(node: DrawnNode | null): boolean;
54
+ /** The boxes this node occupies, in **logical** pixels — already divided
55
+ * by `scale`, so they compare directly with an event's `x`/`y`. One rect
56
+ * for a box; one per line for wrapped text. */
45
57
  getClientRects(): Rect[];
46
58
 
47
59
  // --- text geometry (docs/elements.md, "Selection") ------------------------
48
60
  //
49
61
  // Every drawn node answers these; an element with no text answers `null`,
50
62
  // `0` and `[]`. Indices are **code points** and rectangles are in the
51
- // owning window's coordinates — the same space as `abs` and a mouse
52
- // event's `x`/`y`.
63
+ // owning window's coordinates — the same space as `abs`, which is device
64
+ // pixels: this seam speaks that unit deliberately, so a point taken from
65
+ // an event arrives through `ev.nativeEvent` or multiplied by `scale`.
53
66
 
54
67
  /** This element's text, or null when it has none. */
55
68
  textContent(): string | null;
@@ -6,22 +6,26 @@ import type { AbortSignalLike, WindowTarget } from './filedialog.js';
6
6
  import type { NtkApp } from './nodes.js';
7
7
 
8
8
  /** Which rung of the ladder answered — or would. */
9
- export type ScreenColorBackend = 'portal' | 'x11';
9
+ export type ScreenColorBackend = 'cocoa' | 'portal' | 'x11';
10
10
 
11
11
  export interface PickScreenColorOptions {
12
12
  /**
13
13
  * The window the picker belongs to — `parent_window` for the portal, and
14
- * (when it points at a mounted node) the connection the X11 rung grabs on.
15
- * `useEyedropper()` infers it from the tree.
14
+ * (when it points at a mounted node) the app the cocoa rung samples
15
+ * through or the connection the X11 rung grabs on. `useEyedropper()`
16
+ * infers it from the tree.
16
17
  */
17
18
  parentWindow?: WindowTarget;
18
19
  /** Abort the pick. Closes the portal request, or releases the X11 grab —
19
- * the grab is released **before** the rejection is reported. */
20
+ * the grab is released **before** the rejection is reported. On the cocoa
21
+ * rung it ends the wait only: `NSColorSampler` cannot be dismissed from
22
+ * code, so the loupe stays up until the user answers it. */
20
23
  signal?: AbortSignalLike;
21
24
  /** Force a rung, for kiosks and for tests. */
22
25
  backend?: ScreenColorBackend;
23
26
  /**
24
- * The connection the X11 rung grabs and reads on. Required for that rung
27
+ * The app a pick runs through: the cocoa rung's `colorSampler`, or the
28
+ * connection the X11 rung grabs and reads on. Required for either rung
25
29
  * when `parentWindow` does not resolve to a mounted node — the hook passes
26
30
  * the tree's own.
27
31
  */
@@ -40,23 +44,25 @@ export declare class NoScreenColorError extends Error {
40
44
  }
41
45
 
42
46
  /**
43
- * Sample one pixel from the screen: the desktop's own picker
44
- * (`org.freedesktop.portal.Screenshot.PickColor`, Screenshot interface
45
- * version 2) where there is one, a crosshair pointer grab on plain X11
46
- * everywhere else.
47
+ * Sample one pixel from the screen: the system's own picker where there is
48
+ * one — `NSColorSampler` on the cocoa backend (`@windowkit/appkit` >= 0.9),
49
+ * `org.freedesktop.portal.Screenshot.PickColor` (Screenshot interface
50
+ * version 2) on a desktop that has it — and a crosshair pointer grab on
51
+ * plain X11 everywhere else.
47
52
  *
48
53
  * Resolves to `'#rrggbb'`, or `null` when the user cancelled — Escape on the
49
- * X11 rung, the dialog's own cancel on the portal. Rejects with
50
- * {@link NoScreenColorError} when neither rung is reachable.
54
+ * X11 rung, the dialog's own cancel on the portal, a dismissed sampler on
55
+ * cocoa. Rejects with {@link NoScreenColorError} when no rung is reachable.
51
56
  */
52
57
  export declare function pickScreenColor(
53
58
  options?: PickScreenColorOptions,
54
59
  ): Promise<string | null>;
55
60
 
56
61
  /**
57
- * Which rung this machine lands on, without grabbing anything. `'x11'` needs
58
- * a connection to answer with — pass `app`, or a `parentWindow` pointing at
59
- * a mounted node — and `null` means {@link pickScreenColor} would reject.
62
+ * Which rung this machine lands on, without showing or grabbing anything.
63
+ * `'cocoa'` and `'x11'` both need an app to answer through — pass `app`, or
64
+ * a `parentWindow` pointing at a mounted node — and `null` means
65
+ * {@link pickScreenColor} would reject.
60
66
  */
61
67
  export declare function screenColorBackend(
62
68
  options?: Pick<PickScreenColorOptions, 'app' | 'backend' | 'parentWindow'>,