react-x11 2.16.0 → 2.17.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 (62) hide show
  1. package/README.md +38 -23
  2. package/package.json +3 -1
  3. package/src/Reconciler.js +82 -23
  4. package/src/a11y.js +18 -1
  5. package/src/acceleratorhooks.js +40 -6
  6. package/src/anchor.js +20 -2
  7. package/src/appcontext.js +8 -0
  8. package/src/appearance.js +36 -0
  9. package/src/{cocoa → backend}/context2d.js +27 -7
  10. package/src/capabilities.js +99 -1
  11. package/src/cocoa/app.js +204 -6
  12. package/src/cocoa/fonts.js +1 -1
  13. package/src/cocoa/overlay.js +2 -2
  14. package/src/cocoa/panewindow.js +2 -2
  15. package/src/cocoa/presenter.js +2 -2
  16. package/src/cocoa/surface.js +3 -3
  17. package/src/cocoa/window.js +2 -2
  18. package/src/events.js +21 -0
  19. package/src/foreignnodes.js +8 -3
  20. package/src/frame/index.js +30 -4
  21. package/src/glnodes.js +12 -1
  22. package/src/idle.js +59 -1
  23. package/src/index.d.ts +51 -1
  24. package/src/index.js +30 -3
  25. package/src/keysymchars.js +47 -0
  26. package/src/keysyms.d.ts +19 -1
  27. package/src/keysyms.js +107 -8
  28. package/src/launcher.js +17 -8
  29. package/src/launcherhooks.js +24 -10
  30. package/src/node.d.ts +1 -1
  31. package/src/nodes/cascade.js +9 -0
  32. package/src/nodes/node.js +6 -1
  33. package/src/nodes/window/hints.js +21 -2
  34. package/src/nodes/window/window.js +2 -2
  35. package/src/notifications.js +39 -14
  36. package/src/screens.js +159 -24
  37. package/src/taskbarhooks.js +164 -0
  38. package/src/transfer.js +20 -1
  39. package/src/trayhooks.js +1 -1
  40. package/src/types/capabilities.d.ts +32 -3
  41. package/src/types/elements.d.ts +23 -1
  42. package/src/types/events.d.ts +21 -0
  43. package/src/types/filedialog.d.ts +3 -1
  44. package/src/types/launcher.d.ts +20 -6
  45. package/src/types/taskbar.d.ts +79 -0
  46. package/src/wayland/context2d.js +1 -1
  47. package/src/wayland/xkb.js +170 -59
  48. package/src/win32/a11y.js +604 -0
  49. package/src/win32/app.js +768 -0
  50. package/src/win32/bezels.js +158 -0
  51. package/src/win32/dnd.js +283 -0
  52. package/src/win32/fonts.js +497 -0
  53. package/src/win32/glarea.js +548 -0
  54. package/src/win32/ime.js +267 -0
  55. package/src/win32/keymap.js +116 -0
  56. package/src/win32/native.js +54 -0
  57. package/src/win32/panehost.js +106 -0
  58. package/src/win32/panewindow.js +343 -0
  59. package/src/win32/shell.js +426 -0
  60. package/src/win32/surface.js +192 -0
  61. package/src/win32/window.js +659 -0
  62. package/src/windowid.js +128 -20
