react-x11 2.6.0 → 2.7.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 (60) hide show
  1. package/README.md +5 -3
  2. package/package.json +10 -3
  3. package/src/activate.js +12 -0
  4. package/src/anchor.js +6 -0
  5. package/src/appearance.js +351 -28
  6. package/src/appearancehooks.js +5 -2
  7. package/src/application.js +41 -0
  8. package/src/cocoa/app.js +358 -20
  9. package/src/cocoa/bezels.js +51 -1
  10. package/src/cocoa/context2d.js +271 -25
  11. package/src/cocoa/dnd.js +347 -0
  12. package/src/cocoa/dock.js +39 -0
  13. package/src/cocoa/filepanels.js +155 -0
  14. package/src/cocoa/fonts.js +93 -2
  15. package/src/cocoa/globalmenu.js +41 -33
  16. package/src/cocoa/notifications.js +244 -0
  17. package/src/cocoa/permissions.js +74 -0
  18. package/src/cocoa/presenter.js +190 -2
  19. package/src/cocoa/statusitem.js +112 -0
  20. package/src/cocoa/window.js +85 -4
  21. package/src/components/Button.js +20 -1
  22. package/src/components/Checkbox.js +17 -2
  23. package/src/components/Menu.js +108 -38
  24. package/src/components/Radio.js +17 -2
  25. package/src/components/Select.js +159 -27
  26. package/src/components/Switch.js +8 -1
  27. package/src/components/native.js +99 -0
  28. package/src/components/theme.js +37 -20
  29. package/src/desktopsettings.js +34 -2
  30. package/src/dnd.js +92 -3
  31. package/src/errors.js +6 -3
  32. package/src/filedialog.js +81 -16
  33. package/src/index.d.ts +17 -1
  34. package/src/index.js +17 -0
  35. package/src/launcher.js +170 -0
  36. package/src/launcherhooks.js +81 -0
  37. package/src/nodes.js +553 -35
  38. package/src/notificationhooks.js +56 -0
  39. package/src/notifications.js +558 -0
  40. package/src/palette.js +144 -8
  41. package/src/permissionhooks.js +89 -0
  42. package/src/permissions.js +196 -0
  43. package/src/screens.js +39 -4
  44. package/src/style.d.ts +10 -4
  45. package/src/style.js +1 -0
  46. package/src/styles.js +161 -15
  47. package/src/textselection.js +1 -4
  48. package/src/trayhooks.js +90 -0
  49. package/src/types/appearance.d.ts +24 -0
  50. package/src/types/components.d.ts +10 -0
  51. package/src/types/elements.d.ts +14 -0
  52. package/src/types/events.d.ts +14 -0
  53. package/src/types/filedialog.d.ts +18 -7
  54. package/src/types/launcher.d.ts +43 -0
  55. package/src/types/notifications.d.ts +113 -0
  56. package/src/types/permissions.d.ts +100 -0
  57. package/src/types/style.d.ts +30 -2
  58. package/src/types/system.d.ts +5 -3
  59. package/src/types/tray.d.ts +54 -0
  60. package/src/windowid.js +23 -0
@@ -347,6 +347,15 @@ function environmentContext() {
347
347
  */
348
348
  let current = null;
349
349
 
350
+ /**
351
+ * The schemes the registration declared, kept beside it for the transports
352
+ * that are not the bus: `deliverOpen` filters an Apple Event's URLs by the
353
+ * same list an `Open` call is filtered by. Recorded on the no-bus path too —
354
+ * a Mac with no D-Bus still has a registration's *intent*, and that is the
355
+ * path the cocoa backend takes.
356
+ */
357
+ let currentSchemes = null;
358
+
350
359
  const openHandlers = new Set();
351
360
  const activateHandlers = new Set();
352
361
 
@@ -406,6 +415,36 @@ function drainTo(kind, handler) {
406
415
  }
407
416
  }
408
417
 
