react-x11 1.2.0 → 2.0.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 (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. package/src/scene3d.js +0 -683
@@ -0,0 +1,45 @@
1
+ // Asking the server for an extension, once per connection.
2
+ //
3
+ // `X.require` caches the extension object only once its reply lands, so two
4
+ // callers in the same tick both put a QueryExtension on the wire and one of
5
+ // the answers is thrown away. That did not matter while each extension had a
6
+ // single caller that awaited it in turn. It does now: the startup probes run
7
+ // concurrently, and both the compositing watch and the XSETTINGS watch want
8
+ // XFixes at the same moment.
9
+ //
10
+ // So the promise is memoized, not the result — a second caller arriving
11
+ // while the first is still in flight subscribes to it instead of asking
12
+ // again. Keyed per connection, and null (an extension the server does not
13
+ // have) is cached like any other answer: it is a real reply, and re-asking
14
+ // would put the same question back on the wire every time.
15
+ const tables = new WeakMap();
16
+
17
+ /**
18
+ * The extension object for `name` on this connection, or null when the
19
+ * server does not have it (or the connection cannot answer).
20
+ *
21
+ * @param {object} app the ntk App
22
+ * @param {string} name node-x11's extension name, e.g. 'fixes'
23
+ * @returns {Promise<object|null>}
24
+ */
25
+ export function requireExtension(app, name) {
26
+ const X = app?.X;
27
+ if (!X || typeof X.require !== 'function') return Promise.resolve(null);
28
+ let table = tables.get(X);
29
+ if (!table) {
30
+ table = new Map();
31
+ tables.set(X, table);
32
+ }
33
+ let pending = table.get(name);
34
+ if (!pending) {
35
+ pending = new Promise((resolve) => {
36
+ try {
37
+ X.require(name, (err, ext) => resolve(err || !ext ? null : ext));
38
+ } catch {
39
+ resolve(null);
40
+ }
41
+ });
42
+ table.set(name, pending);
43
+ }
44
+ return pending;
45
+ }
@@ -0,0 +1,375 @@
1
+ // Open, save, and pick a folder — through whatever this machine actually has.
2
+ //
3
+ // There is no one answer, so this is a ladder, tried in order:
4
+ //
5
+ // 1. **the portal** — `org.freedesktop.portal.FileChooser` over D-Bus. The
6
+ // real desktop dialog, with the user's bookmarks and recent files, drawn
7
+ // by GTK or KDE in another process. What a Linux desktop should get.
8
+ // 2. **`osascript`** — macOS with no portal, which is every XQuartz install
9
+ // that has not gone out of its way. `choose file` is `NSOpenPanel`, so
10
+ // the user gets the dialog they know.
11
+ // 3. **the built-in dialog** — a file browser drawn by react-x11 itself.
12
+ // Reached over ssh, under a bare `startx`, in a container: everywhere
13
+ // there is a display and nothing else. See `components/FileDialog.js`;
14
+ // it needs a React tree, so `useFileDialog()` has it and the bare
15
+ // functions here do not.
16
+ //
17
+ // The ladder is the whole design. A file dialog that only works on a full
18
+ // GNOME session would be useless to the person running this over ssh from a
19
+ // laptop, which is the case react-x11 exists for.
20
+ //
21
+ // ## What each rung cannot do
22
+ //
23
+ // - **The portal never embeds.** The dialog is a top-level window in another
24
+ // process; all you get is logical parenting, through `parent_window`. That
25
+ // is `transientFor` by another name, and it is why these calls want a window
26
+ // to point at.
27
+ // - **macOS has no cross-process transient-for at all.** XQuartz windows are
28
+ // `NSWindow`s owned by X11.app, and `addChildWindow` is same-process only.
29
+ // So the panel appears over the app but is not attached to it. Application
30
+ // modality still works, because the caller is awaiting the promise.
31
+ // - **The built-in dialog is ours**, so it is the only rung that can be
32
+ // modal-and-parented properly — and the only one that has never seen the
33
+ // user's bookmarks.
34
+
35
+ import {
36
+ NoPortalError,
37
+ PORTAL_NAME,
38
+ PortalCancelledError,
39
+ RESPONSE_CANCELLED,
40
+ RESPONSE_OK,
41
+ hasService,
42
+ parentWindowHandle,
43
+ pathBytes,
44
+ portalRequest,
45
+ variant,
46
+ } from './portal.js';
47
+ import { sessionBus } from './bus.js';
48
+ import { windowIdOf } from './windowid.js';
49
+
50
+ const FILE_CHOOSER = 'org.freedesktop.portal.FileChooser';
51
+
52
+ /**
53
+ * Nothing on this machine can show a file dialog, and nothing here can draw
54
+ * one either.
55
+ *
56
+ * Only the bare functions throw this — `useFileDialog()` has a tree to draw
57
+ * in, so it never runs out of rungs. It is a **typed** rejection because it is
58
+ * a fallback signal, not a crash: a caller that has its own UI branches on it.
59
+ */
60
+ export class NoFileDialogError extends Error {
61
+ constructor(cause) {
62
+ super(
63
+ 'react-x11: no file dialog is available — there is no ' +
64
+ 'xdg-desktop-portal on the bus, and this is not macOS. Use ' +
65
+ 'useFileDialog() instead, which draws one, or supply `backend`.',
66
+ { cause },
67
+ );
68
+ this.name = 'NoFileDialogError';
69
+ }
70
+ }
71
+
72
+ export { PortalCancelledError };
73
+
74
+ // --------------------------------------------------------------------------
75
+ // Options, in one shape, translated per backend
76
+ // --------------------------------------------------------------------------
77
+
78
+ /**
79
+ * `[{ name: 'Images', extensions: ['png', 'jpg'] }]` → the portal's
80
+ * `a(sa(us))`: a list of (label, [(type, pattern)]) where type 0 is a glob and
81
+ * 1 is a MIME type.
82
+ *
83
+ * Extensions are turned into globs here rather than asking callers for
84
+ * `*.png`, because every other file-dialog API in the world takes extensions
85
+ * and the glob is a portal implementation detail.
86
+ */
87
+ function portalFilters(filters) {
88
+ return filters.map((f) => [
89
+ f.name ?? '',
90
+ [
91
+ ...(f.extensions ?? []).map((ext) => [
92
+ 0,
93
+ `*.${String(ext).replace(/^[.*]*\.?/, '')}`,
94
+ ]),
95
+ ...(f.mimeTypes ?? []).map((type) => [1, type]),
96
+ ],
97
+ ]);
98
+ }
99
+
100
+ async function portalOptions(opts, { save, directory }) {
101
+ const options = {};
102
+ if (opts.acceptLabel) options.accept_label = opts.acceptLabel;
103
+ if (opts.multiple && !save) options.multiple = true;
104
+ if (directory) options.directory = true;
105
+ if (opts.modal !== false) options.modal = true;
106
+ if (opts.filters?.length) {
107
+ options.filters = await variant('a(sa(us))', portalFilters(opts.filters));
108
+ }
109
+ // Paths are NUL-terminated byte arrays here, not strings. A string marshals
110
+ // as `s` and the backend ignores it without saying so.
111
+ if (opts.defaultFolder)
112
+ options.current_folder = pathBytes(opts.defaultFolder);
113
+ if (save && opts.defaultName) options.current_name = opts.defaultName;
114
+ if (save && opts.defaultPath)
115
+ options.current_file = pathBytes(opts.defaultPath);
116
+ return options;
117
+ }
118
+
119
+ /** `file:///a/b%20c` → `/a/b c`. Anything not a file: URI is dropped. */
120
+ async function urisToPaths(uris) {
121
+ const { fileURLToPath } = await import('node:url');
122
+ const out = [];
123
+ for (const uri of uris ?? []) {
124
+ if (typeof uri !== 'string') continue;
125
+ try {
126
+ if (uri.startsWith('file://')) out.push(fileURLToPath(uri));
127
+ else if (uri.startsWith('/')) out.push(uri);
128
+ } catch {
129
+ // a URI we cannot turn into a path is not one the caller can open
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+
135
+ // --------------------------------------------------------------------------
136
+ // Rung 1: the portal
137
+ // --------------------------------------------------------------------------
138
+
139
+ async function portalDialog(kind, opts, ref) {
140
+ const member = kind === 'save' ? 'SaveFile' : 'OpenFile';
141
+ const { response, results } = await portalRequest(ref, {
142
+ iface: FILE_CHOOSER,
143
+ member,
144
+ parentWindow: parentWindowHandle(windowIdOf(opts.parentWindow)),
145
+ title: opts.title ?? defaultTitle(kind),
146
+ options: await portalOptions(opts, {
147
+ save: kind === 'save',
148
+ directory: kind === 'folder',
149
+ }),
150
+ signal: opts.signal,
151
+ });
152
+ if (response !== RESPONSE_OK) throw new PortalCancelledError(response);
153
+ return urisToPaths(results?.uris);
154
+ }
155
+
156
+ // --------------------------------------------------------------------------
157
+ // Rung 2: osascript, on macOS
158
+ // --------------------------------------------------------------------------
159
+
160
+ /** AppleScript string literal. */
161
+ const as = (s) => `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
162
+
163
+ /**
164
+ * The AppleScript for one dialog, as `-e` lines.
165
+ *
166
+ * Every branch normalises to newline-separated POSIX paths, so the caller
167
+ * parses one shape. `choose file` hands back an alias and `choose file name` a
168
+ * file reference that need not exist yet; `POSIX path of` works on both.
169
+ */
170
+ export function osascriptLines(kind, opts = {}) {
171
+ const title = opts.title ?? defaultTitle(kind);
172
+ const parts = [];
173
+
174
+ if (kind === 'save') {
175
+ parts.push(`set theFiles to choose file name with prompt ${as(title)}`);
176
+ if (opts.defaultName) parts.push(`default name ${as(opts.defaultName)}`);
177
+ if (opts.defaultFolder) {
178
+ parts.push(`default location POSIX file ${as(opts.defaultFolder)}`);
179
+ }
180
+ } else if (kind === 'folder') {
181
+ parts.push(`set theFiles to choose folder with prompt ${as(title)}`);
182
+ if (opts.defaultFolder) {
183
+ parts.push(`default location POSIX file ${as(opts.defaultFolder)}`);
184
+ }
185
+ // `with …` flags go last: AppleScript is tolerant about the order of
186
+ // labelled parameters and conventional about this one, and the failure
187
+ // mode of getting it wrong is a syntax error at the far end of a pipe
188
+ // where nobody is looking.
189
+ if (opts.multiple) parts.push('with multiple selections allowed');
190
+ } else {
191
+ parts.push(`set theFiles to choose file with prompt ${as(title)}`);
192
+ // Extensions map exactly; MIME types do not, and guessing a UTI wrong
193
+ // hides the user's file with no way to get at it. So MIME-only filters
194
+ // mean no type restriction here rather than the wrong one — noted in
195
+ // docs/filedialog.md as the one place the backends genuinely differ.
196
+ const extensions = (opts.filters ?? [])
197
+ .flatMap((f) => f.extensions ?? [])
198
+ .map((e) => String(e).replace(/^[.*]*\.?/, ''))
199
+ .filter(Boolean);
200
+ if (extensions.length) {
201
+ parts.push(`of type {${[...new Set(extensions)].map(as).join(', ')}}`);
202
+ }
203
+ if (opts.defaultFolder) {
204
+ parts.push(`default location POSIX file ${as(opts.defaultFolder)}`);
205
+ }
206
+ if (opts.multiple) parts.push('with multiple selections allowed');
207
+ }
208
+
209
+ return [
210
+ parts.join(' '),
211
+ 'if class of theFiles is not list then set theFiles to {theFiles}',
212
+ 'set out to ""',
213
+ 'repeat with f in theFiles',
214
+ 'set out to out & POSIX path of f & linefeed',
215
+ 'end repeat',
216
+ 'return out',
217
+ ].flatMap((line) => ['-e', line]);
218
+ }
219
+
220
+ async function osascriptDialog(kind, opts) {
221
+ const { execFile } = await import('node:child_process');
222
+ const args = osascriptLines(kind, opts);
223
+ return new Promise((resolve, reject) => {
224
+ const child = execFile(
225
+ 'osascript',
226
+ args,
227
+ { encoding: 'utf8' },
228
+ (error, stdout, stderr) => {
229
+ if (error) {
230
+ if (error.code === 'ENOENT') {
231
+ return reject(new NoFileDialogError(error));
232
+ }
233
+ // AppleScript reports a cancel as -128, which is an ordinary
234
+ // outcome and must not read as a failure.
235
+ if (/-128/.test(stderr) || /User canceled/i.test(stderr)) {
236
+ return reject(new PortalCancelledError(RESPONSE_CANCELLED));
237
+ }
238
+ return reject(
239
+ new Error(
240
+ `react-x11: the macOS file dialog failed — ${stderr.trim() || error.message}`,
241
+ { cause: error },
242
+ ),
243
+ );
244
+ }
245
+ resolve(
246
+ stdout
247
+ .split('\n')
248
+ .map((line) => line.trim())
249
+ .filter(Boolean),
250
+ );
251
+ },
252
+ );
253
+ // An abort has to reach the panel; killing osascript closes it.
254
+ opts.signal?.addEventListener('abort', () => child.kill(), { once: true });
255
+ });
256
+ }
257
+
258
+ // --------------------------------------------------------------------------
259
+ // Choosing a rung
260
+ // --------------------------------------------------------------------------
261
+
262
+ function defaultTitle(kind) {
263
+ return kind === 'save'
264
+ ? 'Save file'
265
+ : kind === 'folder'
266
+ ? 'Select folder'
267
+ : 'Open file';
268
+ }
269
+
270
+ /**
271
+ * Which rung this machine lands on, without showing anything.
272
+ *
273
+ * Useful for a menu that wants to say "Open…" versus "Open (built-in)…", and
274
+ * for the tests. It acquires a bus ref and releases it, so it is cheap to call
275
+ * but not free — cache it if it is on a render path.
276
+ *
277
+ * @returns {Promise<'portal'|'osascript'|'builtin'>}
278
+ */
279
+ export async function fileDialogBackend() {
280
+ const ref = await sessionBus();
281
+ if (ref) {
282
+ try {
283
+ if (await hasService(PORTAL_NAME, ref)) return 'portal';
284
+ } finally {
285
+ await ref.release();
286
+ }
287
+ }
288
+ if (process.platform === 'darwin') return 'osascript';
289
+ return 'builtin';
290
+ }
291
+
292
+ /**
293
+ * Run one dialog on the best rung that is not the built-in one.
294
+ *
295
+ * Split out so `useFileDialog()` can try exactly this and fall through to its
296
+ * own dialog, without duplicating the ladder or the option translation.
297
+ */
298
+ export async function runNativeDialog(kind, opts = {}) {
299
+ if (opts.backend === 'builtin') throw new NoFileDialogError();
300
+
301
+ const wantPortal = !opts.backend || opts.backend === 'portal';
302
+ if (wantPortal) {
303
+ const ref = await sessionBus();
304
+ if (ref) {
305
+ try {
306
+ if (await hasService(PORTAL_NAME, ref)) {
307
+ return await portalDialog(kind, opts, ref);
308
+ }
309
+ } finally {
310
+ await ref.release();
311
+ }
312
+ }
313
+ if (opts.backend === 'portal') {
314
+ throw new NoPortalError('no xdg-desktop-portal on the session bus');
315
+ }
316
+ }
317
+
318
+ const wantOsascript = !opts.backend || opts.backend === 'osascript';
319
+ if (wantOsascript && (process.platform === 'darwin' || opts.backend)) {
320
+ return await osascriptDialog(kind, opts);
321
+ }
322
+
323
+ throw new NoFileDialogError();
324
+ }
325
+
326
+ /**
327
+ * Ask for one or more existing files.
328
+ *
329
+ * ```js
330
+ * const files = await openFile({ multiple: true, filters: [
331
+ * { name: 'Images', extensions: ['png', 'jpg'] },
332
+ * ]});
333
+ * if (!files) return; // cancelled
334
+ * ```
335
+ *
336
+ * Resolves to **absolute paths**, or `null` when the user cancelled —
337
+ * cancelling is an ordinary outcome and should not need a `try`. Rejects with
338
+ * {@link NoFileDialogError} when there is no portal and no `osascript`, which
339
+ * is the signal to draw your own; {@link useFileDialog} does that for you.
340
+ *
341
+ * @returns {Promise<string[] | null>}
342
+ */
343
+ export function openFile(options) {
344
+ return cancellable('open', options);
345
+ }
346
+
347
+ /**
348
+ * Ask where to write a file. Resolves to one **absolute path** — which need
349
+ * not exist yet — or `null` when the user cancelled.
350
+ *
351
+ * @returns {Promise<string | null>}
352
+ */
353
+ export async function saveFile(options) {
354
+ const paths = await cancellable('save', options);
355
+ return paths ? (paths[0] ?? null) : null;
356
+ }
357
+
358
+ /**
359
+ * Ask for a directory. Resolves to absolute paths, or `null` when cancelled.
360
+ *
361
+ * @returns {Promise<string[] | null>}
362
+ */
363
+ export function selectFolder(options) {
364
+ return cancellable('folder', options);
365
+ }
366
+
367
+ /** Cancellation is an answer, not an error. Everything else propagates. */
368
+ async function cancellable(kind, options = {}) {
369
+ try {
370
+ return await runNativeDialog(kind, options);
371
+ } catch (err) {
372
+ if (err instanceof PortalCancelledError) return null;
373
+ throw err;
374
+ }
375
+ }
@@ -0,0 +1,132 @@
1
+ // `useFileDialog()` — the file dialog with the bottom rung attached.
2
+ //
3
+ // The bare `openFile()` in `filedialog.js` can reach the portal and
4
+ // `osascript`, and rejects with `NoFileDialogError` when there is neither,
5
+ // because a function has nowhere to draw. A component does: this hook mounts
6
+ // the built-in browser on the connection the tree is already using, so the
7
+ // ladder never runs out and an app never needs a `catch` that renders a
8
+ // second dialog of its own.
9
+ //
10
+ // ## Why a second root rather than an element to render
11
+ //
12
+ // The obvious alternative is `const { open, dialog } = useFileDialog()` with
13
+ // `{dialog}` rendered somewhere — which is a documentation step for every app
14
+ // author, forever, and one that fails silently when forgotten: `open()` would
15
+ // resolve to nothing with no clue why.
16
+ //
17
+ // A file dialog is a **top-level window** on every other rung, so it is one
18
+ // here too: `createRoot({ app })` borrows the tree's own X connection (it
19
+ // never closes a borrowed one) and renders a real, window-manager-placed,
20
+ // `transientFor` dialog. Nothing to render, nothing to forget, and the same
21
+ // window on all three rungs.
22
+
23
+ import { useCallback, useMemo } from 'react';
24
+ import React from 'react';
25
+
26
+ import { FileDialog } from './components/FileDialog.js';
27
+ import { useTheme } from './components/theme.js';
28
+ import { NoFileDialogError, runNativeDialog } from './filedialog.js';
29
+ import { PortalCancelledError } from './portal.js';
30
+ import { useApp } from './appcontext.js';
31
+ import { createRoot } from './Reconciler.js';
32
+ import { useTopLevelWindow, windowIdOf } from './windowid.js';
33
+
34
+ /**
35
+ * Show the built-in dialog on its own root, and resolve with what the user
36
+ * chose. The root is torn down before the promise settles, so a caller that
37
+ * opens a second dialog in the `.then()` never has two on screen.
38
+ */
39
+ async function showBuiltin(app, theme, props) {
40
+ const root = await createRoot({ app });
41
+ try {
42
+ return await new Promise((resolve) => {
43
+ // `<FileDialog>` renders a `<window>`, and a window may only be a root
44
+ // child or nested in another window — so the palette travels as a prop
45
+ // and the dialog installs the provider *inside* its own window. Wrapping
46
+ // it here in `<ThemeProvider>` would put a `<box>` between the root and
47
+ // the window, which throws.
48
+ root.render(
49
+ React.createElement(FileDialog, { ...props, theme, onDone: resolve }),
50
+ );
51
+ });
52
+ } finally {
53
+ await root.unmount();
54
+ }
55
+ }
56
+
57
+ /**
58
+ * File dialogs, on the best rung this machine has.
59
+ *
60
+ * ```jsx
61
+ * const { openFile, saveFile } = useFileDialog();
62
+ *
63
+ * const open = async () => {
64
+ * const files = await openFile({ filters: [{ name: 'Text', extensions: ['txt', 'md'] }] });
65
+ * if (files) load(files[0]); // null means the user cancelled
66
+ * };
67
+ *
68
+ * <MenuBar menus={[{ label: 'File', items: [{ label: 'Open…', onSelect: open }] }]} />
69
+ * ```
70
+ *
71
+ * **No arguments needed.** The dialog is parented to the window this
72
+ * component is in — `transientFor` for the built-in one, `parent_window` for
73
+ * the portal — worked out at the moment it opens rather than asked for.
74
+ * `parentWindow` remains as the override for a tree with several top-level
75
+ * windows, where inference has to guess and says so.
76
+ *
77
+ * Every call resolves to `null` when the user cancels. That is an ordinary
78
+ * outcome, not an exception, on all three rungs.
79
+ */
80
+ export function useFileDialog(defaults = {}) {
81
+ const app = useApp();
82
+ const theme = useTheme();
83
+ // The window this component is in, resolved when a dialog actually opens —
84
+ // by which time it is mounted and has an XID. So a parented dialog costs
85
+ // the app nothing, and `parentWindow` is left as the override for a tree
86
+ // with several top-level windows. See `useTopLevelWindow`.
87
+ const owner = useTopLevelWindow();
88
+
89
+ const run = useCallback(
90
+ async (kind, options = {}) => {
91
+ const opts = { parentWindow: owner, ...defaults, ...options };
92
+ try {
93
+ return await runNativeDialog(kind, opts);
94
+ } catch (err) {
95
+ if (err instanceof PortalCancelledError) return null;
96
+ if (!(err instanceof NoFileDialogError)) throw err;
97
+ // The bottom rung. Reached over ssh, under startx, in a container —
98
+ // and on any machine whose portal went away between calls.
99
+ return showBuiltin(app, theme, {
100
+ kind,
101
+ title: opts.title,
102
+ startFolder: opts.defaultFolder,
103
+ defaultName: opts.defaultName,
104
+ filters: opts.filters ?? [],
105
+ multiple: !!opts.multiple && kind !== 'save',
106
+ acceptLabel: opts.acceptLabel,
107
+ parentWindow: windowIdOf(opts.parentWindow) ?? undefined,
108
+ });
109
+ }
110
+ },
111
+ // `defaults` is deliberately not a dependency: an app writes it inline, so
112
+ // a new object every render would rebuild these callbacks every render and
113
+ // defeat the memo below. It is read through the closure at call time,
114
+ // which is when its values are actually wanted.
115
+ [app, theme, owner],
116
+ );
117
+
118
+ return useMemo(
119
+ () => ({
120
+ /** @returns {Promise<string[] | null>} absolute paths, or null if cancelled */
121
+ openFile: (options) => run('open', options),
122
+ /** @returns {Promise<string | null>} one absolute path, or null if cancelled */
123
+ saveFile: async (options) => {
124
+ const paths = await run('save', options);
125
+ return paths ? (paths[0] ?? null) : null;
126
+ },
127
+ /** @returns {Promise<string[] | null>} absolute paths, or null if cancelled */
128
+ selectFolder: (options) => run('folder', options),
129
+ }),
130
+ [run],
131
+ );
132
+ }
@@ -0,0 +1,64 @@
1
+ // `useFont()` — a font file, from a component.
2
+ //
3
+ // The imperative half lives in `fonts.js` and is where the reasoning is. The
4
+ // hook is thin on purpose: `loadFont` is already idempotent per app and
5
+ // file, so the memo here saves a map lookup rather than a file read, and a
6
+ // component that re-renders sixty times a second still parses nothing.
7
+ //
8
+ // It reads `useApp()` rather than taking the connection, since a component
9
+ // three levels down has one and does not have the app.
10
+
11
+ import { useMemo } from 'react';
12
+
13
+ import { useApp } from './appcontext.js';
14
+ import { loadFont } from './fonts.js';
15
+
16
+ /**
17
+ * The font in `source`, registered and ready to draw with.
18
+ *
19
+ * ```jsx
20
+ * const { family } = useFont(fontPath);
21
+ *
22
+ * <text style={{ fontFamily: family, fontSize: 32 }}>Handgloves</text>;
23
+ * ```
24
+ *
25
+ * `font` is ntk's `Font` — `metrics(size)`, `variationAxes`, `hasGlyph(cp)` —
26
+ * and `family` is the name to put in `fontFamily`, read off the file rather
27
+ * than invented here. See {@link loadFont} for what that name is and when it
28
+ * is scoped.
29
+ *
30
+ * **Returns null when `source` is null**, so a picker can call it before
31
+ * anything is picked without a branch around the hook:
32
+ *
33
+ * ```jsx
34
+ * const picked = useFont(path); // path may be null
35
+ * <text style={{ fontFamily: picked?.family }}>{sample}</text>;
36
+ * ```
37
+ *
38
+ * The file is read on the render that first names it, and never again: the
39
+ * face is cached in the connection's font manager, which is also where a
40
+ * `openFont`/`loadFont` call outside the tree would find it.
41
+ *
42
+ * **A file that cannot be read throws during render**, which is an error
43
+ * boundary's business — right for a font the app ships and wrong for one a
44
+ * user just picked in a dialog, where the app wants to say so in its own UI.
45
+ * Call `loadFont(app, path)` in the handler that picked it for that case, and
46
+ * catch there.
47
+ *
48
+ * @param {string|Uint8Array|Buffer|null} [source] path, or the file's bytes
49
+ * @param {{ family?: string, weight?: number|string, style?: string,
50
+ * postscriptName?: string }} [opts]
51
+ */
52
+ export function useFont(source, opts) {
53
+ const app = useApp();
54
+ // The options are folded into the memo key rather than listed as
55
+ // dependencies: an inline `{ family: 'preview' }` is a fresh object every
56
+ // render and would defeat the memo on identity alone.
57
+ const key = `${opts?.family ?? ''}|${opts?.weight ?? ''}|${opts?.style ?? ''}|${
58
+ opts?.postscriptName ?? ''
59
+ }`;
60
+ return useMemo(
61
+ () => (source == null ? null : loadFont(app, source, opts)),
62
+ [app, source, key],
63
+ );
64
+ }