react-x11 2.6.1 → 2.8.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 (59) hide show
  1. package/README.md +5 -3
  2. package/package.json +10 -3
  3. package/src/activate.js +12 -0
  4. package/src/anchor.js +6 -0
  5. package/src/appearance.js +351 -28
  6. package/src/appearancehooks.js +5 -2
  7. package/src/application.js +41 -0
  8. package/src/cocoa/app.js +367 -3
  9. package/src/cocoa/bezels.js +51 -1
  10. package/src/cocoa/dnd.js +358 -0
  11. package/src/cocoa/dock.js +39 -0
  12. package/src/cocoa/filepanels.js +155 -0
  13. package/src/cocoa/fonts.js +93 -2
  14. package/src/cocoa/globalmenu.js +41 -33
  15. package/src/cocoa/notifications.js +244 -0
  16. package/src/cocoa/permissions.js +74 -0
  17. package/src/cocoa/presenter.js +274 -33
  18. package/src/cocoa/promotion.js +708 -0
  19. package/src/cocoa/statusitem.js +112 -0
  20. package/src/cocoa/window.js +113 -4
  21. package/src/components/Button.js +20 -1
  22. package/src/components/Checkbox.js +17 -2
  23. package/src/components/Menu.js +108 -38
  24. package/src/components/Radio.js +17 -2
  25. package/src/components/Select.js +159 -27
  26. package/src/components/Switch.js +8 -1
  27. package/src/components/native.js +99 -0
  28. package/src/components/theme.js +37 -20
  29. package/src/desktopsettings.js +34 -2
  30. package/src/dnd.js +137 -11
  31. package/src/errors.js +6 -3
  32. package/src/filedialog.js +81 -16
  33. package/src/index.d.ts +29 -2
  34. package/src/index.js +17 -0
  35. package/src/launcher.js +170 -0
  36. package/src/launcherhooks.js +81 -0
  37. package/src/nodes.js +604 -37
  38. package/src/notificationhooks.js +56 -0
  39. package/src/notifications.js +558 -0
  40. package/src/palette.js +144 -8
  41. package/src/permissionhooks.js +89 -0
  42. package/src/permissions.js +196 -0
  43. package/src/style.d.ts +10 -4
  44. package/src/style.js +1 -0
  45. package/src/styles.js +161 -15
  46. package/src/textselection.js +1 -4
  47. package/src/trayhooks.js +90 -0
  48. package/src/types/appearance.d.ts +24 -0
  49. package/src/types/components.d.ts +10 -0
  50. package/src/types/elements.d.ts +14 -0
  51. package/src/types/events.d.ts +14 -0
  52. package/src/types/filedialog.d.ts +18 -7
  53. package/src/types/launcher.d.ts +43 -0
  54. package/src/types/notifications.d.ts +113 -0
  55. package/src/types/permissions.d.ts +100 -0
  56. package/src/types/style.d.ts +30 -2
  57. package/src/types/system.d.ts +5 -3
  58. package/src/types/tray.d.ts +54 -0
  59. package/src/windowid.js +23 -0
