react-x11 2.15.2 → 2.16.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 (64) hide show
  1. package/README.md +37 -0
  2. package/package.json +4 -3
  3. package/src/Reconciler.js +85 -22
  4. package/src/anchor.js +60 -18
  5. package/src/application.js +25 -1
  6. package/src/capabilities.js +349 -0
  7. package/src/cocoa/app.js +28 -9
  8. package/src/cocoa/context2d.js +139 -6
  9. package/src/cocoa/fonts.js +78 -0
  10. package/src/cocoa/presenter.js +17 -0
  11. package/src/cocoa/promotion.js +20 -0
  12. package/src/cocoa/relaunch.js +8 -3
  13. package/src/cocoa/symbols.js +64 -0
  14. package/src/cocoa/threaded.js +24 -4
  15. package/src/cocoa/window.js +362 -139
  16. package/src/components/ProgressBar.js +1 -1
  17. package/src/components/Slider.js +72 -39
  18. package/src/components/anchor.js +7 -2
  19. package/src/components/index.js +1 -0
  20. package/src/components/theme.js +32 -28
  21. package/src/dbusmenuexport.js +243 -0
  22. package/src/desktopcapabilityhooks.js +160 -0
  23. package/src/filedialoghooks.js +3 -5
  24. package/src/frame/childmain.js +8 -20
  25. package/src/frame/env.js +2 -10
  26. package/src/globalmenu.js +3 -205
  27. package/src/icontheme.js +240 -0
  28. package/src/imagesource.js +98 -3
  29. package/src/index.d.ts +1 -0
  30. package/src/index.js +11 -2
  31. package/src/launcher.js +235 -32
  32. package/src/launcherhooks.js +47 -28
  33. package/src/node.d.ts +7 -0
  34. package/src/nodes/animation.js +17 -47
  35. package/src/nodes/cascade.js +17 -2
  36. package/src/nodes/image.js +65 -2
  37. package/src/nodes/kinds.js +12 -0
  38. package/src/nodes/layout.js +5 -1
  39. package/src/nodes/node.js +17 -3
  40. package/src/nodes/paint.js +117 -0
  41. package/src/nodes/scope.js +259 -0
  42. package/src/nodes/scrollable.js +53 -6
  43. package/src/nodes/text.js +2 -0
  44. package/src/nodes/textarea.js +1 -1
  45. package/src/nodes/textinput.js +1 -1
  46. package/src/nodes/window/anchoring.js +45 -18
  47. package/src/nodes/window/flush.js +6 -5
  48. package/src/nodes/window/popup.js +10 -0
  49. package/src/nodes/window/size.js +40 -2
  50. package/src/nodes/window/window.js +41 -14
  51. package/src/registry.js +2 -1
  52. package/src/settings.js +332 -0
  53. package/src/statusnotifier.js +752 -0
  54. package/src/styles.js +212 -8
  55. package/src/symbols.js +200 -0
  56. package/src/testing/mock-app.js +10 -0
  57. package/src/trayhooks.js +193 -29
  58. package/src/types/capabilities.d.ts +139 -0
  59. package/src/types/components.d.ts +33 -0
  60. package/src/types/elements.d.ts +57 -6
  61. package/src/types/launcher.d.ts +50 -4
  62. package/src/types/style.d.ts +57 -0
  63. package/src/types/system.d.ts +104 -0
  64. package/src/types/tray.d.ts +64 -6