418
+ // --------------------------------------------------------------------------
419
+ // The other transports
420
+ // --------------------------------------------------------------------------
421
+
422
+ /**
423
+ * A launch that arrived over a transport other than the bus: the cocoa
424
+ * backend's Apple Events (src/cocoa/app.js), where the OS hands the app its
425
+ * URLs through `application:openURLs:` and a Dock click on a running app
426
+ * through `applicationShouldHandleReopen:`. Same scheme filter, same buffer,
427
+ * same replay to the first handler — one code path in the app whichever
428
+ * desktop launched it.
429
+ *
430
+ * Not public: a transport calls this, an app subscribes with `onAppOpen`.
431
+ * `platformData` is whatever that transport knows about the launch. Apple
432
+ * Events carry no startup id and no timestamp, and the context says so with
433
+ * nulls rather than inventing them — on that backend the raise needs neither
434
+ * (`activateWindow` is `NSApp.activate` there).
435
+ */
436
+ export function deliverOpen(uris, platformData = {}) {
437
+ const accepted = acceptUris(uris, currentSchemes);
438
+ if (accepted.length === 0) return;
439
+ debug(`open from the platform: ${accepted.map(redactUri).join(', ')}`);
440
+ deliver({ kind: 'open', uris: accepted, ctx: launchContext(platformData) });
441
+ }
442
+
443
+ /** The platform asked this app to come forward with nothing to open. */
444
+ export function deliverActivate(platformData = {}) {
445
+ deliver({ kind: 'activate', ctx: launchContext(platformData) });
446
+ }
447
+
409
448
  /**
410
449
  * Be told when the desktop hands this app URIs to open.
411
450
  *
@@ -541,6 +580,7 @@ export async function registerApplication(options = {}) {
541
580
  const appId = checkAppId(options.appId);
542
581
  const schemes = checkSchemes(options.schemes);
543
582
  const objectPath = objectPathForAppId(appId);
583
+ currentSchemes = schemes;
544
584
 
545
585
  if (current) {
546
586
  // An app has one identity; two registrations would be two RequestNames and
@@ -742,6 +782,7 @@ export function currentRegistration() {
742
782
  /** Test seam, not public: forget every handler, buffer and registration. */