@@ -0,0 +1,358 @@
1
+ // Drag and drop on the cocoa backend — the external transport over
2
+ // @windowkit/appkit (>= 0.5): `NSDraggingDestination` on every window's
3
+ // hosting view, `NSDraggingSource` from it. The `dropAccept` / `onDrag*` /
4
+ // `dragData` prop contract is untouched; what this file owns is the
5
+ // translation between AppKit's vocabulary and src/dnd.js's, in both
6
+ // directions.
7
+ //
8
+ // ## The destination
9
+ //
10
+ // AppKit asks its questions as backend events — `drag-enter`, `drag-over`,
11
+ // `drag-exit`, `drag-perform` — and expects the answer **during the
12
+ // callback**: `setDropResponse` from inside the event is what
13
+ // `draggingEntered:` returns. `DropSession._overAt` is synchronous (the
14
+ // `onDragOver` chance included), so the answer is in hand before the
15
+ // callback ends. The session is driven through its local entry points —
16
+ // the ones an in-process `DragSession` uses — with an offer flagged
17
+ // `external`, so the path diffing, `:drag-over`, `dropAccept` matching and
18
+ // the handler dispatch are the one implementation.
19
+ //
20
+ // The payload is read **during `drag-perform`** — the pasteboard is the
21
+ // source's promise and a source may withdraw it once its session has ended
22
+ // — so every representation on offer is read then, and `getData` answers
23
+ // from that. A Finder drag of three files is three items of one
24
+ // `public.file-url` each; they become one `text/uri-list`.
25
+ //
26
+ // ## The source
27
+ //
28
+ // Once the renderer's own threshold says a press is a drag, `beginDrag`
29
+ // hands the gesture to AppKit, which tracks it everywhere from there: the
30
+ // pointer's `mousemove`/`mouseup` stop arriving, `drag-session-moved` is the
31
+ // motion and `drag-session-ended` the release. A drop on one of our own
32
+ // windows comes back through that window's destination events with
33
+ // `local: true` — and those are routed to the live `DragSession` rather than
34
+ // the pasteboard, so an in-app drop still gets `e.items` by reference and
35
+ // `e.source === 'internal'`, the contract the X11 transport keeps. Other
36
+ // applications read the pasteboard, where a thunk is a promise the bridge
37
+ // asks `provide` to keep.
38
+ //
39
+ // ## Types
40
+ //
41
+ // Pasteboard types are UTIs; react-x11's vocabulary is MIME and X atoms
42
+ // (src/transfer.js). The common ones map by table; anything else goes
43
+ // through the OS's own database (`pasteboardTypeForMIME`, whose `dyn.*`
44
+ // identifier for a MIME type no declared type claims is computed alike by
45
+ // every process — how `application/x-myapp-…` travels between two react-x11
46
+ // apps) and back (`pasteboardTypeInfo`).
47
+ import { runWithPriority, DiscreteEventPriority } from '../priority.js';
48
+ import {
49
+ TEXT_TARGETS,
50
+ TYPE_GROUPS,
51
+ parseUriList,
52
+ resolveType,
53
+ } from '../transfer.js';
54
+
55
+ const UTI_TO_MIME = Object.freeze({
56
+ 'public.file-url': 'text/uri-list',
57
+ 'public.url': 'text/uri-list',
58
+ 'public.utf8-plain-text': 'text/plain;charset=utf-8',
59
+ 'public.plain-text': 'text/plain',
60
+ 'public.html': 'text/html',
61
+ 'public.rtf': 'text/rtf',
62
+ 'public.png': 'image/png',
63
+ 'public.jpeg': 'image/jpeg',
64
+ 'public.tiff': 'image/tiff',
65
+ 'public.svg-image': 'image/svg+xml',
66
+ 'public.json': 'application/json',
67
+ });
68
+
69
+ const MIME_TO_UTI = Object.freeze({
70
+ 'text/uri-list': 'public.file-url',
71
+ 'text/plain;charset=utf-8': 'public.utf8-plain-text',
72
+ 'text/plain': 'public.utf8-plain-text',
73
+ UTF8_STRING: 'public.utf8-plain-text',
74
+ STRING: 'public.utf8-plain-text',
75
+ TEXT: 'public.utf8-plain-text',
76
+ 'text/html': 'public.html',
77
+ 'text/rtf': 'public.rtf',
78
+ 'image/png': 'public.png',
79
+ 'image/jpeg': 'public.jpeg',
80
+ 'image/tiff': 'public.tiff',
81
+ 'image/svg+xml': 'public.svg-image',
82
+ 'application/json': 'public.json',
83
+ });
84
+
85
+ /** The pasteboard types every window takes; concrete `dropAccept` types add
86
+ * to them (`CocoaDropTransport.refreshTypes`). */
87
+ export const BASE_DROP_UTIS = Object.freeze([
88
+ 'public.file-url',
89
+ 'public.url',
90
+ 'public.utf8-plain-text',
91
+ 'public.plain-text',
92
+ 'public.html',
93
+ 'public.rtf',
94
+ 'public.png',
95
+ 'public.jpeg',
96
+ 'public.tiff',
97
+ ]);
98
+
99
+ const isTextual = (uti, mime) =>
100
+ uti.startsWith('public.') && /text/.test(uti) ? true : /^text\//i.test(mime);
101
+
102
+ export function mimeFromUti(uti, native) {
103
+ if (UTI_TO_MIME[uti]) return UTI_TO_MIME[uti];
104
+ if (!uti.startsWith('dyn.') && !uti.includes('.')) return null;
105
+ try {
106
+ const info = native.pasteboardTypeInfo?.(uti);
107
+ return typeof info?.mime === 'string' && info.mime ? info.mime : null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ export function utiFromMime(mime, native) {
114
+ if (MIME_TO_UTI[mime]) return MIME_TO_UTI[mime];
115
+ // an X atom name is not a MIME type; nothing on a pasteboard is called it
116
+ if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+/i.test(mime)) return null;
117
+ try {
118
+ const uti = native.pasteboardTypeForMIME?.(mime.replace(/;.*$/, ''));
119
+ return typeof uti === 'string' && uti ? uti : null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /** What a pasteboard's UTIs are in react-x11's vocabulary, best first. */
126
+ export function offeredTypes(utis, native) {
127
+ const out = [];
128
+ const add = (t) => t && !out.includes(t) && out.push(t);
129
+ for (const uti of utis ?? []) {
130
+ const mime = mimeFromUti(uti, native);
131
+ add(mime);
132
+ // the plain name beside the charset one, so `text/plain` in a
133
+ // `dropAccept` matches what every macOS text drag offers
134
+ if (uti === 'public.utf8-plain-text') add('text/plain');
135
+ }
136
+ return out;
137
+ }
138
+
139
+ /** The action a source's operation mask asks for: the first of the three
140
+ * react-x11 names it allows. */
141
+ export function requestedAction(operations) {
142
+ return (
143
+ ['copy', 'move', 'link'].find((a) => operations?.includes(a)) ?? 'copy'
144
+ );
145
+ }
146
+
147
+ /**
148
+ * Everything on the pasteboard, read now (see the header), as the extras
149
+ * `DropSession.localDrop` hands the handler: `items` by type, the parsed
150
+ * `files`, the best `text`, and a `getData` answering from the same read.
151
+ */
152
+ export function readPayload(native, types) {
153
+ const values = {};
154
+ const urls = [];
155
+ const items = (() => {
156
+ try {
157
+ return native.dragItems?.() ?? [];
158
+ } catch {
159
+ return [];
160
+ }
161
+ })();
162
+ items.forEach((item, index) => {
163
+ for (const uti of item?.types ?? []) {
164
+ if (uti === 'public.file-url' || uti === 'public.url') {
165
+ const url = native.dragItemString(index, uti);
166
+ if (url) urls.push(url);
167
+ continue;
168
+ }
169
+ const mime = mimeFromUti(uti, native);
170
+ if (!mime || values[mime] !== undefined) continue;
171
+ const value = isTextual(uti, mime)
172
+ ? native.dragItemString(index, uti)
173
+ : native.dragItemData(index, uti);
174
+ if (value == null) continue;
175
+ values[mime] = value;
176
+ if (
177
+ uti === 'public.utf8-plain-text' &&
178
+ values['text/plain'] === undefined
179
+ ) {
180
+ values['text/plain'] = value;
181
+ }
182
+ }
183
+ });
184
+ if (urls.length) values['text/uri-list'] = urls.join('\r\n') + '\r\n';
185
+ const best = TEXT_TARGETS.find((t) => values[t] !== undefined);
186
+ return {
187
+ items: values,
188
+ files: values['text/uri-list'] ? parseUriList(values['text/uri-list']) : [],
189
+ text: best ? values[best] : undefined,
190
+ getData: (type) =>
191
+ Promise.resolve(values[resolveType(type, types)] ?? null),
192
+ };
193
+ }
194
+
195
+ /** A value as the pasteboard carries it: strings and bytes as they are,
196
+ * anything else as JSON — the same rule the X transport applies. */
197
+ const wire = (value) =>
198
+ typeof value === 'string' ||
199
+ Buffer.isBuffer(value) ||
200
+ ArrayBuffer.isView(value) ||
201
+ value instanceof ArrayBuffer
202
+ ? value
203
+ : JSON.stringify(value);
204
+
205
+ /**
206
+ * A `DragSession`'s payload as `beginDrag`'s spec: one item per file for a
207
+ * `text/uri-list` (that is how the desktop speaks), every other type on the
208
+ * first item under its UTI, thunks as promises the bridge asks `provide`
209
+ * to keep. The files thunk alone resolves now, because the items cannot
210
+ * be counted without it.
211
+ */
212
+ export function dragSpec(session, native, scale) {
213
+ const types = session.types;
214
+ const reverse = new Map(); // uti -> react-x11 type
215
+ const items = [];
216
+ const filesType = types.find((t) => TYPE_GROUPS.files.includes(t));
217
+ if (filesType) {
218
+ const list = session._resolve(filesType);
219
+ if (typeof list === 'string') {
220
+ for (const { uri } of parseUriList(list)) {
221
+ items.push({ 'public.file-url': uri });
222
+ }
223
+ }
224
+ }
225
+ const first = {};
226
+ for (const type of types) {
227
+ if (type === filesType) continue;
228
+ const uti = utiFromMime(type, native);
229
+ if (!uti || uti in first) continue;
230
+ reverse.set(uti, type);
231
+ const raw = session._data[type];
232
+ first[uti] = typeof raw === 'function' ? null : wire(raw);
233
+ }
234
+ if (Object.keys(first).length) {
235
+ if (items.length) Object.assign(items[0], first);
236
+ else items.push(first);
237
+ }
238
+ if (!items.length) items.push({ 'public.utf8-plain-text': '' });
239
+ return {
240
+ x: session.press.x / scale,
241
+ y: session.press.y / scale,
242
+ items,
243
+ operations: session.actions,
244
+ provide: (uti) => wire(session._resolve(reverse.get(uti) ?? uti)),
245
+ };
246
+ }
247
+
248
+ /**
249
+ * One window's drop side: the registered types, and the four events turned
250
+ * into `DropSession` calls with the answer sent back inside the callback.
251
+ */
252
+ export class CocoaDropTransport {
253
+ constructor(wnd, session, node) {
254
+ this.wnd = wnd;
255
+ this.session = session;
256
+ this.node = node;
257
+ this._registered = null;
258
+ // AppKit tracks the whole drag on this thread: the pump does not return
259
+ // until the drop, so nothing an enter/over dispatch schedules — a
260
+ // `useDropTarget` lighting its "drop here" label, any handler's
261
+ // `setState` — has a frame tick or a microtask to land on. Discrete is
262
+ // the one lane the app can land by hand from inside the callback
263
+ // (src/cocoa/app.js `_afterInput`), which is where the answer to
264
+ // AppKit's question is painted. The renderer's own `:drag-over` needs
265
+ // none of this and never did.
266
+ session.hoverPriority = DiscreteEventPriority;
267
+ this.refreshTypes();
268
+ }
269
+
270
+ /** The base set plus every concrete type a `dropAccept` under this
271
+ * window names, re-registered only when the list changes. */
272
+ refreshTypes() {
273
+ const native = this.wnd._native;
274
+ const types = new Set(BASE_DROP_UTIS);
275
+ for (const mime of this.node._dndConcreteTypes?.() ?? []) {
276
+ const uti = utiFromMime(mime, native);
277
+ if (uti) types.add(uti);
278
+ }
279
+ const list = [...types];
280
+ if (this._registered && list.join('\n') === this._registered.join('\n')) {
281
+ return;
282
+ }
283
+ this._registered = list;
284
+ this.wnd.registerDropTypes(list);
285
+ }
286
+
287
+ handle(ev) {
288
+ switch (ev.type) {
289
+ case 'drag-enter':
290
+ case 'drag-over':
291
+ return this._over(ev);
292
+ case 'drag-exit':
293
+ return this._leave(ev);
294
+ case 'drag-perform':
295
+ return this._perform(ev);
296
+ default:
297
+ return undefined;
298
+ }
299
+ }
300
+
301
+ /** The live drag in this process, when the pasteboard is ours. */
302
+ _local(ev) {
303
+ return ev.local ? (this.wnd.app._activeDrag ?? null) : null;
304
+ }
305
+
306
+ _offer(ev) {
307
+ const drag = this._local(ev);
308
+ if (drag) return drag._offer();
309
+ return {
310
+ types: offeredTypes(ev.types, this.wnd._native),
311
+ action: requestedAction(ev.operations),
312
+ source: 'external',
313
+ };
314
+ }
315
+
316
+ _point(ev) {
317
+ const s = this.wnd.scale;
318
+ return { rootX: Math.round(ev.gx * s), rootY: Math.round(ev.gy * s) };
319
+ }
320
+
321
+ _over(ev) {
322
+ const drag = this._local(ev);
323
+ const offer = this._offer(ev);
324
+ const { rootX, rootY } = this._point(ev);
325
+ const answer = this.session.localOver(rootX, rootY, offer, Date.now());
326
+ if (drag) {
327
+ drag.accepted = answer.accepted;
328
+ if (answer.accepted) drag.currentAction = answer.action;
329
+ }
330
+ const response = { accept: answer.accepted };
331
+ if (answer.accepted && ev.operations?.includes(answer.action)) {
332
+ response.operation = answer.action;
333
+ }
334
+ this.wnd.setDropResponse(response);
335
+ }
336
+
337
+ _leave(ev) {
338
+ const drag = this._local(ev);
339
+ // the leaving half of the same stream, in the same lane as the enter
340
+ runWithPriority(DiscreteEventPriority, () => this.session.localLeave());
341
+ if (drag) drag.accepted = false;
342
+ }
343
+
344
+ _perform(ev) {
345
+ const drag = this._local(ev);
346
+ const offer = this._offer(ev);
347
+ const extras = drag
348
+ ? drag._dropExtras()
349
+ : readPayload(this.wnd._native, offer.types);
350
+ const outcome = this.session.localDrop(offer, extras, Date.now());
351
+ if (drag && outcome.handled) drag.currentAction = outcome.action;
352
+ const response = { accept: outcome.handled };
353
+ if (outcome.handled && ev.operations?.includes(outcome.action)) {
354
+ response.operation = outcome.action;
355
+ }
356
+ this.wnd.setDropResponse(response);
357
+ }
358
+ }
@@ -0,0 +1,39 @@
1
+ // The Dock menu on the cocoa backend — `applicationDockMenu:` through
2
+ // @windowkit/appkit (>= 0.5), driven from the same `items` vocabulary
3
+ // `MenuBar` and the tray take.
4
+ //
5
+ // The same machinery as the menu bar: dbusmenu.js's `snapshot` gives every
6
+ // item a stable id and keeps it across re-renders, and the bridge's menu spec
7
+ // is built by the one builder the menu bar uses (`menuItemsSpec`), so the
8
+ // three menus an app can put on the desktop are one authoring model. An
9
+ // activation comes back as a `menu-activate` event tagged `menu: 'dock'` —
10
+ // the bridge's way of saying which tree the id belongs to, since the two
11
+ // allocate ids independently — and runs the item's own `onSelect`, the way
12
+ // `useGlobalMenu` does.
13
+ import { IdAllocator, snapshot } from '../dbusmenu.js';
14
+ import { menuItemsSpec } from './globalmenu.js';
15
+
16
+ export class CocoaDockMenu {
17
+ constructor(app) {
18
+ this.app = app;
19
+ this.alloc = new IdAllocator();
20
+ this.nodes = null;
21
+ }
22
+
23
+ /** Install `items`, or take the menu down with null. */
24
+ update(items) {
25
+ if (!items) {
26
+ this.nodes = null;
27
+ this.app._native.setDockMenu(null);
28
+ return;
29
+ }
30
+ this.nodes = snapshot(items, this.alloc);
31
+ this.app._native.setDockMenu(menuItemsSpec(this.nodes));
32
+ }
33
+
34
+ /** A `menu-activate` tagged `dock` landed on this menu. */
35
+ activate(id) {
36
+ const item = this.nodes?.get(id)?.item;
37
+ item?.onSelect?.(item);
38
+ }
39
+ }
@@ -0,0 +1,155 @@
1
+ // Native file panels on the cocoa backend — `NSOpenPanel` / `NSSavePanel`
2
+ // through @windowkit/appkit (>= 0.5), the top rung of src/filedialog.js's
3
+ // ladder on this backend.
4
+ //
5
+ // The panel belongs to this process's NSApplication, which is what the
6
+ // `osascript` rung could never be: it runs as a **sheet** on the window that
7
+ // asked, every filter the OS type database knows gets through (a MIME type
8
+ // as much as an extension), and a cancel is a cancel rather than a failed
9
+ // subprocess. `CocoaApp.filePanels` is this object, and its *presence* is
10
+ // the capability the ladder tests for — the same rule `nativeBezels` and
11
+ // `raiseWindow` follow, so the ladder itself never names a backend.
12
+ //
13
+ // Two things about the bridge's contract shape the code here:
14
+ //
15
+ // - **With a window handle the panel is a sheet** and the call returns at
16
+ // once; the callback lands through the pump on a later tick, like a menu
17
+ // activation. **Without one it is app-modal** — `runModal` parks the
18
+ // thread inside AppKit's loop until the panel is dismissed, and the
19
+ // callback runs before the call returns. Timers do not tick meanwhile.
20
+ // That is the fallback, not the design: `useFileDialog()` always names
21
+ // its window, and the bare functions want `parentWindow` for this reason.
22
+ // - **A destroyed owner ends its sheet with a cancel**, natively — the
23
+ // bridge answers any panel still attached to a window `destroyWindow2`
24
+ // takes down — so a promise here is never left pending by an unmount.
25
+ import path from 'node:path';
26
+
27
+ import { PortalCancelledError, RESPONSE_CANCELLED } from '../portal.js';
28
+
29
+ /** Extensions arrive with or without a dot, and sometimes as a glob. */
30
+ const bareExtension = (ext) => String(ext).replace(/^[.*]*\.?/, '');
31
+
32
+ /**
33
+ * react-x11's dialog options as the bridge's panel spec.
34
+ *
35
+ * `contentTypeFor({ extension } | { mime })` is the bridge's lookup in the
36
+ * OS's own type database — `'png'` → `'public.png'`, `'application/json'` →
37
+ * `'public.json'`, an undeclared extension a dynamic type that matches
38
+ * exactly it — injected so the translation is a pure function the tests can
39
+ * pin. A lookup that answers nothing drops that entry; a filter list the OS
40
+ * recognises nothing of means no filter, which is also what an absent one
41
+ * means.
42
+ *
43
+ * Only a title the caller gave becomes the panel's `message` (the line
44
+ * above the file list): the panel already says Open or Save on its own,
45
+ * and the ladder's default title would be a second copy of it.
46
+ */
47
+ export function panelSpec(kind, opts = {}, contentTypeFor = () => null) {
48
+ const spec = {};
49
+ if (opts.title) {
50
+ spec.title = opts.title;
51
+ spec.message = opts.title;
52
+ }
53
+ if (opts.acceptLabel) spec.prompt = opts.acceptLabel;
54
+ if (opts.defaultFolder) spec.directoryURL = opts.defaultFolder;
55
+
56
+ if (kind === 'save') {
57
+ spec.canCreateDirectories = true;
58
+ if (opts.defaultPath) {
59
+ // `defaultPath` names a file that need not exist yet: the panel opens
60
+ // in its directory with its name filled in, which is the portal's
61
+ // `current_file` by other means
62
+ spec.directoryURL = path.dirname(opts.defaultPath);
63
+ spec.nameFieldStringValue = path.basename(opts.defaultPath);
64
+ } else if (opts.defaultName) {
65
+ spec.nameFieldStringValue = opts.defaultName;
66
+ }
67
+ } else {
68
+ spec.multiple = Boolean(opts.multiple);
69
+ if (kind === 'folder') {
70
+ spec.directory = true;
71
+ spec.canCreateDirectories = true;
72
+ }
73
+ }
74
+
75
+ if (kind !== 'folder') {
76
+ const types = [];
77
+ for (const filter of opts.filters ?? []) {
78
+ for (const ext of filter.extensions ?? []) {
79
+ const extension = bareExtension(ext);
80
+ if (extension) types.push(contentTypeFor({ extension }));
81
+ }
82
+ for (const mime of filter.mimeTypes ?? []) {
83
+ types.push(contentTypeFor({ mime: String(mime) }));
84
+ }
85
+ }
86
+ const known = [...new Set(types.filter((t) => typeof t === 'string' && t))];
87
+ if (known.length) spec.allowedContentTypes = known;
88
+ }
89
+ return spec;
90
+ }
91
+
92
+ export class CocoaFilePanels {
93
+ constructor(app) {
94
+ this.app = app;
95
+ }
96
+
97
+ /**
98
+ * Show one panel and resolve with what the user chose, in the ladder's own
99
+ * shape: absolute paths (a save answers one, still in a list), a
100
+ * `PortalCancelledError` for a cancel (which `openFile` and the hook turn
101
+ * into `null`), and an abort's own reason when the caller's `signal` fired
102
+ * — the same three outcomes the portal rung has, so nothing above this
103
+ * can tell the rungs apart.
104
+ *
105
+ * `wnd` is the `CocoaWindow` the panel is a sheet on, or null for
106
+ * app-modal (see the header).
107
+ */
108
+ show(kind, opts = {}, wnd = null) {
109
+ const native = this.app._native;
110
+ const signal = opts.signal;
111
+ if (signal?.aborted) {
112
+ return Promise.reject(
113
+ signal.reason ?? new PortalCancelledError(RESPONSE_CANCELLED),
114
+ );
115
+ }
116
+ const spec = panelSpec(kind, opts, (query) => native.contentTypeFor(query));
117
+ if (wnd && !wnd.destroyed && wnd._h != null) spec.window = wnd._h;
118
+
119
+ return new Promise((resolve, reject) => {
120
+ let handle = null;
121
+ let settled = false;
122
+ const onAbort = () => {
123
+ if (settled) return;
124
+ settled = true;
125
+ // dismissing the sheet makes the bridge answer the callback with
126
+ // null, which `done` then ignores — the abort is the outcome
127
+ if (handle != null) native.cancelPanel(handle);
128
+ reject(signal.reason ?? new PortalCancelledError(RESPONSE_CANCELLED));
129
+ };
130
+ const done = (result) => {
131
+ signal?.removeEventListener('abort', onAbort);
132
+ if (settled) return;
133
+ settled = true;
134
+ if (result == null) {
135
+ reject(new PortalCancelledError(RESPONSE_CANCELLED));
136
+ return;
137
+ }
138
+ resolve(Array.isArray(result) ? result : [result]);
139
+ };
140
+ signal?.addEventListener('abort', onAbort, { once: true });
141
+ try {
142
+ // app-modal: this call blocks and `done` has already run by the
143
+ // time it returns; a sheet: it returns at once with the handle
144
+ handle = native[kind === 'save' ? 'savePanel' : 'openPanel'](
145
+ spec,
146
+ done,
147
+ );
148
+ } catch (err) {
149
+ signal?.removeEventListener('abort', onAbort);
150
+ settled = true;
151
+ reject(err);
152
+ }
153
+ });
154
+ }
155
+ }
@@ -25,6 +25,7 @@
25
25
  import { readFileSync } from 'node:fs';
26
26
  import { inflateSync } from 'node:zlib';
27
27
 
28
+ import LineBreaker from 'linebreak';
28
29
  import { cssColorStraight, Font } from 'ntk';
29
30
 
30
31
  import { loadNative } from './native.js';
@@ -144,6 +145,78 @@ class CocoaTextLayout {
144
145
  }
145
146
  }
146
147
 
148
+ /**
149
+ * What a break at the end of a run consumes: the trailing whitespace ntk's
150
+ * tokenizer strips — a line's width never counts it, on either engine — and
151
+ * a hard line break, which is an opportunity the text already spells out and
152
+ * must not become a second one (an empty line in the measurement).
153
+ */
154
+ const RUN_TAIL = /[ \t\u00a0]*(?:\r\n|[\n\r\u2028\u2029])?[ \t\u00a0]*$/;
155
+
156
+ /**
157
+ * The same spans with a hard break at every UAX#14 line-break opportunity,
158
+ * and the whitespace each break consumes dropped: one unbreakable run per
159
+ * line. Laid out with no width bound, the widest line is then the longest
160
+ * word — which is the answer to "how narrow can you be?", CSS's min-content.
161
+ *
162
+ * **Why the text is broken here rather than by asking CoreText for a narrow
163
+ * line.** CoreText's line breaker must make progress whatever width it is
164
+ * given, so a paragraph offered zero (or a pixel) comes back broken *inside*
165
+ * its words: the caption in examples/animation.jsx measures 61 lines and 13px
166
+ * wide, a floor of one character. A floor under the longest word is worse
167
+ * than no floor at all — it lets a flex column shrink to where the text has
168
+ * nowhere left to wrap. Breaking the text here asks CoreText only to shape
169
+ * and measure, which is the half of it that has no opinion.
170
+ *
171
+ * The opportunities come from the `linebreak` package **ntk breaks its own
172
+ * lines with**, so the two engines agree on where a line may break and a
173
+ * tree measures the same on both backends.
174
+ */
175
+ function brokenAtEveryOpportunity(spans) {
176
+ const text = spans.map((span) => span.text).join('');
177
+ if (!text) return spans;
178
+ // Each run is [start, end) with its trailing whitespace trimmed off, so a
179
+ // line is as wide as its ink — the same measurement ntk reports, which
180
+ // strips the trailing run too.
181
+ const runs = [];
182
+ const breaker = new LineBreaker(text);
183
+ let start = 0;
184
+ for (let bk = breaker.nextBreak(); bk; bk = breaker.nextBreak()) {
185
+ const run = text.slice(start, bk.position);
186
+ runs.push([start, bk.position - (RUN_TAIL.exec(run)?.[0].length ?? 0)]);
187
+ start = bk.position;
188
+ }
189
+ if (start < text.length) runs.push([start, text.length]);
190
+ if (runs.length < 2) return spans; // one run: nothing may break anyway
191
+
192
+ // Where each span sits in that text, so a run can be cut out of the spans
193
+ // it crosses — a break between two `<text>` children of different sizes is
194
+ // an ordinary opportunity, and the two sides keep their own faces.
195
+ const placed = [];
196
+ let at = 0;
197
+ for (const span of spans) {
198
+ placed.push({ span, start: at, end: (at += span.text.length) });
199
+ }
200
+ const out = [];
201
+ let previous = spans[0];
202
+ runs.forEach(([from, to], i) => {
203
+ // The separator carries the attributes of the span before it: the native
204
+ // needs a font on every span, and a newline draws nothing.
205
+ if (i > 0) out.push({ ...previous, text: '\n' });
206
+ for (const p of placed) {
207
+ const a = Math.max(from, p.start);
208
+ const b = Math.min(to, p.end);
209
+ if (b <= a) continue;
210
+ previous = p.span;
211
+ out.push({
212
+ ...p.span,
213
+ text: p.span.text.slice(a - p.start, b - p.start),
214
+ });
215
+ }
216
+ });
217
+ return out;
218
+ }
219
+
147
220
  const ALIGN_FLUSH = { left: 0, center: 0.5, right: 1 };
148
221
 
149
222
  function flushFor(align, direction) {
@@ -809,8 +882,19 @@ export class CocoaFontManager {
809
882
  ...(color == null ? {} : { color: parseColor(color) }),
810
883
  });
811
884
  }
885
+ // A width offer of zero is a question, not a degenerate layout: yoga
886
+ // asks it to find the node's min-content floor (`minWidth: 'auto'`,
887
+ // nodes.js). It used to fall into the `undefined` below and answer
888
+ // max-content — the whole paragraph on one line — so a `<text>` in a
889
+ // flex item held its container open at its longest line and two equal
890
+ // columns came out 823px and 34px wide. `brokenAtEveryOpportunity` is
891
+ // the answer instead.
892
+ const minContent = Number.isFinite(maxWidth) && maxWidth <= 0;
893
+ const laid = minContent
894
+ ? brokenAtEveryOpportunity(nativeSpans)
895
+ : nativeSpans;
812
896
  const raw = this._native.createLayout({
813
- spans: nativeSpans,
897
+ spans: laid,
814
898
  maxWidth:
815
899
  Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : undefined,
816
900
  align: flushFor(align, direction),
@@ -819,7 +903,14 @@ export class CocoaFontManager {
819
903
  ellipsis: overflow === 'ellipsis',
820
904
  rtl: direction === 'rtl',
821
905
  });
822
- const layout = new CocoaTextLayout(this._native, raw, text);
906
+ // the text the native actually laid out: a min-content measurement's
907
+ // line and run ranges are indices into the broken text, and the two have
908
+ // to agree for `indexAt`/`caretPosition` to mean anything at all
909
+ const layout = new CocoaTextLayout(
910
+ this._native,
911
+ raw,
912
+ minContent ? laid.map((span) => span.text).join('') : text,
913
+ );
823
914
  layout._contextInk = contextInk;
824
915
  this._layouts.set(signature, layout);
825
916
  if (this._layouts.size > 64) {