@@ -0,0 +1,349 @@
1
+ // What this desktop can actually do — feature discovery for the things an app
2
+ // does *outside* its own windows.
3
+ //
4
+ // ## Why this is not `useSupports()`
5
+ //
6
+ // `useSupports()` answers questions about the **display**: is there a
7
+ // compositor, is there a 32-bit visual, did this connection get the direct GL
8
+ // backend. All of them are local, synchronous, and knowable before the first
9
+ // frame.
10
+ //
11
+ // Everything on this page is the opposite on all three counts. Whether there
12
+ // is a notification daemon, a tray host or a launcher listening is a fact
13
+ // about **another process on a bus**, it takes a round trip to learn, and it
14
+ // changes while the app runs — a panel restarts, an extension is toggled, a
15
+ // user logs into a different session type. So the shape has to be render
16
+ // state that settles and then follows, not a boolean that is right on the
17
+ // first frame.
18
+ //
19
+ // ## Why a boolean is not enough
20
+ //
21
+ // "Does this desktop have notifications" is the wrong question, because the
22
+ // answer is yes on machines that mean four different things by it:
23
+ //
24
+ // - a freedesktop daemon with `actions` — a banner with buttons that call
25
+ // back into the app, updated in place, reporting what the user did;
26
+ // - a freedesktop daemon **without** `actions` — GNOME's own for years,
27
+ // and several minimal ones — where the same call shows a banner and the
28
+ // buttons silently never appear;
29
+ // - macOS's notification centre — actions and callbacks, but only for a
30
+ // signed bundle, and a different vocabulary underneath;
31
+ // - `notify-send` or `osascript` — one-way text with an urgency and an
32
+ // icon, no update, no close, no events, ever.
33
+ //
34
+ // An app that wants "reply from the notification" has to know which of those
35
+ // it has, and the honest unit is therefore a **feature set**, not a flag. The
36
+ // freedesktop daemons already publish exactly this through `GetCapabilities`;
37
+ // this module's job is to translate every backend's answer into one portable
38
+ // vocabulary so an app branches on the feature and never on the platform.
39
+ //
40
+ // ## The rule for adding one
41
+ //
42
+ // A capability name is a *thing an app wants to do*, and its features are the
43
+ // parts of it that a backend can honestly lack. If a feature is missing
44
+ // everywhere but one backend it is still a feature — `badgeText` is macOS's
45
+ // alone and is listed — but if a "feature" is really a different way of doing
46
+ // the same thing, it belongs in `backend` instead. Backends are named after
47
+ // the mechanism (`statusnotifier`, `cocoa`, `notify-send`), never after the
48
+ // platform, so that a second Linux mechanism does not need a second name for
49
+ // Linux.
50
+
51
+ import { sessionBus } from './bus.js';
52
+ import { currentRegistration, currentRegistrationRole } from './application.js';
53
+ import {
54
+ notificationBackend,
55
+ NOTIFICATIONS_NAME,
56
+ NOTIFICATIONS_PATH,
57
+ } from './notifications.js';
58
+ import { WATCHER_NAME } from './statusnotifier.js';
59
+ import { liveApps } from './trace-registry.js';
60
+
61
+ /** Every capability this module can answer for. */
62
+ export const CAPABILITIES = ['notifications', 'tray', 'launcher'];
63
+
64
+ /** The shape a probe resolves to when there is no mechanism at all. */
65
+ const NONE = Object.freeze({ available: false, backend: null, features: {} });
66
+
67
+ const frozen = (backend, features) =>
68
+ Object.freeze({
69
+ available: true,
70
+ backend,
71
+ features: Object.freeze(features),
72
+ });
73
+
74
+ /** The app to ask when the caller did not say. Mirrors `launcher.js`. */
75
+ function soleApp() {
76
+ const apps = liveApps();
77
+ if (apps.length <= 1) return apps[0] ?? null;
78
+ const showing = apps.filter((app) => (app._rootChildren ?? []).length > 0);
79
+ return showing.length === 1 ? showing[0] : null;
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // notifications
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /**
87
+ * The freedesktop daemon's own `GetCapabilities`, translated.
88
+ *
89
+ * The daemon publishes a flat list of strings; the names below are the ones
90
+ * that change what an app may do. Anything not in the list is absent, which
91
+ * is why every field is read with `includes` rather than defaulted true —
92
+ * a daemon that lists nothing supports nothing but a banner.
93
+ */
94
+ function fromDaemonCaps(caps) {
95
+ const has = (name) => caps.includes(name);
96
+ return {
97
+ // The two that decide whether a notification is a conversation or a sign.
98
+ actions: has('actions'),
99
+ events: has('actions'),
100
+ // Every fd.o daemon can replace and close by id; neither is advertised
101
+ // as a capability because the protocol requires both.
102
+ update: true,
103
+ close: true,
104
+ body: has('body'),
105
+ bodyMarkup: has('body-markup'),
106
+ bodyImage: has('body-images'),
107
+ icon: has('icon-static') || has('icon-multi'),
108
+ sound: has('sound'),
109
+ // The banner survives in a tray/centre rather than expiring unseen.
110
+ persistence: has('persistence'),
111
+ urgency: true,
112
+ };
113
+ }
114
+
115
+ async function probeNotifications({ app } = {}) {
116
+ const backend = await notificationBackend({ app });
117
+ if (!backend) return NONE;
118
+
119
+ if (backend === 'dbus') {
120
+ const ref = await sessionBus();
121
+ if (!ref) return NONE;
122
+ try {
123
+ const iface = await ref.bus.getInterface(
124
+ NOTIFICATIONS_NAME,
125
+ NOTIFICATIONS_PATH,
126
+ NOTIFICATIONS_NAME,
127
+ );
128
+ const caps = await new Promise((resolve) => {
129
+ iface.GetCapabilities((err, list) => resolve(err ? [] : (list ?? [])));
130
+ });
131
+ return frozen('dbus', fromDaemonCaps(caps));
132
+ } catch {
133
+ // The name is there but the object will not answer. A banner will
134
+ // probably still post; nothing richer should be promised.
135
+ return frozen('dbus', fromDaemonCaps([]));
136
+ } finally {
137
+ await ref.release();
138
+ }
139
+ }
140
+
141
+ if (backend === 'cocoa') {
142
+ return frozen('cocoa', {
143
+ actions: true,
144
+ events: true,
145
+ update: true,
146
+ close: true,
147
+ body: true,
148
+ bodyMarkup: false, // the centre renders plain text
149
+ bodyImage: true,
150
+ icon: true,
151
+ sound: true,
152
+ persistence: true,
153
+ urgency: true, // mapped onto interruption levels
154
+ });
155
+ }
156
+
157
+ // `osascript` and `notify-send`: one way, and that is the whole of it.
158
+ // `notify-send -p` prints an id on newer libnotify, which is why `update`
159
+ // is not flatly false there — see notifications.js.
160
+ return frozen(backend, {
161
+ actions: false,
162
+ events: false,
163
+ update: backend === 'notify-send',
164
+ close: false,
165
+ body: true,
166
+ bodyMarkup: false,
167
+ bodyImage: false,
168
+ icon: true,
169
+ sound: false,
170
+ persistence: false,
171
+ urgency: backend === 'notify-send',
172
+ });
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // tray
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /** The tray on an app that has one of its own, which needs nothing asked. */
180
+ function trayNow(target) {
181
+ if (typeof target?.createStatusItem === 'function') {
182
+ return frozen('cocoa', {
183
+ menu: true,
184
+ iconName: true, // SF Symbols
185
+ iconBytes: true,
186
+ attention: false, // no NeedsAttention equivalent on a status item
187
+ overlay: false,
188
+ tooltip: true,
189
+ title: true,
190
+ click: true,
191
+ clickPosition: true,
192
+ clickRect: true,
193
+ clickModifiers: true,
194
+ scroll: false,
195
+ });
196
+ }
197
+ return null;
198
+ }
199
+
200
+ async function probeTray({ app } = {}) {
201
+ const now = trayNow(app ?? soleApp());
202
+ if (now) return now;
203
+
204
+ const ref = await sessionBus();
205
+ if (!ref) return NONE;
206
+ try {
207
+ // A **live owner**, not an activatable name — see `statusnotifier.js`.
208
+ if (!(await ref.bus.nameHasOwner(WATCHER_NAME))) return NONE;
209
+ } catch {
210
+ return NONE;
211
+ } finally {
212
+ await ref.release();
213
+ }
214
+ return frozen('statusnotifier', {
215
+ menu: true,
216
+ iconName: true, // themed icon names, which is the good path here
217
+ iconBytes: true, // ARGB pixmaps
218
+ attention: true,
219
+ overlay: true,
220
+ tooltip: true,
221
+ title: true,
222
+ click: true,
223
+ clickPosition: true,
224
+ // The protocol carries none of these — see `statusnotifier.js`.
225
+ clickRect: false,
226
+ clickModifiers: false,
227
+ scroll: true,
228
+ });
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // launcher
233
+ // ---------------------------------------------------------------------------
234
+
235
+ /** The Dock tile on an app that has one, which needs nothing asked. */
236
+ function launcherNow(target) {
237
+ if (typeof target?.setDockBadge === 'function') {
238
+ return frozen('cocoa', {
239
+ badge: true,
240
+ badgeText: true, // the tile takes any label
241
+ progress: false, // NSDockTile has no progress bar
242
+ urgent: true, // requestUserAttention, via window states
243
+ menu: typeof target.setDockMenu === 'function',
244
+ // The Dock always shows the app; nothing has to be installed for it.
245
+ needsDesktopFile: false,
246
+ });
247
+ }
248
+ return null;
249
+ }
250
+
251
+ async function probeLauncher({ app } = {}) {
252
+ const now = launcherNow(app ?? soleApp());
253
+ if (now) return now;
254
+
255
+ // The Linux rung needs two things that are not the bus: an app id to
256
+ // attribute the entry to, and a `.desktop` file of that name for the
257
+ // launcher to hang it on. The first is knowable here; the second is not
258
+ // (it is a file on a path the launcher chooses), which is why it is
259
+ // reported as a *requirement* rather than as availability.
260
+ const ref = await sessionBus();
261
+ if (!ref) return NONE;
262
+ await ref.release();
263
+ if (!currentRegistration()?.appId) {
264
+ return Object.freeze({
265
+ available: false,
266
+ backend: null,
267
+ features: {},
268
+ // Two different "no"s, and only one is a mistake. A **secondary**
269
+ // instance called `registerApplication()` and lost the race for the
270
+ // name — the first copy owns the badge and the quicklist, which is the
271
+ // whole point of single-instance — so telling its author to call a
272
+ // function they already called sends them after a bug that is not
273
+ // there. The launcher is genuinely unavailable *to this process*
274
+ // either way; only the advice differs.
275
+ reason:
276
+ currentRegistrationRole() === 'secondary' ? 'not-primary' : 'no-app-id',
277
+ });
278
+ }
279
+ return frozen('launcherentry', {
280
+ badge: true,
281
+ badgeText: false, // the protocol carries a count and nothing else
282
+ progress: true,
283
+ urgent: true,
284
+ menu: true, // the quicklist
285
+ needsDesktopFile: true,
286
+ });
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+
291
+ const PROBES = {
292
+ notifications: probeNotifications,
293
+ tray: probeTray,
294
+ launcher: probeLauncher,
295
+ };
296
+
297
+ /**
298
+ * What this desktop can do for one feature, as a promise.
299
+ *
300
+ * ```js
301
+ * const n = await desktopCapability('notifications');
302
+ * if (n.features.actions) postWithReplyButton();
303
+ * else postPlainBanner();
304
+ * ```
305
+ *
306
+ * Resolves to `{ available, backend, features }`. `available` false means
307
+ * there is no mechanism at all and `features` is empty; otherwise `backend`
308
+ * names the mechanism — `'dbus'`, `'cocoa'`, `'statusnotifier'`,
309
+ * `'launcherentry'`, `'notify-send'`, `'osascript'` — and `features` is the
310
+ * portable vocabulary for this capability.
311
+ *
312
+ * **Never cached.** A panel restarting, an extension being enabled or a
313
+ * daemon being installed all change the answer, and a cached "no" would
314
+ * outlive every one of them. {@link useDesktopCapability} re-probes on the same
315
+ * events that would change it.
316
+ */
317
+ export async function desktopCapability(name, options = {}) {
318
+ const probe = PROBES[name];
319
+ if (!probe) {
320
+ throw new TypeError(
321
+ `react-x11: desktopCapability(${JSON.stringify(name)}) — no such ` +
322
+ `capability. Expected ${CAPABILITIES.join(', ')}.`,
323
+ );
324
+ }
325
+ try {
326
+ return await probe(options);
327
+ } catch {
328
+ // A probe that throws is a desktop that could not be asked, which is the
329
+ // same outcome for a caller as one that answered no.
330
+ return NONE;
331
+ }
332
+ }
333
+
334
+ /** The "nothing here" answer, exported so a caller can compare against it and
335
+ * so the hook has something stable to return on the first frame. */
336
+ export const NO_CAPABILITY = NONE;
337
+
338
+ /**
339
+ * The answer for a capability where it is known without asking anything —
340
+ * the Cocoa app's own tray and Dock tile — or null where finding out takes a
341
+ * round trip. What lets `useDesktopCapability` be settled on its first frame
342
+ * where the answer never needed waiting for.
343
+ */
344
+ export function capabilityNow(name, { app } = {}) {
345
+ const target = app ?? soleApp();
346
+ if (name === 'tray') return trayNow(target);
347
+ if (name === 'launcher') return launcherNow(target);
348
+ return null;
349
+ }
package/src/cocoa/app.js CHANGED
@@ -38,7 +38,9 @@ import { CocoaPaneWindow } from './panewindow.js';
38
38
  import { CocoaColorSampler } from './screencolor.js';
39
39
  import { CocoaFilePanels } from './filepanels.js';
40
40
  import { CocoaFontManager } from './fonts.js';
41
+ import { releaseImageUpload } from './context2d.js';
41
42
  import { CocoaSurface } from './surface.js';
43
+ import { CocoaSymbols } from './symbols.js';
42
44
  import { CocoaWindow } from './window.js';
43
45
  import { decodeKey, modifierMask } from './keymap.js';
44
46
  import { loadNative } from './native.js';
@@ -199,6 +201,8 @@ export class CocoaApp {
199
201
  // real bridge on the machine — the manager's default loads it only when
200
202
  // it is built standalone
201
203
  this.fonts = new CocoaFontManager(native);
204
+ // `<image src={{ symbol }}>`'s names are SF Symbols here (src/symbols.js)
205
+ this.symbols = new CocoaSymbols(native);
202
206
 
203
207
  // Native open/save panels (src/cocoa/filepanels.js). Present exactly
204
208
  // when the bridge has them (>= 0.5), and its presence is what puts the
@@ -449,6 +453,18 @@ export class CocoaApp {
449
453
  return new CocoaSurface(this, options);
450
454
  }
451
455
 
456
+ /**
457
+ * The release seam for an `Image`'s upload (`freeImage`, src/imagesource.js).
458
+ * `ctx.drawImage(image)` here composites from a CG bitmap made for the
459
+ * Image on its first draw, which ntk's `Image.destroy()` — written for X,
460
+ * where the upload is a pixmap it tracks itself — cannot see; an owner
461
+ * letting go of an Image calls this too, and the bitmap is freed on the
462
+ * call. An Image nobody releases takes its bitmap with it when collected.
463
+ */
464
+ releaseImage(image) {
465
+ releaseImageUpload(image);
466
+ }
467
+
452
468
  /**
453
469
  * The `useGlobalMenu` transport seam: same owner shape as the D-Bus
454
470
  * GlobalMenuExport (start/stop/update), pointed at the macOS menu bar.
@@ -628,6 +644,8 @@ export class CocoaApp {
628
644
  _unregisterWindow(wnd) {
629
645
  this._windows.delete(wnd._key);
630
646
  if (this._grabWindow === wnd) this._grabWindow = null;
647
+ // a popup that took the keyboard and closed is not where keys go next
648
+ if (this._lastKeyWindow === wnd) this._lastKeyWindow = null;
631
649
  }
632
650
 
633
651
  // --- the pump ------------------------------------------------------------
@@ -789,8 +807,8 @@ export class CocoaApp {
789
807
  this._rafQueue = [];
790
808
  let soonest = Infinity;
791
809
  for (const entry of queue) {
792
- // A window whose last flip has not given its back buffer back yet
793
- // (threaded mode's fence, `CocoaWindow.frameInFlight`) waits before
810
+ // A window whose last flip has not reached its layer yet (threaded
811
+ // mode's fence, `CocoaWindow.frameInFlight`) waits before
794
812
  // its clock is asked, or the clock would count a frame that did not
795
813
  // run. The release is an event, and the batch it arrives in ticks
796
814
  // again.
@@ -861,7 +879,7 @@ export class CocoaApp {
861
879
  * paid once, when the batch is done: ten moves and a click that crossed
862
880
  * while this thread was busy are one frame, not eleven. Then what a pump
863
881
  * tick does: the frames that are due, and the ones a window's visibility
864
- * or its back buffer was holding, which an occlusion change or a
882
+ * or its last flip was holding, which an occlusion change or a
865
883
  * `surface-released` in this very batch may just have freed.
866
884
  */
867
885
  _routeBatch(batch) {
@@ -1077,9 +1095,9 @@ export class CocoaApp {
1077
1095
  // the notch and the scroll, most of a refresh period of nothing.
1078
1096
  //
1079
1097
  // Mostly: a trackpad's momentum lands two in one tick often, and a
1080
- // second flip inside the refresh draws into the buffer the first one
1081
- // just took off glass (`CocoaWindow._flippedRecently` says why that
1082
- // shows). So the rest of a burst lands React's half and leaves the
1098
+ // second flip inside the refresh is a frame the display never shows
1099
+ // (`CocoaWindow._flippedRecently` says what it cost). So the rest of a
1100
+ // burst lands React's half and leaves the
1083
1101
  // paint to the paced frame the scroll already asked for — the model
1084
1102
  // has scrolled, and the next refresh shows all of it.
1085
1103
  if (wnd._flippedRecently()) {
@@ -1188,9 +1206,10 @@ export class CocoaApp {
1188
1206
  /**
1189
1207
  * `surface-released`: a worker's frame took an IOSurface off a layer and
1190
1208
  * the frame that replaced it has committed (windowkit/appkit#52). The
1191
- * window whose back buffer that was stops waiting on it
1192
- * (`CocoaWindow._surfaceReleased`), and the tick at the end of this
1193
- * batch runs the frame it was holding.
1209
+ * window whose buffer that was stops waiting on it, and may draw into it
1210
+ * again once the WindowServer lets go of it too
1211
+ * (`CocoaWindow._surfaceReleased`); the tick at the end of this batch
1212
+ * runs the frame it was holding.
1194
1213
  */
1195
1214
  _routeSurfaceReleased(ev) {
1196
1215
  for (const wnd of this._windows.values()) {
@@ -33,6 +33,100 @@ function parseColor(value) {
33
33
  return parsed;
34
34
  }
35
35
 
36
+ /** Bytes as a Buffer over the same memory — for a view, its own window of
37
+ * the ArrayBuffer, not the whole buffer from offset 0. */
38
+ function toBuffer(data) {
39
+ if (Buffer.isBuffer(data)) return data;
40
+ if (ArrayBuffer.isView(data)) {
41
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
42
+ }
43
+ return Buffer.from(data);
44
+ }
45
+
46
+ // --- ntk Images as drawImage sources -----------------------------------------
47
+ //
48
+ // An ntk `Image` is straight RGBA in JS memory. On X, ntk uploads it to a
49
+ // pixmap per connection and caches that on the Image; here the upload is a
50
+ // CG bitmap made on the first draw and kept in this map, then composited
51
+ // through `ctxDrawSurface` like any surface, scaling and cropping included.
52
+ // The bridge's `ctxPutImageData` does the conversion — it premultiplies the
53
+ // straight bytes into the bitmap's BGRA (ByteOrder32Host + AlphaFirst) —
54
+ // so the bytes go over as the Image holds them.
55
+ //
56
+ // Images are immutable content (ntk's contract, and `<image>`'s), so an
57
+ // entry is never refreshed. The map is keyed weakly: an Image that is
58
+ // dropped takes its entry along and the handle's finalizer frees the
59
+ // bitmap; an owner that is done with one frees it on the call instead,
60
+ // through `releaseImageUpload` (the app's `releaseImage` seam).
61
+
62
+ /** Image -> { native, handle } */
63
+ const imageUploads = new WeakMap();
64
+
65
+ /** An ntk `Image`: the duck type `isDirectImageSource` and ntk's own
66
+ * `drawImage` accept, plus the pixels this backend reads in place of the
67
+ * picture. A bare `{ width, height, data }` is not one — `ImageData` is
68
+ * written between draws, and caching it by identity would show stale
69
+ * pixels. */
70
+ function isImagePixels(image) {
71
+ const { width, height, data } = image;
72
+ return (
73
+ typeof image.picture === 'function' &&
74
+ Number.isInteger(width) &&
75
+ Number.isInteger(height) &&
76
+ width > 0 &&
77
+ height > 0 &&
78
+ data?.length === width * height * 4
79
+ );
80
+ }
81
+
82
+ function uploadImage(native, image) {
83
+ const held = imageUploads.get(image);
84
+ if (held?.native === native) return held.handle;
85
+ const { width, height } = image;
86
+ const handle = native.createSurface(width, height, 1);
87
+ native.ctxPutImageData(handle, toBuffer(image.data), width, height, 0, 0);
88
+ imageUploads.set(image, { native, handle });
89
+ return handle;
90
+ }
91
+
92
+ /** Free an Image's bitmap now, if it has one; drawing it again uploads it
93
+ * again, as ntk's `destroy()` promises for its own copies. */
94
+ export function releaseImageUpload(image) {
95
+ const held = image != null && imageUploads.get(image);
96
+ if (!held) return;
97
+ imageUploads.delete(image);
98
+ if (typeof held.native.releaseSurface === 'function') {
99
+ held.native.releaseSurface(held.handle);
100
+ }
101
+ }
102
+
103
+ const warnedSources = new Set();
104
+
105
+ /** A source this backend has no pixels for, said once per kind in
106
+ * development — the alternative is an empty box and no reason. */
107
+ function warnUndrawable(image) {
108
+ if (process.env.NODE_ENV === 'production') return;
109
+ const kind =
110
+ typeof image === 'object'
111
+ ? (image.constructor?.name ?? 'object')
112
+ : typeof image;
113
+ if (warnedSources.has(kind)) return;
114
+ warnedSources.add(kind);
115
+ const serverSide = typeof image === 'object' && 'id' in image;
116
+ const article = /^[aeiou]/i.test(kind) ? 'an' : 'a';
117
+ console.warn(
118
+ `react-x11: drawImage on the cocoa backend has no pixels for ${article} ${kind}, ` +
119
+ 'and draws nothing. ' +
120
+ (serverSide
121
+ ? 'It names an X server-side Picture or Drawable — <image picture>, ' +
122
+ '<image drawable>, an ntk Picture — and this backend has no X ' +
123
+ 'server to composite from, so those are X11-only. Hand <image src> ' +
124
+ 'the pixels instead: encoded PNG/JPEG bytes, raw RGBA, or an ntk Image.'
125
+ : 'This backend draws a Surface (react-x11/ntk) or an ntk Image; wrap ' +
126
+ 'raw RGBA as new Image({ width, height, data }).'),
127
+ );
128
+ }
129
+
36
130
  class LinearGradient {
37
131
  constructor(x0, y0, x1, y1) {
38
132
  this._coords = [x0, y0, x1, y1];
@@ -952,9 +1046,32 @@ export class CocoaContext2D {
952
1046
  this._dirty();
953
1047
  }
954
1048
 
1049
+ /**
1050
+ * An SF Symbol by name, fitted into the rect and centred, in the fill
1051
+ * colour — `ctxDrawSymbol`, `@windowkit/appkit` 0.12.0. Answers false,
1052
+ * drawing nothing, for a name the system does not know and on a bridge
1053
+ * without the verb. `options` are the bridge's: `pointSize`, `weight`,
1054
+ * `scale`, `variableValue`.
1055
+ */
1056
+ drawSymbol(name, x, y, width, height, options) {
1057
+ if (typeof this._native.ctxDrawSymbol !== 'function') return false;
1058
+ this._applyFill();
1059
+ const drawn = this._native.ctxDrawSymbol(
1060
+ this._s(),
1061
+ name,
1062
+ x,
1063
+ y,
1064
+ width,
1065
+ height,
1066
+ options,
1067
+ );
1068
+ if (drawn) this._dirty();
1069
+ return drawn === true;
1070
+ }
1071
+
955
1072
  drawImage(image, ...args) {
956
- const src = image?._surfaceHandle ?? image?._surface?._surfaceHandle;
957
- if (!src) return; // ntk Images/Pictures are not on this backend yet
1073
+ const src = this._sourceHandle(image);
1074
+ if (!src) return;
958
1075
  const size = this._native.surfaceSize(src);
959
1076
  let sx = 0;
960
1077
  let sy = 0;
@@ -990,6 +1107,25 @@ export class CocoaContext2D {
990
1107
  this._dirty();
991
1108
  }
992
1109
 
1110
+ /**
1111
+ * The bitmap a `drawImage` source composites from: a surface's own, an
1112
+ * ntk Image's upload (made on its first draw, see `uploadImage`), or
1113
+ * none. A destroyed surface is none, silently — it had pixels once; any
1114
+ * other source is one this backend cannot draw at all, and development
1115
+ * says so once per kind.
1116
+ */
1117
+ _sourceHandle(image) {
1118
+ if (image == null) return null;
1119
+ if (typeof image === 'object') {
1120
+ if ('_surfaceHandle' in image || image._surface) {
1121
+ return image._surfaceHandle ?? image._surface?._surfaceHandle ?? null;
1122
+ }
1123
+ if (isImagePixels(image)) return uploadImage(this._native, image);
1124
+ }
1125
+ warnUndrawable(image);
1126
+ return null;
1127
+ }
1128
+
993
1129
  /**
994
1130
  * `drawImage` as a row memcpy, for the one shape where a copy is all it
995
1131
  * ever was: a surface composited into another at a translate, whole
@@ -1081,12 +1217,9 @@ export class CocoaContext2D {
1081
1217
 
1082
1218
  putImageData(data, x, y) {
1083
1219
  if (!data?.data) return;
1084
- const buf = Buffer.isBuffer(data.data)
1085
- ? data.data
1086
- : Buffer.from(data.data.buffer ?? data.data);
1087
1220
  this._native.ctxPutImageData(
1088
1221
  this._s(),
1089
- buf,
1222
+ toBuffer(data.data),
1090
1223
  data.width,
1091
1224
  data.height,
1092
1225
  Math.round(x),