@@ -0,0 +1,768 @@
1
+ // The Windows backend's app object: the bridge's event channel on one side,
2
+ // the ntk-application shape the renderer expects on the other.
3
+ //
4
+ // There is no pump here, and that is the point. The Cocoa backend pumps AppKit
5
+ // from a libuv timer because AppKit insists on the process's main thread, and
6
+ // pays for it — an input waits for the next tick, and a modal loop freezes Node
7
+ // outright (#484). Win32 binds a window to the thread that created it, so the
8
+ // bridge runs every HWND and every modal loop on a thread of its own and
9
+ // reaches this side through a threadsafe function, which wakes Node's loop at
10
+ // once. What is left here is routing.
11
+ import { systemAppearance } from '../appearance.js';
12
+ import { setCompositingForTests } from '../compositing.js';
13
+ import { setScaleForTests } from '../scale.js';
14
+ import { setScreensForTests } from '../screens.js';
15
+
16
+ import { createBezels } from './bezels.js';
17
+ import { Win32InputMethod } from './ime.js';
18
+ import { decodeKey, modifierMask } from './keymap.js';
19
+ import { Win32PaneHost } from './panehost.js';
20
+ import { Win32PaneWindow } from './panewindow.js';
21
+ import { Win32Surface } from './surface.js';
22
+ import { installGl, Win32GlWindow } from './glarea.js';
23
+ import { Win32FontManager } from './fonts.js';
24
+ import { loadNative } from './native.js';
25
+ import {
26
+ installIdle,
27
+ installNotifications,
28
+ installTaskbar,
29
+ installTaskbarSurfaces,
30
+ Win32FilePanels,
31
+ Win32StatusItem,
32
+ } from './shell.js';
33
+ import { Win32Window } from './window.js';
34
+
35
+ // The fallback frame period, for a Windows with no compositor clock to wait
36
+ // on (before 10/1803) — and only that, because as a *clock* a JS timer is a
37
+ // bad one here. Node rounds a timer up to the next system tick, the default
38
+ // tick is 15.6ms, and a 16.67ms request therefore lands on the second one:
39
+ // measured, 155 of 200 intervals at 31ms and 24 at 16ms, which is 34.8
40
+ // frames a second alternating between one tick and two. Everything paced
41
+ // judders at that however cheap its frames are.
42
+ //
43
+ // So the clock is `frameClockRequest` (windows/src/frameclock.cc), which
44
+ // blocks a thread of its own on DCompositionWaitForCompositorClock and tells
45
+ // JS when the compositor is ready for a frame. This number is what is left
46
+ // when that is unavailable.
47
+ const FRAME_INTERVAL_MS = 1000 / 60;
48
+
49
+ // How long a promised compositor tick may take before the timer takes over
50
+ // for the rest of the session. A tick that is asked for and never arrives
51
+ // would stop every frame in the application for good — an app frozen with a
52
+ // clean event loop, which is the worst kind to be told about — so the clock
53
+ // is watched. One timer per frame costs nothing: what was wrong with a timer
54
+ // here was never its cost, only its granularity.
55
+ const CLOCK_WATCHDOG_MS = 250;
56
+
57
+ /**
58
+ * A pointer event's position on the screen, which X carries as `rootx`/
59
+ * `rooty` and Windows does not: WM_MOUSEMOVE and the button messages are all
60
+ * client-relative. The window knows where its client area is, so the sum is
61
+ * exact and costs nothing.
62
+ *
63
+ * Anything that places something *outside* the window reads these — a
64
+ * `ContextMenu` opens at the pointer, and a drag's feedback follows it
65
+ * (src/components/Menu.js, src/dnd.js). Without them both fall back to the
66
+ * window-relative coordinate and treat it as a screen one, which puts a
67
+ * right-click menu a whole window-offset away from the pointer.
68
+ */
69
+ function rootOf(wnd, event) {
70
+ const origin = wnd._screenOrigin ?? { x: wnd.x ?? 0, y: wnd.y ?? 0 };
71
+ return { rootx: event.a + origin.x, rooty: event.b + origin.y };
72
+ }
73
+
74
+ export class Win32App {
75
+ constructor(native, options = {}) {
76
+ this._native = native;
77
+ this.options = options;
78
+ this._windows = new Map();
79
+ // The drag this process started, while the shell is carrying it — what
80
+ // an in-app drop reads to keep `e.items` by reference (src/dnd.js).
81
+ this._activeDrag = null;
82
+ this._rafQueue = [];
83
+ this._frameTimer = null;
84
+ /** A frame has been asked for and not yet delivered — one outstanding
85
+ * request at a time, whether the clock or the timer is answering it. */
86
+ this._framePending = false;
87
+ /** The compositor promised a tick and did not deliver one. Latched: the
88
+ * timer answers every frame from then on. */
89
+ this._clockLost = false;
90
+ this._closed = false;
91
+ this._atoms = new Map();
92
+ this._appearanceListeners = new Set();
93
+ /** Tray icons by the bridge's handle, so an event can find its item. */
94
+ this._statusItems = new Map();
95
+ /** The input method. Composition is a property of the platform rather
96
+ * than of a window, so there is one of these and it follows whichever
97
+ * field is focused (src/win32/ime.js). */
98
+ this.inputMethod = new Win32InputMethod(this);
99
+ /** Popups that asked for a pointer grab. Windows has none to give, so
100
+ * what the grab was for is watched for instead — see `grabPointer` in
101
+ * src/win32/window.js and `_dismissOutsidePopups` below. */
102
+ this._dismissOnOutside = new Set();
103
+ /** GL surfaces by the bridge's id, so a 'gl-ready' event finds its own. */
104
+ this._glWindows = new Map();
105
+ /** This process *is* a `<Frame>`'s pane: it has no windows of its own,
106
+ * only a buffer the host composites (REACT_X11_FRAME is what the Frame
107
+ * host sets on the fork). */
108
+ this._paneMode = options.pane ?? process.env.REACT_X11_FRAME === '1';
109
+ this._paneSend = null;
110
+ /** The native open/save panels. Its *presence* is what puts the top rung
111
+ * on src/filedialog.js's ladder for this app. */
112
+ this.filePanels =
113
+ typeof native.fileDialog === 'function'
114
+ ? new Win32FilePanels(this)
115
+ : null;
116
+ /** The system's own control bezels, when they would look right — see
117
+ * `_syncBezels`. Read by `useNativeControls()` as a capability. */
118
+ this.nativeBezels = null;
119
+
120
+ this.fonts = new Win32FontManager(native);
121
+
122
+ const screens = native.listScreens?.() ?? [];
123
+ const primary = screens[0] ?? {
124
+ x: 0,
125
+ y: 0,
126
+ width: 1920,
127
+ height: 1080,
128
+ scale: 1,
129
+ };
130
+ this.scale = primary.scale ?? 1;
131
+ this._screens = screens.length ? screens : [primary];
132
+
133
+ const listeners = {};
134
+ // The X connection's shape, for the code above that reaches into it —
135
+ // atoms, a root window, the connection's own events. Nothing here talks a
136
+ // protocol; it exists so the layers that were written against X do not
137
+ // each need a backend branch.
138
+ this.X = {
139
+ display: { screen: [{ root: 1 }] },
140
+ keycode2keysyms: {},
141
+ InternAtom: (onlyIfExists, name, cb) => {
142
+ if (!this._atoms.has(name))
143
+ this._atoms.set(name, 1000 + this._atoms.size);
144
+ cb(null, this._atoms.get(name));
145
+ },
146
+ ConfigureWindow() {},
147
+ SendClientMessage() {},
148
+ on(event, fn) {
149
+ (listeners[event] ??= []).push(fn);
150
+ },
151
+ emit(event, ...args) {
152
+ for (const fn of listeners[event] ?? []) fn(...args);
153
+ },
154
+ };
155
+
156
+ // ntk's clipboard shape over the Windows clipboard. Opened with no owner
157
+ // window, which keeps it off the UI thread; what that gives up is delayed
158
+ // rendering, so a write here is eager rather than lazy. X's other everyday
159
+ // selection, PRIMARY, has no Windows equivalent at all — every name but
160
+ // CLIPBOARD is a selection nobody on this desktop can paste from, and is
161
+ // answered as empty rather than pretended at.
162
+ const isClipboard = (selection) => !selection || selection === 'CLIPBOARD';
163
+ this.clipboard = {
164
+ write: (data, { selection = 'CLIPBOARD' } = {}) => {
165
+ if (!isClipboard(selection)) return Promise.resolve();
166
+ const text =
167
+ typeof data === 'string'
168
+ ? data
169
+ : (data?.['text/plain'] ?? data?.UTF8_STRING ?? data?.STRING);
170
+ if (typeof text !== 'string') {
171
+ return Promise.reject(
172
+ new Error(
173
+ 'react-x11: the win32 clipboard writes text only for now — ' +
174
+ 'images and file lists need the OLE data object, which is not ' +
175
+ 'built. Pass a string, or a map with a text/plain entry.',
176
+ ),
177
+ );
178
+ }
179
+ return native.clipboardWriteText(text)
180
+ ? Promise.resolve()
181
+ : Promise.reject(
182
+ new Error(
183
+ 'react-x11: the clipboard refused the write — another program ' +
184
+ 'held it open. Retrying usually succeeds.',
185
+ ),
186
+ );
187
+ },
188
+ clear: (selection = 'CLIPBOARD') => {
189
+ if (isClipboard(selection)) native.clipboardWriteText('');
190
+ return Promise.resolve();
191
+ },
192
+ targets: ({ selection = 'CLIPBOARD' } = {}) =>
193
+ Promise.resolve(
194
+ isClipboard(selection) ? native.clipboardFormats() : [],
195
+ ),
196
+ read: ({ selection = 'CLIPBOARD', target } = {}) => {
197
+ if (!isClipboard(selection)) {
198
+ return Promise.reject(
199
+ new Error(
200
+ `clipboard: nothing to paste — ${selection} has no owner`,
201
+ ),
202
+ );
203
+ }
204
+ const text = native.clipboardReadText();
205
+ if (text === null) {
206
+ return Promise.reject(new Error('clipboard: nothing to paste'));
207
+ }
208
+ if (target === undefined) return Promise.resolve(text);
209
+ if (
210
+ target === 'text/plain' ||
211
+ target === 'UTF8_STRING' ||
212
+ target === 'STRING'
213
+ ) {
214
+ return Promise.resolve(Buffer.from(text, 'utf8'));
215
+ }
216
+ return Promise.reject(
217
+ new Error(`clipboard: owner cannot convert to ${target}`),
218
+ );
219
+ },
220
+ // No owner window means no AddClipboardFormatListener, so a change is
221
+ // noticed by the sequence number rather than announced. Polled slowly
222
+ // and unref'd: this must never be the reason a process stays alive.
223
+ watch: (selection, handler) => {
224
+ if (!isClipboard(selection)) return Promise.resolve(() => {});
225
+ let last = native.clipboardSequence();
226
+ const timer = setInterval(() => {
227
+ const now = native.clipboardSequence();
228
+ if (now === last) return;
229
+ last = now;
230
+ handler({ selection: 'CLIPBOARD', owner: 1, reason: 'new-owner' });
231
+ }, 400);
232
+ timer.unref?.();
233
+ return Promise.resolve(() => clearInterval(timer));
234
+ },
235
+ };
236
+ }
237
+
238
+ // --- the desktop ----------------------------------------------------------
239
+
240
+ /** Light or dark, the accent, contrast and reduced motion, as one answer —
241
+ * the rung `src/appearance.js` asks for by capability. */
242
+ systemAppearance() {
243
+ return this._native.systemAppearance();
244
+ }
245
+
246
+ onAppearanceChange(fn) {
247
+ this._appearanceListeners.add(fn);
248
+ return () => this._appearanceListeners.delete(fn);
249
+ }
250
+
251
+ /** A pixel off the screen, for `useEyedropper()`. Windows asks no permission
252
+ * and draws no capture border, so the loupe is the renderer's to draw. */
253
+ screenColorAt(x, y) {
254
+ return this._native.screenColorAt(Math.round(x), Math.round(y));
255
+ }
256
+
257
+ pointerPosition() {
258
+ return this._native.pointerPosition();
259
+ }
260
+
261
+ /** A tray icon. `useTray()` finds this by name, not by platform — the rule
262
+ * AGENTS.md sets for every ladder. */
263
+ /**
264
+ * Close every open `<popup grab>` that this press did not land in.
265
+ *
266
+ * `pressed` is the window the press was delivered to, or null when the
267
+ * application lost activation and there is no window of ours to exclude.
268
+ * Everything else open hears the press as an outside one and answers with
269
+ * `onDismiss` (src/win32/window.js `_dismissFromOutside`).
270
+ *
271
+ * Excluding the pressed window is what makes a submenu work: clicking the
272
+ * menu that opened it closes the submenu and keeps the menu, which is what
273
+ * an X11 grab does by handing the press to the innermost holder.
274
+ *
275
+ * A copy of the set is walked because `onDismiss` is what unmounts the
276
+ * popup, and unmounting runs `ungrabPointer` — which deletes from the set
277
+ * this loop would otherwise still be reading.
278
+ */
279
+ _dismissOutsidePopups(pressed) {
280
+ if (this._dismissOnOutside.size === 0) return;
281
+ for (const wnd of [...this._dismissOnOutside]) {
282
+ if (wnd === pressed) continue;
283
+ if (wnd.destroyed) {
284
+ this._dismissOnOutside.delete(wnd);
285
+ continue;
286
+ }
287
+ wnd._dismissFromOutside();
288
+ }
289
+ }
290
+
291
+ /**
292
+ * A drag session's point, as the tree's own units.
293
+ *
294
+ * The bridge reports screen device pixels, like every other event it
295
+ * sends; `DragSession.nativeEnded` multiplies by the node's scale to get
296
+ * back to them, because the cocoa bridge it was written against reports
297
+ * points. So this divides, and the two agree at any scale.
298
+ */
299
+ _dragPoint(event) {
300
+ const s = this.scale || 1;
301
+ return { x: (event.a ?? 0) / s, y: (event.b ?? 0) / s };
302
+ }
303
+
304
+ createStatusItem(options) {
305
+ return new Win32StatusItem(this, options);
306
+ }
307
+
308
+ /** Installed only where the system's own bezels would actually look right —
309
+ * see src/win32/bezels.js, which measures rather than assumes. */
310
+ _syncBezels(colorScheme) {
311
+ const wanted = colorScheme !== 'dark';
312
+ if (wanted === Boolean(this.nativeBezels)) return;
313
+ this.nativeBezels?.clear?.();
314
+ this.nativeBezels = wanted
315
+ ? createBezels(this._native, { colorScheme, scale: this.scale })
316
+ : null;
317
+ }
318
+
319
+ // --- the renderer's app surface -------------------------------------------
320
+
321
+ /** Every window is composited by DWM and every surface has an alpha
322
+ * channel, so `<window transparent>` needs nothing found first. */
323
+ findArgbVisual() {
324
+ return { visual: 0x21, depth: 32 };
325
+ }
326
+
327
+ /**
328
+ * The offscreen-surface seam `react-x11/ntk`'s `Surface` dispatches on:
329
+ * ntk's `Surface` contract over a Direct2D bitmap (src/win32/surface.js).
330
+ * Its presence is what makes `new Surface(app, { width, height })` answer
331
+ * a surface here rather than ntk's pixmap, which needs an X connection —
332
+ * a backend without the method gets ntk's, so an X app is never asked.
333
+ */
334
+ createSurface(options) {
335
+ return new Win32Surface(this, options);
336
+ }
337
+ createWindow(attributes = {}) {
338
+ // `embeddable` is "created, not shown; somebody else will place me", and
339
+ // on this backend that cannot be a window: a composition target stops
340
+ // presenting the moment its window becomes a child, so an HWND is not
341
+ // something another process can take (docs/windows-embedding.md). What a
342
+ // host can take is the **buffer**, so an embeddable window here is the
343
+ // same shared surface a `<Frame>` pane draws into — whether this process
344
+ // was forked as a pane or is a guest somebody else started.
345
+ //
346
+ // Before the `parent` branch: an embeddable window has no parent, and
347
+ // this is the only place the two could be confused.
348
+ if (attributes.embeddable) {
349
+ return new Win32PaneWindow(this, attributes);
350
+ }
351
+ // A window with a parent is a `<glarea>`'s surface, which is a different
352
+ // thing entirely: a child HWND with a GL context and no DirectComposition
353
+ // surface at all. `glnodes.js` asks for one through this same door, as it
354
+ // does on X11 where a GL surface is also just a child window.
355
+ if (attributes.parent) return new Win32GlWindow(this, attributes);
356
+ return new Win32Window(this, attributes);
357
+ }
358
+
359
+ /**
360
+ * The seam `<Frame>` dispatches on (src/frame/index.js): a backend that
361
+ * composites a pane from a shared buffer answers here, and one that
362
+ * reparents the pane's real window does not. Its presence is the whole
363
+ * capability — the element, the props, the fallback and the restart are
364
+ * the same code on every backend.
365
+ */
366
+ createPaneHost(wnd) {
367
+ return new Win32PaneHost(this, wnd);
368
+ }
369
+
370
+ /**
371
+ * The pane process's end of the frame channel: geometry and input in,
372
+ * presents out. Feature-detected by the pane bootstrap
373
+ * (src/frame/childmain.js), and a no-op in a process that is not a pane.
374
+ */
375
+ attachPaneChannel(channel) {
376
+ if (!this._paneMode) return;
377
+ this._paneSend = (msg) => {
378
+ try {
379
+ channel.send(msg);
380
+ } catch {
381
+ // the host is going away; its shutdown owns the rest
382
+ }
383
+ };
384
+ channel.onMessage((msg) => {
385
+ const wnd = [...this._windows.values()][0];
386
+ if (!wnd) return;
387
+ if (msg?.type === 'pane-rect') {
388
+ wnd.setPaneSize?.(msg.width, msg.height, msg.scale);
389
+ } else if (msg?.type === 'pane-event') {
390
+ wnd.emit(msg.name, msg.ev);
391
+ }
392
+ });
393
+ }
394
+
395
+ close() {
396
+ if (this._closed) return Promise.resolve();
397
+ this._closed = true;
398
+ if (this._frameTimer) clearTimeout(this._frameTimer);
399
+ this._frameTimer = null;
400
+ this._framePending = false;
401
+ this.inputMethod.destroy();
402
+ this._native.stop();
403
+ return Promise.resolve();
404
+ }
405
+
406
+ frameIntervalFor() {
407
+ return FRAME_INTERVAL_MS;
408
+ }
409
+
410
+ // --- windows --------------------------------------------------------------
411
+
412
+ _register(wnd) {
413
+ this._windows.set(wnd.id, wnd);
414
+ }
415
+
416
+ _unregister(wnd) {
417
+ this._windows.delete(wnd.id);
418
+ }
419
+
420
+ // --- the frame clock ------------------------------------------------------
421
+
422
+ _requestFrame(cb, wnd = null) {
423
+ this._rafQueue.push({ cb, wnd });
424
+ if (this._framePending || this._closed) return this._rafQueue.length;
425
+ this._framePending = true;
426
+ // One tick, from the compositor if this Windows has a clock to wait on.
427
+ // It answers false when it has not, and then — and only then — a timer.
428
+ if (!this._clockLost && this._native.frameClockRequest?.()) {
429
+ this._frameTimer = setTimeout(() => {
430
+ this._frameTimer = null;
431
+ if (!this._framePending || this._closed) return;
432
+ this._clockLost = true;
433
+ this._framePending = false;
434
+ this._tickFrames();
435
+ }, CLOCK_WATCHDOG_MS);
436
+ this._frameTimer.unref?.();
437
+ return this._rafQueue.length;
438
+ }
439
+ this._frameTimer = setTimeout(() => {
440
+ this._frameTimer = null;
441
+ this._framePending = false;
442
+ this._tickFrames();
443
+ }, FRAME_INTERVAL_MS);
444
+ this._frameTimer.unref?.();
445
+ return this._rafQueue.length;
446
+ }
447
+
448
+ _tickFrames() {
449
+ const due = this._rafQueue;
450
+ this._rafQueue = [];
451
+ const now = performance.now();
452
+ for (const { cb } of due) {
453
+ try {
454
+ cb(now);
455
+ } catch (err) {
456
+ // A throw from one window's frame must not take the others' with it;
457
+ // the renderer's own error plumbing has already had its say by here.
458
+ if (process.env.NODE_ENV !== 'production') console.error(err);
459
+ }
460
+ }
461
+ // The input method hears about the focused field now, after the frame has
462
+ // laid the tree out and the caret rectangle is one a candidate list can
463
+ // be put next to (src/win32/ime.js). Once per window, however many
464
+ // callbacks it had.
465
+ const seen = new Set();
466
+ for (const { wnd } of due) {
467
+ if (!wnd || seen.has(wnd)) continue;
468
+ seen.add(wnd);
469
+ this.inputMethod.sync(wnd);
470
+ }
471
+ }
472
+
473
+ // --- the event channel ----------------------------------------------------
474
+
475
+ /**
476
+ * One bridge event. Everything arrives on Node's own loop through the
477
+ * threadsafe function, inside a callback scope that drains microtasks when
478
+ * it closes — so a handler's setState commits on the event that caused it.
479
+ */
480
+ _route(event) {
481
+ // The shell's events are not a window's: a tray icon, a file dialog and a
482
+ // hotkey each carry an id of their own, and looking one up in the window
483
+ // map would drop it.
484
+ switch (event.type) {
485
+ // The compositor is ready for a frame. Carries no id and belongs to no
486
+ // window: it is the clock, and every window that asked for a frame in
487
+ // the meantime is paced by this one tick.
488
+ case 'frame-clock':
489
+ if (this._frameTimer) clearTimeout(this._frameTimer);
490
+ this._frameTimer = null;
491
+ this._framePending = false;
492
+ if (!this._closed) this._tickFrames();
493
+ return;
494
+ case 'tray-click':
495
+ this._statusItems.get(event.id)?._emit('click', event);
496
+ return;
497
+ case 'tray-action':
498
+ this._statusItems.get(event.id)?._activate(event.text ?? '');
499
+ return;
500
+ case 'tray-ready':
501
+ case 'tray-failed':
502
+ return;
503
+ case 'gl-ready':
504
+ this._glWindows.get(event.id)?._onReady(event.a === 1, event.b);
505
+ return;
506
+ case 'file-dialog':
507
+ this.filePanels?._answer(event.id, event.a === 1, event.text ?? '');
508
+ return;
509
+ case 'hotkey':
510
+ case 'hotkey-registered':
511
+ return;
512
+ default:
513
+ break;
514
+ }
515
+
516
+ const wnd = this._windows.get(event.id);
517
+ if (!wnd) return;
518
+ switch (event.type) {
519
+ case 'window-ready':
520
+ wnd._onReady(event.a, event.b);
521
+ break;
522
+ case 'move':
523
+ wnd._noteOrigin(event.a, event.b);
524
+ break;
525
+ case 'resize': {
526
+ const width = Math.max(1, Math.round(event.a));
527
+ const height = Math.max(1, Math.round(event.b));
528
+ if (width === wnd.width && height === wnd.height) break;
529
+ wnd.width = width;
530
+ wnd.height = height;
531
+ if (wnd._composed) this._native.resize(wnd.id, width, height);
532
+ wnd.emit('resize', { width, height });
533
+ break;
534
+ }
535
+ case 'mousemove':
536
+ wnd.emit('mousemove', {
537
+ x: event.a,
538
+ y: event.b,
539
+ ...rootOf(wnd, event),
540
+ });
541
+ break;
542
+ case 'mouseout':
543
+ wnd.emit('mouseout', {});
544
+ break;
545
+ case 'wheel': {
546
+ const deltaX = event.c;
547
+ const deltaY = event.d;
548
+ wnd.emit('wheel', {
549
+ name: 'wheel',
550
+ x: event.a,
551
+ y: event.b,
552
+ rootx: event.a,
553
+ rooty: event.b,
554
+ buttons: 0,
555
+ deltaX,
556
+ deltaY,
557
+ deltaMode: 'line',
558
+ // A precision touchpad sends fractions of a notch by default and an
559
+ // addon cannot opt out of them, so a fraction is exactly the signal
560
+ // that this came from one.
561
+ smooth: !Number.isInteger(deltaX) || !Number.isInteger(deltaY),
562
+ source: 'wheel',
563
+ });
564
+ break;
565
+ }
566
+ case 'mousedown':
567
+ case 'mouseup':
568
+ // `keycode` is the button, numbered as X numbers them — the bridge
569
+ // already speaks that vocabulary. It used to be hardcoded to 1, so
570
+ // a right-click arrived as a left-click and no context menu ever
571
+ // opened.
572
+ wnd.emit(event.type, {
573
+ x: event.a,
574
+ y: event.b,
575
+ keycode: event.c || 1,
576
+ buttons: modifierMask(event.d),
577
+ ...rootOf(wnd, event),
578
+ });
579
+ // A press in one window is a press *outside* every open menu but the
580
+ // one it landed in — which on X11 the grab would have delivered to
581
+ // the menu instead of to this window.
582
+ if (event.type === 'mousedown') this._dismissOutsidePopups(wnd);
583
+ break;
584
+ case 'keydown':
585
+ case 'keyup': {
586
+ // The bridge asked the active layout what the key types before the
587
+ // modifiers were stripped from it, because that question can only be
588
+ // answered where the keyboard state is (src/win32.cc, EmitKey).
589
+ const decoded = decodeKey(event);
590
+ wnd.emit(event.type, {
591
+ keycode: event.a,
592
+ keysym: decoded.keysym,
593
+ baseKeysym: decoded.baseKeysym,
594
+ codepoint: decoded.codepoint,
595
+ buttons: modifierMask(event.d),
596
+ group: 0,
597
+ time: Date.now(),
598
+ });
599
+ break;
600
+ }
601
+ // The shell's drag, on its way through one of our windows. The
602
+ // transport answers each of these before returning — for a motion the
603
+ // answer is remembered for the next one, and for the drop the bridge's
604
+ // UI thread is waiting inside `IDropTarget::Drop` for exactly it.
605
+ case 'drag-enter':
606
+ case 'drag-over':
607
+ case 'drag-leave':
608
+ case 'drag-drop':
609
+ wnd._routeDrag(event);
610
+ return;
611
+ // Our own drag, which the shell is carrying. `DoDragDrop` reports no
612
+ // motion of its own, so these come from the source's feedback
613
+ // callback — the only news of the gesture while the shell owns the
614
+ // pointer, and what a `<popup dragPreview>` follows.
615
+ case 'drag-session-moved':
616
+ this._activeDrag?.nativeMoved(this._dragPoint(event));
617
+ return;
618
+ case 'drag-session-ended': {
619
+ const drag = this._activeDrag;
620
+ this._activeDrag = null;
621
+ drag?.nativeEnded({
622
+ ...this._dragPoint(event),
623
+ operation: event.c ? String(event.text ?? '') || null : null,
624
+ });
625
+ return;
626
+ }
627
+ // Activation, which the tree reads as focus: a caret blinks, a focus
628
+ // ring is drawn, and a `<window>`'s `focused` state follows it.
629
+ // A thumbnail toolbar button. The shell sends the index it was given;
630
+ // the caller's own id for that button is what the handler wants.
631
+ case 'thumbbutton': {
632
+ const ids = this._thumbButtons?.get(event.id) ?? [];
633
+ wnd.emit('thumbbutton', {
634
+ id: ids[event.a] ?? event.a,
635
+ index: event.a,
636
+ });
637
+ return;
638
+ }
639
+ case 'window-focus':
640
+ case 'window-blur':
641
+ wnd.emit(event.type === 'window-focus' ? 'focus' : 'blur', {
642
+ buttons: 0,
643
+ time: Date.now(),
644
+ });
645
+ // A field focused before the window got the keyboard should compose
646
+ // from the first key rather than from the first frame after it.
647
+ // Nothing is done on blur: Windows deactivates the IME for a window
648
+ // that is not in front, and a composition left open is one the user
649
+ // comes back to, which is what every other application does.
650
+ if (event.type === 'window-focus') this.inputMethod.sync(wnd);
651
+ // Losing activation is the other half of the grab this platform does
652
+ // not have: the press went to another application or to the desktop.
653
+ // A popup never takes activation (`WS_EX_NOACTIVATE`), so opening one
654
+ // raises no blur and this cannot fire on the menu's own appearance.
655
+ if (event.type === 'window-blur' && !this._dismissOnOutside.has(wnd)) {
656
+ this._dismissOutsidePopups(null);
657
+ }
658
+ return;
659
+ case 'ime-start':
660
+ case 'ime-preedit':
661
+ case 'ime-commit':
662
+ case 'ime-end':
663
+ this.inputMethod.handle(event, wnd);
664
+ return;
665
+ case 'uia-wanted':
666
+ // A screen reader just attached to this window. The tree it reads is
667
+ // built now rather than left at whatever the last commit produced.
668
+ this._a11y?.wanted(event.id);
669
+ return;
670
+ case 'uia-action':
671
+ // A screen reader asking for something to happen. A request, not a
672
+ // change: the tree decides, through the same click a pointer makes.
673
+ this._a11y?.action(event);
674
+ return;
675
+ case 'close':
676
+ // The same shape Cocoa sends. `preventDefault` is a no-op because
677
+ // nothing has happened yet to prevent: WM_CLOSE is answered with 0
678
+ // and the window is still there, so the request is the app's to
679
+ // grant. The listener calls it before deciding, and a close event
680
+ // without it throws before `onCloseRequest` is ever reached.
681
+ wnd.emit('close', { preventDefault() {} });
682
+ break;
683
+ case 'appearance': {
684
+ // Re-read whole, and the cached bezels go with it: every one of them
685
+ // was drawn in the appearance that just stopped being true.
686
+ const next = this.systemAppearance();
687
+ this._syncBezels(next?.colorScheme);
688
+ for (const fn of [...this._appearanceListeners]) {
689
+ try {
690
+ fn(next);
691
+ } catch (err) {
692
+ if (process.env.NODE_ENV !== 'production') console.error(err);
693
+ }
694
+ }
695
+ break;
696
+ }
697
+ case 'dpichanged':
698
+ // The renderer cannot re-scale a live window yet (docs/windows.md
699
+ // §Layout, open question 8), so this is recorded and not acted on.
700
+ wnd.emit('statechange', { scale: event.a / 96 });
701
+ break;
702
+ default:
703
+ break;
704
+ }
705
+ }
706
+ }
707
+
708
+ export async function createWin32App(options = {}) {
709
+ const native = loadNative();
710
+ const app = new Win32App(native, options);
711
+
712
+ native.start((event) => app._route(event));
713
+
714
+ // Before the first window, so a control's very first frame is drawn with the
715
+ // bezels it will keep rather than swapping to them a frame later.
716
+ app._syncBezels(app.systemAppearance()?.colorScheme);
717
+ // What each shell rung is actually built on. `src/capabilities.js` reports
718
+ // this as a capability's `backend`, and it has to be declared rather than
719
+ // inferred: this backend installs the same method names AppKit does -- that
720
+ // is what makes `useTray` and `useBadge` one hook each -- so a probe reading
721
+ // `createStatusItem` called Shell_NotifyIcon `cocoa` and handed out AppKit's
722
+ // feature map with it. Mechanisms, never platforms (AGENTS.md).
723
+ app.shellMechanisms = { tray: 'shellnotifyicon', launcher: 'taskbar' };
724
+ // The accessibility bridge this backend has of its own. Its presence is
725
+ // what `startA11y()` reads to know not to climb toward an AT-SPI bus that
726
+ // is not there; the import is deferred so a process with no screen reader
727
+ // never loads it (src/win32/a11y.js).
728
+ app.startAccessibility = async () => {
729
+ const { startWin32Accessibility } = await import('./a11y.js');
730
+ return startWin32Accessibility(app);
731
+ };
732
+ installTaskbar(app);
733
+ // A notification centre and the two session-wide facts — whether anybody is
734
+ // at the keyboard, and whether the screen may sleep. All three are seams the
735
+ // core looks for on the app and finds on no backend but the one it is on.
736
+ installNotifications(app);
737
+ installIdle(app);
738
+ // The three the taskbar has and no other desktop does. Installing them is
739
+ // what the launcher capability's `tasks`, `thumbnailToolbar` and
740
+ // `recentDocuments` features read, so an app asks what this desktop has
741
+ // rather than which platform it is on.
742
+ installTaskbarSurfaces(app);
743
+ // The GL ladder, which decides whether <glarea> has a rung here at all.
744
+ installGl(app);
745
+
746
+ // The ladder is otherwise climbed only when something asks — and its Windows
747
+ // rung needs an app to ask, which nothing but this has. Started here and not
748
+ // awaited: the first frame is drawn from the answer this machine gave last
749
+ // time and the live one lands behind it, which is the direction
750
+ // AGENTS.md asks appearance to settle in. A failure is the ladder's own to
751
+ // report; there is nothing useful to do with it here.
752
+ void systemAppearance({ app }).catch(() => {});
753
+
754
+ setScaleForTests(app, app.scale, 'win32');
755
+ setScreensForTests(app, {
756
+ monitors: app._screens.map((s) => ({
757
+ x: s.x ?? 0,
758
+ y: s.y ?? 0,
759
+ width: s.width,
760
+ height: s.height,
761
+ })),
762
+ });
763
+ // DWM is always compositing: there is no "no compositor" state to probe for
764
+ // the way there is on X11.
765
+ setCompositingForTests(app, true);
766
+
767
+ return app;
768
+ }