react-x11 2.2.1 → 2.3.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 +12 -5
- package/src/Reconciler.js +76 -11
- package/src/anchor.js +15 -5
- package/src/appcontext.js +27 -7
- package/src/cocoa/app.js +557 -0
- package/src/cocoa/bezels.js +161 -0
- package/src/cocoa/context2d.js +679 -0
- package/src/cocoa/fonts.js +541 -0
- package/src/cocoa/glarea.js +321 -0
- package/src/cocoa/globalmenu.js +149 -0
- package/src/cocoa/keymap.js +86 -0
- package/src/cocoa/native.js +51 -0
- package/src/cocoa/panehost.js +50 -0
- package/src/cocoa/panewindow.js +236 -0
- package/src/cocoa/presenter.js +837 -0
- package/src/cocoa/window.js +383 -0
- package/src/components/Button.js +68 -0
- package/src/components/Checkbox.js +43 -0
- package/src/components/Radio.js +37 -1
- package/src/components/Select.js +73 -28
- package/src/components/Slider.js +59 -0
- package/src/components/Switch.js +52 -0
- package/src/components/native.js +142 -0
- package/src/decorations.js +21 -24
- package/src/editmenu.js +23 -14
- package/src/events.js +11 -2
- package/src/frame/childmain.js +9 -0
- package/src/frame/index.js +162 -2
- package/src/glnodes.js +43 -0
- package/src/globalmenu.js +11 -3
- package/src/index.d.ts +10 -0
- package/src/nodes.js +107 -33
- package/src/paintcache.js +15 -4
- package/src/palette.js +14 -0
- package/src/styles.js +5 -0
- package/src/types/elements.d.ts +10 -0
package/src/cocoa/app.js
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
// The Cocoa application object — what `createRoot({ backend: 'cocoa' })`
|
|
2
|
+
// hands the renderer instead of an ntk connection. It answers the same
|
|
3
|
+
// contract the headless mock proves closed (src/testing/mock-app.js):
|
|
4
|
+
// createWindow / fonts / clipboard / X-stub / close, plus the event pump
|
|
5
|
+
// that stands in for the X socket.
|
|
6
|
+
//
|
|
7
|
+
// The run loop is deliberately the simple version (docs/macos.md §"Input
|
|
8
|
+
// and the run loop", option 1): Node's loop is master and a timer pumps
|
|
9
|
+
// AppKit. Input latency is the pump cadence; AppKit's internal modal loops
|
|
10
|
+
// (live resize, menu tracking) stall JS timers — but not the delegate
|
|
11
|
+
// callbacks, which is why live resize still relayouts: the resize event
|
|
12
|
+
// flushes the frame synchronously on its way through (`flushPendingFrames`,
|
|
13
|
+
// the same early-flush a click gets). The CFRunLoop drain (option 2) is the
|
|
14
|
+
// measured upgrade, not the first version.
|
|
15
|
+
import { cssColorStraight } from 'ntk';
|
|
16
|
+
|
|
17
|
+
import { flushPendingFrames } from '../frames.js';
|
|
18
|
+
import { setCompositingForTests } from '../compositing.js';
|
|
19
|
+
import { setScreensForTests } from '../screens.js';
|
|
20
|
+
import { setScaleForTests } from '../scale.js';
|
|
21
|
+
import { BezelStore } from './bezels.js';
|
|
22
|
+
import { CocoaGLArea, cocoaGLConfig, resolveCocoaGLRuntime } from './glarea.js';
|
|
23
|
+
import { CocoaGlobalMenuExport } from './globalmenu.js';
|
|
24
|
+
import { CocoaPaneHost } from './panehost.js';
|
|
25
|
+
import { CocoaPaneWindow } from './panewindow.js';
|
|
26
|
+
import { CocoaFontManager } from './fonts.js';
|
|
27
|
+
import { CocoaWindow } from './window.js';
|
|
28
|
+
import { decodeKey, modifierMask } from './keymap.js';
|
|
29
|
+
import { loadNative } from './native.js';
|
|
30
|
+
|
|
31
|
+
const RAF_INTERVAL_MS = 16;
|
|
32
|
+
|
|
33
|
+
class CocoaApp {
|
|
34
|
+
constructor(native, options = {}) {
|
|
35
|
+
this._native = native;
|
|
36
|
+
this.options = options;
|
|
37
|
+
this._windows = new Map(); // windowNumber -> CocoaWindow
|
|
38
|
+
this._grabWindow = null;
|
|
39
|
+
this._rafQueue = [];
|
|
40
|
+
this._rafLast = 0;
|
|
41
|
+
this._shadowStale = new Set();
|
|
42
|
+
this._pump = null;
|
|
43
|
+
this._closed = false;
|
|
44
|
+
|
|
45
|
+
const screens = native.listScreens();
|
|
46
|
+
this.scale = screens[0]?.scale ?? 1;
|
|
47
|
+
this._screens = screens;
|
|
48
|
+
|
|
49
|
+
// 'surface' (the measured default) or 'layers' — the retained CALayer
|
|
50
|
+
// presenter, opt-in while docs/macos.md's measure-first gate is open.
|
|
51
|
+
this._presenterMode =
|
|
52
|
+
options.cocoa?.presenter ??
|
|
53
|
+
process.env.REACT_X11_COCOA_PRESENTER ??
|
|
54
|
+
'surface';
|
|
55
|
+
|
|
56
|
+
this.fonts = new CocoaFontManager();
|
|
57
|
+
|
|
58
|
+
// AppKit-rendered control bezels. Its *presence* is the capability:
|
|
59
|
+
// `useSupports('nativeControls')` and the widget set's `controls:
|
|
60
|
+
// 'auto'` policy both test for this property, so a backend without it
|
|
61
|
+
// (X11, the headless mock) draws the themed controls with no further
|
|
62
|
+
// branching.
|
|
63
|
+
this.nativeBezels = new BezelStore(native);
|
|
64
|
+
|
|
65
|
+
// The GL policy, glbackend.js's shape. No GLX exists here, so the
|
|
66
|
+
// default is 'auto' (the direct backend where the runtime loads);
|
|
67
|
+
// useSupports('shaders') stays false until the first <glarea> resolves
|
|
68
|
+
// the runtime and settles _glCapsResolved.
|
|
69
|
+
this.glPolicy = { mode: options.glPolicy ?? 'auto' };
|
|
70
|
+
this._cocoaGL = null;
|
|
71
|
+
|
|
72
|
+
// the global-menu exports, newest active: the macOS menu bar is one per
|
|
73
|
+
// app, so the most recent MenuBar owns it (per-focused-window switching
|
|
74
|
+
// is the follow-up — docs/macos.md §Menus)
|
|
75
|
+
this._globalMenus = [];
|
|
76
|
+
this._activeGlobalMenu = null;
|
|
77
|
+
|
|
78
|
+
// Frame-pane mode: this process renders a pane whose pixels a host
|
|
79
|
+
// composites (REACT_X11_FRAME is what the Frame host sets on the fork).
|
|
80
|
+
this._paneMode = options.pane ?? process.env.REACT_X11_FRAME === '1';
|
|
81
|
+
this._paneSend = null;
|
|
82
|
+
|
|
83
|
+
// The X stub: just enough for the modules that carry an X escape hatch
|
|
84
|
+
// to no-op the way they do against the headless mock.
|
|
85
|
+
const listeners = {};
|
|
86
|
+
this.X = {
|
|
87
|
+
display: { screen: [{ root: 1 }] },
|
|
88
|
+
keycode2keysyms: {},
|
|
89
|
+
InternAtom: (onlyIfExists, name, cb) => {
|
|
90
|
+
if (!this._atoms.has(name)) {
|
|
91
|
+
this._atoms.set(name, 1000 + this._atoms.size);
|
|
92
|
+
}
|
|
93
|
+
cb(null, this._atoms.get(name));
|
|
94
|
+
},
|
|
95
|
+
ConfigureWindow() {},
|
|
96
|
+
SendClientMessage() {},
|
|
97
|
+
on(event, fn) {
|
|
98
|
+
(listeners[event] ??= []).push(fn);
|
|
99
|
+
},
|
|
100
|
+
emit(event, ...args) {
|
|
101
|
+
for (const fn of listeners[event] ?? []) fn(...args);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
this._atoms = new Map();
|
|
105
|
+
|
|
106
|
+
this.clipboard = {
|
|
107
|
+
write: (data) => {
|
|
108
|
+
const text =
|
|
109
|
+
typeof data === 'string'
|
|
110
|
+
? data
|
|
111
|
+
: (data?.UTF8_STRING ?? data?.STRING ?? '');
|
|
112
|
+
native.pasteboardWriteText(String(text));
|
|
113
|
+
return Promise.resolve();
|
|
114
|
+
},
|
|
115
|
+
clear: () => {
|
|
116
|
+
native.pasteboardClear();
|
|
117
|
+
return Promise.resolve();
|
|
118
|
+
},
|
|
119
|
+
targets: () => {
|
|
120
|
+
const text = native.pasteboardReadText();
|
|
121
|
+
return Promise.resolve(text == null ? [] : ['UTF8_STRING', 'STRING']);
|
|
122
|
+
},
|
|
123
|
+
read: ({ target } = {}) => {
|
|
124
|
+
const text = native.pasteboardReadText();
|
|
125
|
+
if (text == null) {
|
|
126
|
+
return Promise.reject(
|
|
127
|
+
new Error('clipboard: nothing to paste — the pasteboard is empty'),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (target === undefined) return Promise.resolve(text);
|
|
131
|
+
if (target === 'UTF8_STRING' || target === 'STRING') {
|
|
132
|
+
return Promise.resolve(Buffer.from(text, 'utf8'));
|
|
133
|
+
}
|
|
134
|
+
return Promise.reject(
|
|
135
|
+
new Error(`clipboard: cannot convert the pasteboard to ${target}`),
|
|
136
|
+
);
|
|
137
|
+
},
|
|
138
|
+
watch: () => Promise.resolve(() => {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_parseColor(value) {
|
|
143
|
+
return typeof value === 'string' ? cssColorStraight(value) : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
findArgbVisual() {
|
|
147
|
+
// every Cocoa window composites; the "visual" is a formality
|
|
148
|
+
return { visual: 1, depth: 32 };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
createWindow(attributes = {}) {
|
|
152
|
+
// A frame pane's window: no NSWindow at all — the pane paints into
|
|
153
|
+
// shared IOSurfaces and the HOST composites them (src/cocoa/
|
|
154
|
+
// panewindow.js). Chosen by the same prop the X11 pane uses.
|
|
155
|
+
if (this._paneMode && attributes.embeddable) {
|
|
156
|
+
return new CocoaPaneWindow(this, attributes);
|
|
157
|
+
}
|
|
158
|
+
if (attributes.parent) {
|
|
159
|
+
// A parented "window" here is a GL child surface — GlAreaNode's
|
|
160
|
+
// contract, the one child-window consumer this backend has. It is a
|
|
161
|
+
// sublayer, not an NSWindow; nested <window> elements proper are
|
|
162
|
+
// still not supported.
|
|
163
|
+
if (attributes.parent instanceof CocoaWindow) {
|
|
164
|
+
return new CocoaGLArea(this, attributes);
|
|
165
|
+
}
|
|
166
|
+
throw new Error(
|
|
167
|
+
'react-x11: nested <window> elements are not supported on the ' +
|
|
168
|
+
'cocoa backend yet — track docs/macos.md.',
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return new CocoaWindow(this, attributes);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The `<glarea>` config seam (src/glnodes.js): resolve the GL runtime and
|
|
176
|
+
* answer which rung of the API ladder this area draws through. See
|
|
177
|
+
* src/cocoa/glarea.js for the ladder.
|
|
178
|
+
*/
|
|
179
|
+
chooseGLConfig(spec) {
|
|
180
|
+
return cocoaGLConfig(this, spec);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The Frame host seam (src/frame/index.js): a pane's composited region
|
|
185
|
+
* in this window. Its presence is what routes <Frame> to the shared-
|
|
186
|
+
* memory pane path instead of the X foreign-window embed.
|
|
187
|
+
*/
|
|
188
|
+
createPaneHost(wnd) {
|
|
189
|
+
return new CocoaPaneHost(this, wnd);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The `useGlobalMenu` transport seam: same owner shape as the D-Bus
|
|
194
|
+
* GlobalMenuExport (start/stop/update), pointed at the macOS menu bar.
|
|
195
|
+
*/
|
|
196
|
+
createGlobalMenuExport(options) {
|
|
197
|
+
return new CocoaGlobalMenuExport(this, options);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
_registerGlobalMenu(exporter) {
|
|
201
|
+
this._globalMenus.push(exporter);
|
|
202
|
+
this._activeGlobalMenu = exporter;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_unregisterGlobalMenu(exporter) {
|
|
206
|
+
this._globalMenus = this._globalMenus.filter((e) => e !== exporter);
|
|
207
|
+
if (this._activeGlobalMenu === exporter) {
|
|
208
|
+
this._activeGlobalMenu = this._globalMenus.at(-1) ?? null;
|
|
209
|
+
if (this._activeGlobalMenu) this._activeGlobalMenu._install();
|
|
210
|
+
else this._native.setMainMenu([{ title: 'App', items: [] }]);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* What the machine can do, `useSupports('shaders')`'s slow half: ntk
|
|
216
|
+
* settles this during its connect handshake, and here it settles on the
|
|
217
|
+
* first ask — watchDirectGL calls it exactly when a component subscribes
|
|
218
|
+
* before any <glarea> forced the probe (src/glbackend.js).
|
|
219
|
+
*/
|
|
220
|
+
glCapabilities() {
|
|
221
|
+
return resolveCocoaGLRuntime(this).then(
|
|
222
|
+
() => this._glCapsResolved,
|
|
223
|
+
() => this._glCapsResolved,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
_registerWindow(wnd) {
|
|
228
|
+
this._windows.set(wnd.windowNumber, wnd);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The pane's end of the frame channel (childmain hands it over,
|
|
233
|
+
* feature-detected so the X11 pane path never notices): geometry and
|
|
234
|
+
* input come in, pane-present goes out.
|
|
235
|
+
*/
|
|
236
|
+
attachPaneChannel(channel) {
|
|
237
|
+
if (!this._paneMode) return;
|
|
238
|
+
this._paneSend = (msg) => {
|
|
239
|
+
try {
|
|
240
|
+
channel.send(msg);
|
|
241
|
+
} catch {
|
|
242
|
+
// the host is going away; its shutdown owns the rest
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
channel.onMessage((msg) => {
|
|
246
|
+
const wnd = [...this._windows.values()][0];
|
|
247
|
+
if (!wnd) return;
|
|
248
|
+
if (msg?.type === 'pane-rect') {
|
|
249
|
+
wnd.setPaneSize(msg.width, msg.height, msg.scale);
|
|
250
|
+
this._afterInput();
|
|
251
|
+
} else if (msg?.type === 'pane-event') {
|
|
252
|
+
wnd.emit(msg.name, msg.ev);
|
|
253
|
+
this._afterInput();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
_unregisterWindow(wnd) {
|
|
259
|
+
this._windows.delete(wnd.windowNumber);
|
|
260
|
+
if (this._grabWindow === wnd) this._grabWindow = null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// --- the pump ------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
start({ pumpInterval = 8 } = {}) {
|
|
266
|
+
if (this._pump) return;
|
|
267
|
+
const native = this._native;
|
|
268
|
+
// A pane process has no NSApplication to pump — no windows, no events,
|
|
269
|
+
// no dock presence. Its loop is frames and presents only.
|
|
270
|
+
if (this._paneMode) {
|
|
271
|
+
this._pump = setInterval(() => {
|
|
272
|
+
this._tickFrames();
|
|
273
|
+
this._presentAll();
|
|
274
|
+
}, pumpInterval);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
native.initApp();
|
|
278
|
+
native.setBackendEventCallback((ev) => this._route(ev));
|
|
279
|
+
this._pump = setInterval(() => {
|
|
280
|
+
native.pump2(); // flushes the previous tick's CATransaction
|
|
281
|
+
if (this._shadowStale.size) {
|
|
282
|
+
for (const wnd of this._shadowStale) {
|
|
283
|
+
if (!wnd.destroyed) native.invalidateWindowShadow(wnd._h);
|
|
284
|
+
}
|
|
285
|
+
this._shadowStale.clear();
|
|
286
|
+
}
|
|
287
|
+
this._tickFrames();
|
|
288
|
+
this._presentAll();
|
|
289
|
+
}, pumpInterval);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
_requestFrame(cb) {
|
|
293
|
+
this._rafQueue.push(cb);
|
|
294
|
+
return this._rafQueue.length;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
_tickFrames() {
|
|
298
|
+
if (!this._rafQueue.length) return;
|
|
299
|
+
const now = Date.now();
|
|
300
|
+
if (now - this._rafLast < RAF_INTERVAL_MS) return;
|
|
301
|
+
this._rafLast = now;
|
|
302
|
+
const queue = this._rafQueue;
|
|
303
|
+
this._rafQueue = [];
|
|
304
|
+
for (const cb of queue) {
|
|
305
|
+
try {
|
|
306
|
+
cb(now);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
queueMicrotask(() => {
|
|
309
|
+
throw err;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
_presentAll() {
|
|
316
|
+
for (const wnd of this._windows.values()) wnd.present();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** After any synchronously dispatched input: paint the response now (the
|
|
320
|
+
* same early flush a discrete event gets on X11) and put it on glass. */
|
|
321
|
+
_afterInput() {
|
|
322
|
+
flushPendingFrames();
|
|
323
|
+
this._presentAll();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// --- event routing -------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
_route(ev) {
|
|
329
|
+
if (this._closed) return;
|
|
330
|
+
switch (ev.type) {
|
|
331
|
+
case 'mousedown':
|
|
332
|
+
case 'mouseup':
|
|
333
|
+
return this._routeButton(ev);
|
|
334
|
+
case 'mousemove':
|
|
335
|
+
return this._routeMotion(ev, 'mousemove');
|
|
336
|
+
case 'mouseleave':
|
|
337
|
+
return this._routeMotion(ev, 'mouseout');
|
|
338
|
+
case 'mouseenter':
|
|
339
|
+
return this._routeMotion(ev, 'mousemove');
|
|
340
|
+
case 'wheel':
|
|
341
|
+
return this._routeWheel(ev);
|
|
342
|
+
case 'keydown':
|
|
343
|
+
case 'keyup':
|
|
344
|
+
return this._routeKey(ev);
|
|
345
|
+
case 'window-resize':
|
|
346
|
+
case 'window-move':
|
|
347
|
+
return this._routeGeometry(ev);
|
|
348
|
+
case 'window-close-request':
|
|
349
|
+
return this._routeClose(ev);
|
|
350
|
+
case 'window-focus':
|
|
351
|
+
return this._routeFocus(ev, 'focus');
|
|
352
|
+
case 'window-blur':
|
|
353
|
+
return this._routeFocus(ev, 'blur');
|
|
354
|
+
case 'menu-activate':
|
|
355
|
+
this._activeGlobalMenu?.activate(ev.id);
|
|
356
|
+
return this._afterInput();
|
|
357
|
+
default:
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
_window(ev) {
|
|
363
|
+
return ev.windowNumber != null
|
|
364
|
+
? (this._windows.get(ev.windowNumber) ?? null)
|
|
365
|
+
: null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The grab rule, X-shaped: while a popup holds the "pointer grab", a press
|
|
370
|
+
* in any other window of ours is delivered to the grab holder in the grab
|
|
371
|
+
* holder's coordinates — landing outside its bounds, which is what its
|
|
372
|
+
* event manager reads as a dismissal (events.js `_pressOutside`).
|
|
373
|
+
*/
|
|
374
|
+
_grabTarget(ev, wnd) {
|
|
375
|
+
const grab = this._grabWindow;
|
|
376
|
+
if (!grab || grab.destroyed || grab === wnd) return null;
|
|
377
|
+
const s = this.scale;
|
|
378
|
+
return {
|
|
379
|
+
wnd: grab,
|
|
380
|
+
x: Math.round(ev.gx * s) - grab._screenOrigin.x,
|
|
381
|
+
y: Math.round(ev.gy * s) - grab._screenOrigin.y,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
_routeButton(ev) {
|
|
386
|
+
const wnd = this._window(ev);
|
|
387
|
+
if (!wnd) return;
|
|
388
|
+
const s = this.scale;
|
|
389
|
+
const buttons = modifierMask(ev);
|
|
390
|
+
const redirect = ev.type === 'mousedown' ? this._grabTarget(ev, wnd) : null;
|
|
391
|
+
const target = redirect?.wnd ?? wnd;
|
|
392
|
+
target.emit(ev.type, {
|
|
393
|
+
x: redirect ? redirect.x : Math.round(ev.x * s),
|
|
394
|
+
y: redirect ? redirect.y : Math.round(ev.y * s),
|
|
395
|
+
rootx: Math.round(ev.gx * s),
|
|
396
|
+
rooty: Math.round(ev.gy * s),
|
|
397
|
+
keycode: ev.button,
|
|
398
|
+
buttons,
|
|
399
|
+
time: ev.time,
|
|
400
|
+
});
|
|
401
|
+
this._afterInput();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
_routeMotion(ev, name) {
|
|
405
|
+
const wnd = this._window(ev);
|
|
406
|
+
if (!wnd) return;
|
|
407
|
+
const s = this.scale;
|
|
408
|
+
wnd.emit(name, {
|
|
409
|
+
x: Math.round(ev.x * s),
|
|
410
|
+
y: Math.round(ev.y * s),
|
|
411
|
+
rootx: Math.round(ev.gx * s),
|
|
412
|
+
rooty: Math.round(ev.gy * s),
|
|
413
|
+
buttons: modifierMask(ev),
|
|
414
|
+
time: ev.time,
|
|
415
|
+
});
|
|
416
|
+
// motion is paced on the frame clock, not flushed per event
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
_routeWheel(ev) {
|
|
420
|
+
const wnd = this._window(ev);
|
|
421
|
+
if (!wnd) return;
|
|
422
|
+
const s = this.scale;
|
|
423
|
+
// AppKit: positive deltas scroll toward the top (natural direction
|
|
424
|
+
// already folded in); the renderer's notches are the X convention,
|
|
425
|
+
// positive = content advancing downward. Precise deltas are points —
|
|
426
|
+
// 48 device pixels is one notch, so a swipe maps 1:1 onto pixels.
|
|
427
|
+
const toNotches = (delta) =>
|
|
428
|
+
ev.precise
|
|
429
|
+
? (-delta * s) / 48
|
|
430
|
+
: -Math.sign(delta) * Math.ceil(Math.abs(delta));
|
|
431
|
+
const deltaX = toNotches(ev.dx);
|
|
432
|
+
const deltaY = toNotches(ev.dy);
|
|
433
|
+
if (!deltaX && !deltaY) return;
|
|
434
|
+
wnd.emit('wheel', {
|
|
435
|
+
name: 'wheel',
|
|
436
|
+
x: Math.round(ev.x * s),
|
|
437
|
+
y: Math.round(ev.y * s),
|
|
438
|
+
rootx: Math.round(ev.gx * s),
|
|
439
|
+
rooty: Math.round(ev.gy * s),
|
|
440
|
+
buttons: modifierMask(ev),
|
|
441
|
+
deltaX,
|
|
442
|
+
deltaY,
|
|
443
|
+
deltaMode: 'line',
|
|
444
|
+
smooth: Boolean(ev.precise),
|
|
445
|
+
source: ev.precise ? 'valuator' : 'button',
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
_routeKey(ev) {
|
|
450
|
+
// keys go to the key window; without one (all popups) the last focused
|
|
451
|
+
// toplevel keeps the keyboard, which matches the focus model upstairs
|
|
452
|
+
const wnd = this._window(ev) ?? this._lastKeyWindow;
|
|
453
|
+
if (!wnd) return;
|
|
454
|
+
const decoded = decodeKey(ev);
|
|
455
|
+
wnd.emit(ev.type, {
|
|
456
|
+
keycode: ev.keyCode,
|
|
457
|
+
keysym: decoded.keysym,
|
|
458
|
+
baseKeysym: decoded.baseKeysym,
|
|
459
|
+
codepoint: decoded.codepoint,
|
|
460
|
+
buttons: modifierMask(ev),
|
|
461
|
+
group: 0,
|
|
462
|
+
time: ev.time,
|
|
463
|
+
});
|
|
464
|
+
this._afterInput();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
_routeGeometry(ev) {
|
|
468
|
+
const wnd = this._window(ev);
|
|
469
|
+
if (!wnd || wnd.destroyed) return;
|
|
470
|
+
wnd._nativeResized(ev);
|
|
471
|
+
wnd.emit('resize', {
|
|
472
|
+
width: wnd.width,
|
|
473
|
+
height: wnd.height,
|
|
474
|
+
x: wnd.x,
|
|
475
|
+
y: wnd.y,
|
|
476
|
+
moved: true,
|
|
477
|
+
resized: ev.type === 'window-resize',
|
|
478
|
+
});
|
|
479
|
+
// During a live resize AppKit's modal loop owns the thread and Node
|
|
480
|
+
// timers stall; flushing here is what keeps layout tracking the drag.
|
|
481
|
+
this._afterInput();
|
|
482
|
+
// The React HALF of the response — an anchored popup following the
|
|
483
|
+
// window, an onResize setState — commits on a microtask AFTER this
|
|
484
|
+
// handler returns, and the pump that would paint it is the thing the
|
|
485
|
+
// modal loop stalled. A second flush queued BEHIND that commit is what
|
|
486
|
+
// lets an open menu resize with the drag instead of on release.
|
|
487
|
+
queueMicrotask(() => {
|
|
488
|
+
if (!this._closed) this._afterInput();
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
_routeClose(ev) {
|
|
493
|
+
const wnd = this._window(ev);
|
|
494
|
+
if (!wnd) return;
|
|
495
|
+
wnd.emit('close', {
|
|
496
|
+
preventDefault() {},
|
|
497
|
+
});
|
|
498
|
+
this._afterInput();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
_routeFocus(ev, name) {
|
|
502
|
+
const wnd = this._window(ev);
|
|
503
|
+
if (!wnd) return;
|
|
504
|
+
if (name === 'focus') this._lastKeyWindow = wnd;
|
|
505
|
+
wnd.emit(name, { buttons: 0, time: ev.time });
|
|
506
|
+
this._afterInput();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// --- teardown ------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
close() {
|
|
512
|
+
if (this._closed) return Promise.resolve();
|
|
513
|
+
this._closed = true;
|
|
514
|
+
if (this._pump) clearInterval(this._pump);
|
|
515
|
+
this._pump = null;
|
|
516
|
+
this._cocoaGL?.destroy();
|
|
517
|
+
this._cocoaGL = null;
|
|
518
|
+
this._native.setBackendEventCallback(null);
|
|
519
|
+
for (const wnd of [...this._windows.values()]) wnd.destroy();
|
|
520
|
+
return Promise.resolve();
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Build the app and seed the platform stores the way the mock seeds them —
|
|
526
|
+
* `beginScale`/`beginScreens`/`beginCompositing` find a session already
|
|
527
|
+
* open and leave it alone, so `createRoot`'s shared flow runs unchanged.
|
|
528
|
+
*/
|
|
529
|
+
export async function createCocoaApp(options = {}) {
|
|
530
|
+
const native = loadNative();
|
|
531
|
+
const app = new CocoaApp(native, options);
|
|
532
|
+
|
|
533
|
+
setScaleForTests(app, app.scale, 'cocoa');
|
|
534
|
+
const s = app.scale;
|
|
535
|
+
const monitors = app._screens.map((screen) => ({
|
|
536
|
+
x: Math.round(screen.x * s),
|
|
537
|
+
y: Math.round(screen.y * s),
|
|
538
|
+
width: Math.round(screen.width * s),
|
|
539
|
+
height: Math.round(screen.height * s),
|
|
540
|
+
}));
|
|
541
|
+
const primary = app._screens[0];
|
|
542
|
+
setScreensForTests(app, {
|
|
543
|
+
monitors,
|
|
544
|
+
workArea: primary
|
|
545
|
+
? {
|
|
546
|
+
x: Math.round(primary.visible.x * s),
|
|
547
|
+
y: Math.round(primary.visible.y * s),
|
|
548
|
+
width: Math.round(primary.visible.width * s),
|
|
549
|
+
height: Math.round(primary.visible.height * s),
|
|
550
|
+
}
|
|
551
|
+
: null,
|
|
552
|
+
});
|
|
553
|
+
setCompositingForTests(app, true);
|
|
554
|
+
|
|
555
|
+
app.start(options.cocoa ?? {});
|
|
556
|
+
return app;
|
|
557
|
+
}
|