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.
- package/README.md +5 -3
- package/package.json +10 -3
- package/src/activate.js +12 -0
- package/src/anchor.js +6 -0
- package/src/appearance.js +351 -28
- package/src/appearancehooks.js +5 -2
- package/src/application.js +41 -0
- package/src/cocoa/app.js +367 -3
- package/src/cocoa/bezels.js +51 -1
- package/src/cocoa/dnd.js +358 -0
- package/src/cocoa/dock.js +39 -0
- package/src/cocoa/filepanels.js +155 -0
- package/src/cocoa/fonts.js +93 -2
- package/src/cocoa/globalmenu.js +41 -33
- package/src/cocoa/notifications.js +244 -0
- package/src/cocoa/permissions.js +74 -0
- package/src/cocoa/presenter.js +274 -33
- package/src/cocoa/promotion.js +708 -0
- package/src/cocoa/statusitem.js +112 -0
- package/src/cocoa/window.js +113 -4
- package/src/components/Button.js +20 -1
- package/src/components/Checkbox.js +17 -2
- package/src/components/Menu.js +108 -38
- package/src/components/Radio.js +17 -2
- package/src/components/Select.js +159 -27
- package/src/components/Switch.js +8 -1
- package/src/components/native.js +99 -0
- package/src/components/theme.js +37 -20
- package/src/desktopsettings.js +34 -2
- package/src/dnd.js +137 -11
- package/src/errors.js +6 -3
- package/src/filedialog.js +81 -16
- package/src/index.d.ts +29 -2
- package/src/index.js +17 -0
- package/src/launcher.js +170 -0
- package/src/launcherhooks.js +81 -0
- package/src/nodes.js +604 -37
- package/src/notificationhooks.js +56 -0
- package/src/notifications.js +558 -0
- package/src/palette.js +144 -8
- package/src/permissionhooks.js +89 -0
- package/src/permissions.js +196 -0
- package/src/style.d.ts +10 -4
- package/src/style.js +1 -0
- package/src/styles.js +161 -15
- package/src/textselection.js +1 -4
- package/src/trayhooks.js +90 -0
- package/src/types/appearance.d.ts +24 -0
- package/src/types/components.d.ts +10 -0
- package/src/types/elements.d.ts +14 -0
- package/src/types/events.d.ts +14 -0
- package/src/types/filedialog.d.ts +18 -7
- package/src/types/launcher.d.ts +43 -0
- package/src/types/notifications.d.ts +113 -0
- package/src/types/permissions.d.ts +100 -0
- package/src/types/style.d.ts +30 -2
- package/src/types/system.d.ts +5 -3
- package/src/types/tray.d.ts +54 -0
- package/src/windowid.js +23 -0
package/src/cocoa/dnd.js
ADDED
|
@@ -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
|
+
}
|
package/src/cocoa/fonts.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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) {
|