743
783
  export function _resetApplicationState() {
744
784
  current = null;
785
+ currentSchemes = null;
745
786
  openHandlers.clear();
746
787
  activateHandlers.clear();
747
788
  buffered = [];
package/src/cocoa/app.js CHANGED
@@ -14,15 +14,21 @@
14
14
  // measured upgrade, not the first version.
15
15
  import { cssColorStraight } from 'ntk';
16
16
 
17
+ import { deliverActivate, deliverOpen } from '../application.js';
17
18
  import { flushPendingFrames } from '../frames.js';
18
19
  import { setCompositingForTests } from '../compositing.js';
19
20
  import { setScreensForTests } from '../screens.js';
20
21
  import { setScaleForTests } from '../scale.js';
21
22
  import { BezelStore } from './bezels.js';
22
23
  import { CocoaGLArea, cocoaGLConfig, resolveCocoaGLRuntime } from './glarea.js';
24
+ import { CocoaDockMenu } from './dock.js';
23
25
  import { CocoaGlobalMenuExport } from './globalmenu.js';
26
+ import { CocoaStatusItem } from './statusitem.js';
27
+ import { CocoaNotifications } from './notifications.js';
24
28
  import { CocoaPaneHost } from './panehost.js';
29
+ import { CocoaPermissions } from './permissions.js';
25
30
  import { CocoaPaneWindow } from './panewindow.js';
31
+ import { CocoaFilePanels } from './filepanels.js';
26
32
  import { CocoaFontManager } from './fonts.js';
27
33
  import { CocoaSurface } from './surface.js';
28
34
  import { CocoaWindow } from './window.js';
@@ -55,6 +61,12 @@ export class CocoaApp {
55
61
  // window paces itself on its own display (`frameIntervalFor`)
56
62
  this._frameInterval = options.cocoa?.frameInterval ?? null;
57
63
  this._pumpInterval = PUMP_INTERVAL_MS;
64
+ // src/desktopsettings.js's subscribers to the accessibility display
65
+ // options — "reduce motion" — which arrive as a backend event
66
+ this._a11yListeners = new Set();
67
+ // the layer presenter's animations, by the id the bridge reports their
68
+ // end under (`animation-end`, src/cocoa/presenter.js)
69
+ this._animationEnds = new Map();
58
70
  // the one-shot that runs a frame due between two pump ticks, and when
59
71
  // it is due (`_armFrameTimer`)
60
72
  this._frameTimer = null;
@@ -63,11 +75,43 @@ export class CocoaApp {
63
75
  this._shadowStale = new Set();
64
76
  this._pump = null;
65
77
  this._closed = false;
78
+ // the DragSession whose gesture an NSDraggingSession is tracking; a drop
79
+ // on one of our own windows is routed to its live payload
80
+ this._activeDrag = null;
81
+ // set by a quit request; read by close() to end the process
82
+ this._quitting = false;
83
+
84
+ // The activation policy has to be fixed before the app finishes
85
+ // launching — a Regular launch registers a Dock tile, so an agent app
86
+ // that switched afterwards would already have flashed its icon — and
87
+ // `listScreens` below is the first native call that launches it. So the
88
+ // policy goes first, through `initApp`'s option (bridge >= 0.5), and
89
+ // only when the root asked for one: a bridge without the option, or a
90
+ // fake, is left alone.
91
+ const policy = options.cocoa?.activationPolicy;
92
+ if (policy != null && typeof native.initApp === 'function') {
93
+ native.initApp({ activationPolicy: policy });
94
+ }
66
95
 
67
96
  const screens = native.listScreens();
68
97
  this.scale = screens[0]?.scale ?? 1;
69
98
  this._screens = screens;
70
99
 
100
+ // The name the Dock, ⌘-Tab and the menu bar print. An unbundled
101
+ // process is registered with LaunchServices under its executable —
102
+ // `node` — and this renames that record; a bundle's Info.plist wins
103
+ // (the bridge answers false and leaves it).
104
+ const appName = options.cocoa?.appName;
105
+ if (appName != null && typeof native.setAppName === 'function') {
106
+ native.setAppName(String(appName));
107
+ }
108
+
109
+ // The Dock menu (src/cocoa/dock.js), installed by `useDockMenu`.
110
+ this._dockMenu = new CocoaDockMenu(this);
111
+ // The tray items (src/cocoa/statusitem.js), by the bridge's handle —
112
+ // which is what a click event names them by.
113
+ this._statusItems = new Map();
114
+
71
115
  // 'surface' (the measured default) or 'layers' — the retained CALayer
72
116
  // presenter, opt-in while docs/macos.md's measure-first gate is open.
73
117
  this._presenterMode =
@@ -80,6 +124,16 @@ export class CocoaApp {
80
124
  // it is built standalone
81
125
  this.fonts = new CocoaFontManager(native);
82
126
 
127
+ // Native open/save panels (src/cocoa/filepanels.js). Present exactly
128
+ // when the bridge has them (>= 0.5), and its presence is what puts the
129
+ // top rung on src/filedialog.js's ladder for this app — a fake bridge
130
+ // without `openPanel`, or an older one, leaves the ladder as it was.
131
+ this.filePanels =
132
+ typeof native.openPanel === 'function' &&
133
+ typeof native.savePanel === 'function'
134
+ ? new CocoaFilePanels(this)
135
+ : null;
136
+
83
137
  // AppKit-rendered control bezels. Its *presence* is the capability:
84
138
  // `useSupports('nativeControls')` and the widget set's `controls:
85
139
  // 'auto'` policy both test for this property, so a backend without it
@@ -87,6 +141,24 @@ export class CocoaApp {
87
141
  // branching.
88
142
  this.nativeBezels = new BezelStore(native);
89
143
 
144
+ // macOS privacy authorizations (src/cocoa/permissions.js). Present
145
+ // exactly when the bridge has them (>= 0.5), and its presence is the
146
+ // rung src/permissions.js finds for this app.
147
+ this.permissions =
148
+ typeof native.authorizationStatus === 'function' &&
149
+ typeof native.requestAuthorization === 'function'
150
+ ? new CocoaPermissions(native)
151
+ : null;
152
+
153
+ // The notification centre (src/cocoa/notifications.js). Present exactly
154
+ // when the bridge has it (>= 0.5); whether it can *deliver* — a bundle
155
+ // id — is its own `available()`, the second gate the ladder asks.
156
+ this.notifications =
157
+ typeof native.postNotification === 'function' &&
158
+ typeof native.notificationSettings === 'function'
159
+ ? new CocoaNotifications(this)
160
+ : null;
161
+
90
162
  // The GL policy, glbackend.js's shape. No GLX exists here, so the
91
163
  // default is 'auto' (the direct backend where the runtime loads);
92
164
  // useSupports('shaders') stays false until the first <glarea> resolves
@@ -293,6 +365,87 @@ export class CocoaApp {
293
365
  return fps > 0 ? 1000 / fps : RAF_INTERVAL_MS;
294
366
  }
295
367
 
368
+ /**
369
+ * Bring this app and one of its windows to the front — the cocoa answer to
370
+ * `activateWindow()` (src/activate.js), the raise a deep link's
371
+ * `useAppOpen`/`useAppActivate` handler performs.
372
+ *
373
+ * There is no window manager here and no `_NET_ACTIVE_WINDOW` to send:
374
+ * `activateApp()` is `NSApp.activate` and `showWindow(h, true)` re-issues
375
+ * `makeKeyAndOrderFront:`, which is the whole of a raise on this backend.
376
+ * The X11 path weighs a launch timestamp against focus-stealing
377
+ * prevention; macOS has no such negotiation from a client, so the
378
+ * timestamp is not a parameter here.
379
+ *
380
+ * `target` is whatever `activate.js` resolved — a `CocoaWindow`, the
381
+ * `WindowNode` that owns one, or nothing when there is no window to raise.
382
+ * Returns whether a window was actually raised, matching the X path's
383
+ * "was the request issued" contract (and so *not* the false success the
384
+ * no-op `X.SendClientMessage` stub would report).
385
+ */
386
+ raiseWindow(target) {
387
+ if (this._closed) return false;
388
+ const wnd = target && target._h != null ? target : (target?.window ?? null);
389
+ if (!wnd || wnd.destroyed || wnd._h == null) return false;
390
+ this._native.activateApp();
391
+ this._native.showWindow(wnd._h, true);
392
+ return true;
393
+ }
394
+
395
+ // --- the Dock tile ---------------------------------------------------------
396
+
397
+ /**
398
+ * The badge on the Dock tile — `setBadge()`'s first rung (src/launcher.js),
399
+ * which finds it by this method's presence. `null` clears.
400
+ */
401
+ setDockBadge(label) {
402
+ this._native.setDockBadge(label == null ? null : String(label));
403
+ }
404
+
405
+ /** The menu behind a right-click on the Dock icon — `useDockMenu()`. */
406
+ setDockMenu(items) {
407
+ this._dockMenu.update(items);
408
+ }
409
+
410
+ /**
411
+ * Bounce the Dock icon until the app is activated — the cocoa answer to a
412
+ * window's `demands_attention` state (src/cocoa/window.js `setWmState`).
413
+ * AppKit ignores the request while the app is already active, which is
414
+ * the same outcome a window manager gives a focused window's urgency.
415
+ */
416
+ requestAttention() {
417
+ return this._native.requestUserAttention('critical');
418
+ }
419
+
420
+ cancelAttention(requestId) {
421
+ this._native.cancelUserAttention(requestId);
422
+ }
423
+
424
+ // --- the tray --------------------------------------------------------------
425
+
426
+ /**
427
+ * An `NSStatusItem` — `useTray()`'s mechanism, found by this method's
428
+ * presence (the X11 backend has none, and the hook says so). Returns the
429
+ * item; `remove()` on it is the whole of its teardown.
430
+ */
431
+ createStatusItem(options) {
432
+ const item = new CocoaStatusItem(this, options);
433
+ this._statusItems.set(item.handle, item);
434
+ const remove = item.remove.bind(item);
435
+ item.remove = () => {
436
+ this._statusItems.delete(item.handle);
437
+ remove();
438
+ };
439
+ return item;
440
+ }
441
+
442
+ /** A `menu-activate` tagged `status`: the item whose menu owns the id. */
443
+ _routeStatusMenu(id) {
444
+ for (const item of this._statusItems.values()) {
445
+ if (item.owns(id)) return item.activate(id);
446
+ }
447
+ }
448
+
296
449
  /**
297
450
  * The pane's end of the frame channel (childmain hands it over,
298
451
  * feature-detected so the X11 pane path never notices): geometry and
@@ -523,8 +676,45 @@ export class CocoaApp {
523
676
  case 'window-occlusion':
524
677
  return this._routeOcclusion(ev);
525
678
  case 'menu-activate':
526
- this._activeGlobalMenu?.activate(ev.id);
679
+ // `menu` says which tree the id belongs to (bridge >= 0.5): the
680
+ // Dock menu and the menu bar allocate ids independently. Absent on
681
+ // an older bridge, where only the bar exists.
682
+ if (ev.menu === 'dock') this._dockMenu.activate(ev.id);
683
+ else if (ev.menu === 'status') this._routeStatusMenu(ev.id);
684
+ else this._activeGlobalMenu?.activate(ev.id);
685
+ return this._afterInput();
686
+ case 'status-item-click':
687
+ this._statusItems.get(ev.statusItem)?.click(ev);
527
688
  return this._afterInput();
689
+ case 'accessibility-display-changed':
690
+ return this._routeAccessibility(ev);
691
+ case 'drag-enter':
692
+ case 'drag-over':
693
+ case 'drag-exit':
694
+ case 'drag-perform':
695
+ return this._routeDrop(ev);
696
+ case 'drag-session-began':
697
+ return undefined;
698
+ case 'drag-session-moved':
699
+ this._activeDrag?.nativeMoved(ev);
700
+ return undefined;
701
+ case 'drag-session-ended':
702
+ return this._routeDragEnded(ev);
703
+ case 'app-open-urls':
704
+ return this._routeAppOpen(ev);
705
+ case 'app-reopen':
706
+ return this._routeAppReopen(ev);
707
+ case 'app-quit-request':
708
+ return this.requestQuit();
709
+ case 'notification-action':
710
+ case 'notification-dismissed':
711
+ this.notifications?.route(ev);
712
+ return this._afterInput();
713
+ case 'animation-end':
714
+ // the presenter that added the animation registered for its id; an
715
+ // id nobody knows is an animation already forgotten (cancelled, or
716
+ // its layer dropped), and the bridge's report is just late
717
+ return this._animationEnds.get(ev.id)?.(ev);
528
718
  default:
529
719
  return undefined;
530
720
  }
@@ -689,6 +879,48 @@ export class CocoaApp {
689
879
  wnd._occluded = ev.visible === false;
690
880
  }
691
881
 
882
+ _routeAccessibility(ev) {
883
+ for (const fn of [...this._a11yListeners]) fn(ev);
884
+ }
885
+
886
+ /**
887
+ * System Settings › Accessibility › Display, as the bridge reports it —
888
+ * `{ reduceMotion, reduceTransparency, increaseContrast,
889
+ * differentiateWithoutColor, invertColors }` — or null over a bridge (or
890
+ * a test fake) that does not answer. The seam src/desktopsettings.js reads
891
+ * "reduce motion" through; `watchAccessibilityDisplay` is its other half.
892
+ */
893
+ accessibilityDisplayOptions() {
894
+ return this._native.accessibilityDisplayOptions?.() ?? null;
895
+ }
896
+
897
+ /** Subscribe to the options changing while the app runs. */
898
+ watchAccessibilityDisplay(fn) {
899
+ this._a11yListeners.add(fn);
900
+ return () => this._a11yListeners.delete(fn);
901
+ }
902
+
903
+ /**
904
+ * AppKit's four destination questions, answered inside the callback: the
905
+ * window's transport (src/cocoa/dnd.js) drives its DropSession and sets
906
+ * the response before this returns. A frame is flushed on the way out,
907
+ * like a click's — `:drag-over` is a repaint.
908
+ */
909
+ _routeDrop(ev) {
910
+ const wnd = this._window(ev);
911
+ if (!wnd || wnd.destroyed) return;
912
+ wnd._dropTransport?.handle(ev);
913
+ this._afterInput();
914
+ }
915
+
916
+ /** The release of a drag AppKit was tracking for us. */
917
+ _routeDragEnded(ev) {
918
+ const drag = this._activeDrag;
919
+ this._activeDrag = null;
920
+ drag?.nativeEnded(ev);
921
+ this._afterInput();
922
+ }
923
+
692
924
  _routeClose(ev) {
693
925
  const wnd = this._window(ev);
694
926
  if (!wnd) return;
@@ -706,6 +938,82 @@ export class CocoaApp {
706
938
  this._afterInput();
707
939
  }
708
940
 
941
+ // --- the application as a whole ------------------------------------------
942
+ //
943
+ // What the OS asks the app rather than one of its windows, through the
944
+ // NSApplicationDelegate the bridge installs (>= 0.5). The bridge decides
945
+ // nothing — it answers `applicationShouldHandleReopen:` NO and
946
+ // `applicationShouldTerminate:` Cancel and hands the question over — so
947
+ // each of these is the renderer's decision, and the decisions are the ones
948
+ // the freedesktop transport already made (src/application.js): a URL is an
949
+ // `Open`, a second launch is an `Activate`, and quitting is what closing
950
+ // the app's primary window means. Anything that arrived before the
951
+ // callback was installed — the launching Apple Event lands inside
952
+ // `initApp()` — the bridge holds and replays on the first pump.
953
+
954
+ /**
955
+ * `application:openURLs:` — a URL for a scheme the bundle's `Info.plist`
956
+ * registers, or a document the Finder handed over as `file://`. Delivered
957
+ * through the same filter, buffer and replay a D-Bus `Open` takes, so
958
+ * `useAppOpen` fires the same way here.
959
+ */
960
+ _routeAppOpen(ev) {
961
+ const urls = Array.isArray(ev.urls)
962
+ ? ev.urls.filter((u) => typeof u === 'string')
963
+ : [];
964
+ deliverOpen(urls, {});
965
+ }
966
+
967
+ /**
968
+ * `applicationShouldHandleReopen:` — the user launched the app again while
969
+ * it was running (a Dock click, `open -a`). AppKit has already brought
970
+ * the windows forward by the time this arrives; what a second launch
971
+ * *means* is the app's, through `useAppActivate`, and `has-visible-windows`
972
+ * on the context is the one fact the OS adds.
973
+ */
974
+ _routeAppReopen(ev) {
975
+ deliverActivate({ 'has-visible-windows': ev.hasVisibleWindows === true });
976
+ }
977
+
978
+ /**
979
+ * Quit, from wherever macOS says it: the Dock's Quit, ⌘Q on the app menu, a
980
+ * logout. One route for all of them, and it is the **primary window's
981
+ * close request** — `onCloseRequest` where the app wrote one (a "save your
982
+ * work?" dialog is a veto until it decides), the default otherwise, which
983
+ * unmounts the tree. Quit means what closing the app's window means on
984
+ * every desktop, rather than a second, faster exit that skips the same
985
+ * question.
986
+ *
987
+ * With no window to ask — an accessory app between windows — the tree
988
+ * unmounts directly.
989
+ *
990
+ * The bridge has already answered `applicationShouldTerminate:` with
991
+ * Cancel, so the process outliving the tree is this side's to end: when a
992
+ * quit request leads to the app closing, `close()` exits the process
993
+ * (`cocoa.exitOnQuit`, on by default — a Regular-policy process that is
994
+ * still alive is still in the Dock, and a Dock tile for an app whose
995
+ * windows are all gone is what a user calls a hang).
996
+ */
997
+ requestQuit() {
998
+ if (this._closed) return;
999
+ this._quitting = true;
1000
+ const windows = [...this._windows.values()].filter(
1001
+ (w) => !w.destroyed && !w._popup,
1002
+ );
1003
+ const primary =
1004
+ windows.find((w) => w._reactX11Node?._isPrimaryWindow?.()) ??
1005
+ windows[0] ??
1006
+ null;
1007
+ if (primary) {
1008
+ primary.emit('close', { preventDefault() {} });
1009
+ } else {
1010
+ Promise.resolve(this._reactX11Root?.unmount?.()).catch((err) => {
1011
+ this.options?.onXError?.(err);
1012
+ });
1013
+ }
1014
+ this._afterInput();
1015
+ }
1016
+
709
1017
  // --- teardown ------------------------------------------------------------
710
1018
 
711
1019
  close() {
@@ -719,10 +1027,58 @@ export class CocoaApp {
719
1027
  this._cocoaGL = null;
720
1028
  this._native.setBackendEventCallback(null);
721
1029
  for (const wnd of [...this._windows.values()]) wnd.destroy();
1030
+ // A quit the app accepted: the tree is down and the connection closed,
1031
+ // and macOS is still waiting on the Cancel the bridge answered with. End
1032
+ // the process on the next turn — after the unmount's own microtasks —
1033
+ // rather than leaving a Dock tile with nothing behind it. `exitOnQuit:
1034
+ // false` is the seam for an embedder that owns the process's lifetime.
1035
+ if (this._quitting && this.options?.cocoa?.exitOnQuit !== false) {
1036
+ setImmediate(() => process.exit(0));
1037
+ }
722
1038
  return Promise.resolve();
723
1039
  }
724
1040
  }
725
1041
 
1042
+ /**
1043
+ * `listScreens()` turned into the screen layout `src/screens.js` publishes.
1044
+ *
1045
+ * **One scale for every screen, and it is the app's.** macOS lays all the
1046
+ * displays out in a single global point space, and `app.scale` is this
1047
+ * app's points-to-device-pixels factor for the whole of it — window
1048
+ * origins, event coordinates, these rects. Converting a 1x external
1049
+ * display by *its own* 1 while windows on it still report `points * 2`
1050
+ * would put the monitor somewhere no window ever is, and `monitorAt()`
1051
+ * would answer with the wrong head. (What backing scale a window on a
1052
+ * mixed-DPI desk should raster at is a real and separate question; the
1053
+ * layout is not where it is answered.)
1054
+ *
1055
+ * **A usable rect per monitor.** `NSScreen.visibleFrame` is per screen —
1056
+ * that display's own menu bar and Dock taken off — so each monitor carries
1057
+ * its `visible` and `usable()` takes it as a rect. Publishing only the
1058
+ * primary's, the way `_NET_WORKAREA` forces on X11, applied the primary's
1059
+ * *width* as a bound to every other head: a second display wider than the
1060
+ * built-in had its right edge pulled in by the difference, and every
1061
+ * anchored popup that reached past it was clamped back (issue #453).
1062
+ */
1063
+ export function screenLayout(screens, scale) {
1064
+ const rect = (r) => ({
1065
+ x: Math.round(r.x * scale),
1066
+ y: Math.round(r.y * scale),
1067
+ width: Math.round(r.width * scale),
1068
+ height: Math.round(r.height * scale),
1069
+ });
1070
+ const primary = screens?.[0];
1071
+ return {
1072
+ monitors: (screens ?? []).map((screen) => ({
1073
+ ...rect(screen),
1074
+ ...(screen.visible ? { visible: rect(screen.visible) } : null),
1075
+ })),
1076
+ // Still published for `useScreens().workArea`, which is one rect for
1077
+ // the desktop by definition; the primary's is the closest macOS has.
1078
+ workArea: primary?.visible ? rect(primary.visible) : null,
1079
+ };
1080
+ }
1081
+
726
1082
  /**
727
1083
  * Build the app and seed the platform stores the way the mock seeds them —
728
1084
  * `beginScale`/`beginScreens`/`beginCompositing` find a session already
@@ -733,25 +1089,7 @@ export async function createCocoaApp(options = {}) {
733
1089
  const app = new CocoaApp(native, options);
734
1090
 
735
1091
  setScaleForTests(app, app.scale, 'cocoa');
736
- const s = app.scale;
737
- const monitors = app._screens.map((screen) => ({
738
- x: Math.round(screen.x * s),
739
- y: Math.round(screen.y * s),
740
- width: Math.round(screen.width * s),
741
- height: Math.round(screen.height * s),
742
- }));
743
- const primary = app._screens[0];
744
- setScreensForTests(app, {
745
- monitors,
746
- workArea: primary
747
- ? {
748
- x: Math.round(primary.visible.x * s),
749
- y: Math.round(primary.visible.y * s),
750
- width: Math.round(primary.visible.width * s),
751
- height: Math.round(primary.visible.height * s),
752
- }
753
- : null,
754
- });
1092
+ setScreensForTests(app, screenLayout(app._screens, app.scale));
755
1093
  setCompositingForTests(app, true);
756
1094
 
757
1095
  app.start(options.cocoa ?? {});
@@ -26,6 +26,22 @@ export class BezelStore {
26
26
  this._MAX = 160;
27
27
  }
28
28
 
29
+ /**
30
+ * Forget every rendered bezel. The pixels depend on one thing that is not
31
+ * a parameter of `get`: the desktop's accent, which AppKit reads for
32
+ * itself when it draws the cell. After the user picks another accent a
33
+ * cached bezel is the old colour, and its key still matches — so a bezel
34
+ * whose state happened to change came up in the new accent while the
35
+ * ones beside it kept the old, until something else redrew them. The
36
+ * appearance change forgets them all; the next paint renders each again.
37
+ *
38
+ * The measured insets and natural sizes stay: geometry is not coloured.
39
+ */
40
+ clear() {
41
+ // the surfaces are freed by their External finalizer
42
+ this._cache.clear();
43
+ }
44
+
29
45
  /**
30
46
  * The control's natural size in logical px — the size the bezel is
31
47
  * designed at, which layout adopts for the kinds that must not stretch
@@ -40,6 +56,21 @@ export class BezelStore {
40
56
  };
41
57
  }
42
58
 
59
+ /**
60
+ * The translucent rows above and below the bezel's solid body, in logical
61
+ * px — a push button's drop shadow, mostly. They are part of the natural
62
+ * box (`natural` measures every inked pixel, so the box is the footprint)
63
+ * and not part of the control: AppKit centres a title in the cell's body,
64
+ * and a label centred in the footprint sits half the shadow too low.
65
+ */
66
+ shadow(kind, controlSize = 'regular') {
67
+ const c = this._scan(kind, controlSize, 2);
68
+ return {
69
+ top: Math.round(c.body.top),
70
+ bottom: Math.round(c.body.bottom),
71
+ };
72
+ }
73
+
43
74
  /**
44
75
  * The bezel for one laid-out box: `w`/`h` in device px, blit-ready.
45
76
  * Returns `{ surface, sx, sy, sw, sh }` — draw with the 9-arg
@@ -126,14 +157,23 @@ export class BezelStore {
126
157
  let y0 = ph;
127
158
  let x1 = -1;
128
159
  let y1 = -1;
160
+ // and the solid body inside the footprint: where the bezel is opaque,
161
+ // which is the control itself rather than its shadow
162
+ let by0 = ph;
163
+ let by1 = -1;
129
164
  for (let y = 0; y < ph; y++) {
130
165
  for (let x = 0; x < pw; x++) {
131
- if (buf[(y * pw + x) * 4 + 3] > 8) {
166
+ const alpha = buf[(y * pw + x) * 4 + 3];
167
+ if (alpha > 8) {
132
168
  if (x < x0) x0 = x;
133
169
  if (x > x1) x1 = x;
134
170
  if (y < y0) y0 = y;
135
171
  if (y > y1) y1 = y;
136
172
  }
173
+ if (alpha >= 250) {
174
+ if (y < by0) by0 = y;
175
+ if (y > by1) by1 = y;
176
+ }
137
177
  }
138
178
  }
139
179
  if (x1 < 0) {
@@ -143,6 +183,12 @@ export class BezelStore {
143
183
  x1 = pw - 1;
144
184
  y1 = ph - 1;
145
185
  }
186
+ if (by1 < 0) {
187
+ // no solid pixel at all (a fully translucent bezel): the body is the
188
+ // footprint
189
+ by0 = y0;
190
+ by1 = y1;
191
+ }
146
192
  c = {
147
193
  insets: {
148
194
  left: x0 / scale,
@@ -154,6 +200,10 @@ export class BezelStore {
154
200
  width: (x1 - x0 + 1) / scale,
155
201
  height: (y1 - y0 + 1) / scale,
156
202
  },
203
+ body: {
204
+ top: (by0 - y0) / scale,
205
+ bottom: (y1 - by1) / scale,
206
+ },
157
207
  };
158
208
  this._canonical.set(key, c);
159
209
  return c;