react-x11 2.6.1 → 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 (58) 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 +317 -1
  9. package/src/cocoa/bezels.js +51 -1
  10. package/src/cocoa/dnd.js +347 -0
  11. package/src/cocoa/dock.js +39 -0
  12. package/src/cocoa/filepanels.js +155 -0
  13. package/src/cocoa/fonts.js +93 -2
  14. package/src/cocoa/globalmenu.js +41 -33
  15. package/src/cocoa/notifications.js +244 -0
  16. package/src/cocoa/permissions.js +74 -0
  17. package/src/cocoa/presenter.js +190 -2
  18. package/src/cocoa/statusitem.js +112 -0
  19. package/src/cocoa/window.js +85 -4
  20. package/src/components/Button.js +20 -1
  21. package/src/components/Checkbox.js +17 -2
  22. package/src/components/Menu.js +108 -38
  23. package/src/components/Radio.js +17 -2
  24. package/src/components/Select.js +159 -27
  25. package/src/components/Switch.js +8 -1
  26. package/src/components/native.js +99 -0
  27. package/src/components/theme.js +37 -20
  28. package/src/desktopsettings.js +34 -2
  29. package/src/dnd.js +92 -3
  30. package/src/errors.js +6 -3
  31. package/src/filedialog.js +81 -16
  32. package/src/index.d.ts +17 -1
  33. package/src/index.js +17 -0
  34. package/src/launcher.js +170 -0
  35. package/src/launcherhooks.js +81 -0
  36. package/src/nodes.js +553 -35
  37. package/src/notificationhooks.js +56 -0
  38. package/src/notifications.js +558 -0
  39. package/src/palette.js +144 -8
  40. package/src/permissionhooks.js +89 -0
  41. package/src/permissions.js +196 -0
  42. package/src/style.d.ts +10 -4
  43. package/src/style.js +1 -0
  44. package/src/styles.js +161 -15
  45. package/src/textselection.js +1 -4
  46. package/src/trayhooks.js +90 -0
  47. package/src/types/appearance.d.ts +24 -0
  48. package/src/types/components.d.ts +10 -0
  49. package/src/types/elements.d.ts +14 -0
  50. package/src/types/events.d.ts +14 -0
  51. package/src/types/filedialog.d.ts +18 -7
  52. package/src/types/launcher.d.ts +43 -0
  53. package/src/types/notifications.d.ts +113 -0
  54. package/src/types/permissions.d.ts +100 -0
  55. package/src/types/style.d.ts +30 -2
  56. package/src/types/system.d.ts +5 -3
  57. package/src/types/tray.d.ts +54 -0
  58. package/src/windowid.js +23 -0
@@ -0,0 +1,112 @@
1
+ // The tray on the cocoa backend — `NSStatusItem`, the menu-bar extra, through
2
+ // @windowkit/appkit (>= 0.5). The macOS half of the tray react-x11#353 asks
3
+ // for; `useTray` (src/trayhooks.js) is the API over it.
4
+ //
5
+ // An item shows an image (an SF Symbol by name, or PNG bytes drawn as a
6
+ // template so they follow the bar's light and dark) and/or a title, with a
7
+ // tooltip, and either owns a **menu** — the same `items` vocabulary the
8
+ // menu bar and the Dock menu take, through the one spec builder — or
9
+ // reports **clicks**, each carrying the item's screen rect, which is the
10
+ // anchor for a popup of the app's own.
11
+ //
12
+ // Two facts about the bridge shape the routing here:
13
+ //
14
+ // - A click names its item: the event's `statusItem` is the very handle
15
+ // `createStatusItem` returned, so the app keeps a Map keyed on it.
16
+ // - A menu activation does **not** — `menu-activate` says `menu: 'status'`
17
+ // and an id, and every item's menu allocates ids from its own snapshot. So
18
+ // each item's allocator starts in a stride of its own (`ID_STRIDE`), the
19
+ // ids of two trays' menus are disjoint by construction, and the app asks
20
+ // each item whether the id is one of its own.
21
+ import { IdAllocator, snapshot } from '../dbusmenu.js';
22
+ import { menuItemsSpec } from './globalmenu.js';
23
+
24
+ /** How far apart two items' id ranges start. A menu with a million rows is
25
+ * not a menu; the stride is the size of a number nobody reaches. */
26
+ const ID_STRIDE = 1_000_000;
27
+ let nextStride = 1;
28
+
29
+ /** react-x11's tray options as the bridge's item spec, keys it knows only. */
30
+ export function statusItemSpec(options = {}) {
31
+ const spec = {};
32
+ if (options.icon !== undefined) {
33
+ // an SF Symbol by name, or the encoded bytes of an image; null clears
34
+ spec.image = options.icon ?? null;
35
+ }
36
+ if (options.title !== undefined) spec.title = options.title ?? '';
37
+ if (options.tooltip !== undefined) spec.tooltip = options.tooltip ?? '';
38
+ if (options.visible !== undefined) spec.visible = options.visible !== false;
39
+ if (options.template !== undefined) spec.imageTemplate = options.template;
40
+ if (options.length !== undefined) spec.length = options.length;
41
+ if (Array.isArray(options.iconSize)) spec.imageSize = options.iconSize;
42
+ return spec;
43
+ }
44
+
45
+ export class CocoaStatusItem {
46
+ constructor(app, options = {}) {
47
+ this.app = app;
48
+ this.alloc = new IdAllocator();
49
+ this.alloc.next = nextStride++ * ID_STRIDE + 1;
50
+ this.nodes = null;
51
+ this.removed = false;
52
+ this.onClick = options.onClick ?? null;
53
+ this.handle = app._native.createStatusItem(statusItemSpec(options));
54
+ this.setMenu(options.menu ?? null);
55
+ }
56
+
57
+ /** In-place patch: only the keys present are touched. */
58
+ update(options = {}) {
59
+ if (this.removed) return;
60
+ this.onClick = options.onClick ?? null;
61
+ const spec = statusItemSpec(options);
62
+ if (Object.keys(spec).length) {
63
+ this.app._native.setStatusItem(this.handle, spec);
64
+ }
65
+ this.setMenu(options.menu ?? null);
66
+ }
67
+
68
+ /** `items`, or null to go back to reporting clicks. */
69
+ setMenu(items) {
70
+ if (this.removed) return;
71
+ if (!items) {
72
+ this.nodes = null;
73
+ this.app._native.setStatusItemMenu(this.handle, null);
74
+ return;
75
+ }
76
+ this.nodes = snapshot(items, this.alloc);
77
+ this.app._native.setStatusItemMenu(this.handle, menuItemsSpec(this.nodes));
78
+ }
79
+
80
+ /** Is this `menu-activate` id one of this item's menu's? */
81
+ owns(id) {
82
+ return Boolean(this.nodes?.has(id));
83
+ }
84
+
85
+ activate(id) {
86
+ const item = this.nodes?.get(id)?.item;
87
+ item?.onSelect?.(item);
88
+ }
89
+
90
+ /** A `status-item-click` on this item, as the app's event. */
91
+ click(ev) {
92
+ this.onClick?.({
93
+ button: ev.kind,
94
+ x: ev.x,
95
+ y: ev.y,
96
+ width: ev.width,
97
+ height: ev.height,
98
+ clickCount: ev.clickCount ?? 1,
99
+ shift: Boolean(ev.shift),
100
+ control: Boolean(ev.control),
101
+ option: Boolean(ev.option),
102
+ command: Boolean(ev.command),
103
+ });
104
+ }
105
+
106
+ remove() {
107
+ if (this.removed) return;
108
+ this.removed = true;
109
+ this.nodes = null;
110
+ this.app._native.removeStatusItem(this.handle);
111
+ }
112
+ }
@@ -7,6 +7,7 @@
7
7
  // _screenOrigin. The divide-by-scale into Cocoa points happens against the
8
8
  // native layer and nowhere above it.
9
9
  import { CocoaContext2D } from './context2d.js';
10
+ import { CocoaDropTransport, dragSpec } from './dnd.js';
10
11
  import { CocoaLayerPresenter } from './presenter.js';
11
12
 
12
13
  let nextWindowId = 1;
@@ -93,16 +94,22 @@ export class CocoaWindow {
93
94
 
94
95
  // The retained layer presenter (docs/macos.md Tier L), behind
95
96
  // REACT_X11_COCOA_PRESENTER=layers while the surface path is the
96
- // measured default. Its two hooks exist only in this mode, so the
97
- // feature detection in nodes.js keeps the surface path byte-identical;
98
- // the scroll blit is shadowed off because a layer frame has no backing
99
- // bitmap to blit.
97
+ // measured default. Its hooks exist only in this mode, so the feature
98
+ // detection in nodes.js keeps the surface path byte-identical; the
99
+ // scroll blit is shadowed off because a layer frame has no backing
100
+ // bitmap to blit. The last two are the animation seam: a transition or
101
+ // a loop the presenter takes runs in the render server and schedules no
102
+ // frames here (docs/architecture/animation.md §4).
100
103
  if (app._presenterMode === 'layers') {
101
104
  this._presenter = new CocoaLayerPresenter(this);
102
105
  this.presentFrame = (windowNode) => this._presenter.frame(windowNode);
103
106
  this.noteInvalidate = (damage, layoutChanged) =>
104
107
  this._presenter.noteInvalidate(damage, layoutChanged);
105
108
  this.scrollRegion = null;
109
+ this.animateNode = (node, prop, entry) =>
110
+ this._presenter.animate(node, prop, entry);
111
+ this.cancelNodeAnimation = (node, prop) =>
112
+ this._presenter.cancel(node, prop);
106
113
  }
107
114
  app._registerWindow(this);
108
115
  }
@@ -211,6 +218,12 @@ export class CocoaWindow {
211
218
  this.destroyed = true;
212
219
  this.mapped = false;
213
220
  this.app._unregisterWindow(this);
221
+ this._dropTransport = null;
222
+ // a bounce nobody can answer any more
223
+ if (this._attentionRequest != null) {
224
+ this.app.cancelAttention(this._attentionRequest);
225
+ this._attentionRequest = null;
226
+ }
214
227
  this._native.destroyWindow2(this._h);
215
228
  this._releaseBacking();
216
229
  }
@@ -240,6 +253,74 @@ export class CocoaWindow {
240
253
 
241
254
  setActions() {}
242
255
 
256
+ /**
257
+ * `_NET_WM_STATE` requests, as far as this backend has verbs for them:
258
+ * `demands_attention` is the Dock bounce (`requestUserAttention`), held
259
+ * until the state is removed or the window goes. Every other name resolves
260
+ * `false` — ntk's own contract for a state the server cannot honour —
261
+ * because the bridge has no zoom/miniaturize/fullscreen verbs yet
262
+ * (windowkit/appkit#15 scoped them out); nodes.js swallows the false.
263
+ */
264
+ setWmState(names, action = 'add') {
265
+ const list = Array.isArray(names) ? names : [names];
266
+ let honoured = true;
267
+ for (const name of list) {
268
+ if (name !== 'demands_attention') {
269
+ honoured = false;
270
+ continue;
271
+ }
272
+ const held = this._attentionRequest != null;
273
+ const wants = action === 'add' || (action === 'toggle' && !held);
274
+ if (wants && !held && !this.destroyed) {
275
+ this._attentionRequest = this.app.requestAttention();
276
+ } else if (!wants && held) {
277
+ this.app.cancelAttention(this._attentionRequest);
278
+ this._attentionRequest = null;
279
+ }
280
+ }
281
+ return Promise.resolve(honoured);
282
+ }
283
+
284
+ // --- drag and drop (src/cocoa/dnd.js) ------------------------------------
285
+
286
+ /**
287
+ * The drop side: nodes.js hands over the window's DropSession at realize
288
+ * (`_initDnd`), and from then on the app routes this window's `drag-*`
289
+ * events into it. Its presence on the window is what tells nodes.js the
290
+ * backend has drop machinery of its own.
291
+ */
292
+ attachDropTransport(session, node) {
293
+ this._dropTransport = new CocoaDropTransport(this, session, node);
294
+ }
295
+
296
+ /** A `dropAccept` came or went under this window: re-register the types. */
297
+ dropTargetsChanged() {
298
+ this._dropTransport?.refreshTypes();
299
+ }
300
+
301
+ registerDropTypes(types) {
302
+ if (this.destroyed) return;
303
+ this._native.registerDropTypes(this._h, types);
304
+ }
305
+
306
+ setDropResponse(response) {
307
+ if (this.destroyed) return;
308
+ this._native.setDropResponse(this._h, response);
309
+ }
310
+
311
+ /**
312
+ * The source side: hand a DragSession's gesture to an NSDraggingSession
313
+ * (see src/cocoa/dnd.js for what the spec carries). Returns at once; the
314
+ * session reports back as `drag-session-*` events.
315
+ */
316
+ beginDrag(session) {
317
+ if (this.destroyed) return null;
318
+ return this._native.beginDrag(
319
+ this._h,
320
+ dragSpec(session, this._native, this.scale),
321
+ );
322
+ }
323
+
243
324
  setTransientFor() {
244
325
  // addChildWindow attachment comes with the layer presenter phase; a
245
326
  // managed dialog already floats via its own window today.
@@ -7,7 +7,11 @@ import { useAppOrNull } from '../appcontext.js';
7
7
  import {
8
8
  ABS_FILL,
9
9
  Bezel,
10
+ NATIVE_RING,
11
+ TITLE_BASELINE,
10
12
  bezelNatural,
13
+ bezelShadow,
14
+ nativeTitleStyle,
11
15
  pressWash,
12
16
  useNativeControls,
13
17
  } from './native.js';
@@ -85,6 +89,7 @@ export function Button({
85
89
  if (nativeControls && solid) {
86
90
  const controlSize = small ? 'small' : 'regular';
87
91
  const nat = bezelNatural(app, 'push', controlSize);
92
+ const shadow = bezelShadow(app, 'push', controlSize);
88
93
  return h(
89
94
  'box',
90
95
  {
@@ -105,6 +110,20 @@ export function Button({
105
110
  height: nat.height,
106
111
  paddingLeft: small ? 10 : 14,
107
112
  paddingRight: small ? 10 : 14,
113
+ // The natural box is the bezel's footprint, shadow included;
114
+ // the body is what the title is placed against — and placed,
115
+ // not centred (`TITLE_BASELINE`). A label centred by its
116
+ // capitals sat 1pt low beside a native button.
117
+ paddingTop: shadow.top,
118
+ paddingBottom: shadow.bottom + TITLE_BASELINE[controlSize],
119
+ // The keyboard ring is the renderer's, on this box — shaped by
120
+ // the bezel's corners and hugging it, as AppKit's is
121
+ // (`NATIVE_RING`), rather than the palette's offset rectangle.
122
+ borderRadius: small ? 5 : 6,
123
+ ':focus-visible': {
124
+ outlineWidth: NATIVE_RING.width,
125
+ outlineOffset: NATIVE_RING.offset,
126
+ },
108
127
  color: disabled
109
128
  ? theme.textMuted
110
129
  : primary
@@ -122,7 +141,7 @@ export function Button({
122
141
  isDefault: primary && !disabled,
123
142
  style: ABS_FILL,
124
143
  }),
125
- labelContent(children ?? label),
144
+ labelContent(children ?? label, nativeTitleStyle(controlSize)),
126
145
  // The press answer. Last child on purpose: `:active` marks the
127
146
  // pressed node and its ancestors, and the topmost child is what the
128
147
  // press lands on. No hover tint — AppKit buttons have none.
@@ -6,7 +6,14 @@ import React from 'react';
6
6
  import { useAppOrNull } from '../appcontext.js';
7
7
  import { changeEvent } from './change.js';
8
8
  import { Icon } from './Icon.js';
9
- import { Bezel, bezelNatural, useNativeControls } from './native.js';
9
+ import {
10
+ Bezel,
11
+ CONTROL_FONT_SIZE,
12
+ NO_ROW_RING,
13
+ bezelNatural,
14
+ nativeRingStyle,
15
+ useNativeControls,
16
+ } from './native.js';
10
17
  import { focusRingStyle, labelContent, useControl, useTheme } from './theme.js';
11
18
 
12
19
  const h = React.createElement;
@@ -44,6 +51,7 @@ export function Checkbox({
44
51
  const {
45
52
  hover,
46
53
  focused,
54
+ focusVisible,
47
55
  pressed,
48
56
  props,
49
57
  style: controlStyle,
@@ -69,6 +77,7 @@ export function Checkbox({
69
77
  style: [
70
78
  controlStyle,
71
79
  { flexDirection: 'row', alignItems: 'center', gap: 8 },
80
+ NO_ROW_RING,
72
81
  style,
73
82
  ],
74
83
  },
@@ -80,11 +89,17 @@ export function Checkbox({
80
89
  style: {
81
90
  width: nat.width,
82
91
  height: nat.height,
83
- ...focusRingStyle(theme, focused),
92
+ // the box's own corners, so the ring rounds as AppKit's does
93
+ ...nativeRingStyle(theme, focusVisible, 4),
84
94
  },
85
95
  }),
96
+ // At the control font size, as the cell's own title is: the bezel is
97
+ // designed beside 13pt, and the palette's 14px read a size too large
98
+ // next to it. Centred on the box, which is where AppKit centres it
99
+ // (measured: within a quarter point).
86
100
  labelContent(children ?? label, {
87
101
  color: disabled ? theme.textMuted : theme.text,
102
+ fontSize: CONTROL_FONT_SIZE.regular,
88
103
  }),
89
104
  );
90
105
  }
@@ -11,6 +11,7 @@ import {
11
11
  useTheme,
12
12
  } from './theme.js';
13
13
  import { Icon, iconSize } from './Icon.js';
14
+ import { NATIVE_MENU, useNativeControls } from './native.js';
14
15
  import {
15
16
  DEFAULT_LABEL_SIZE,
16
17
  anchorArea,
@@ -81,6 +82,53 @@ const MENU_PAD = 4;
81
82
  // borders on its *controls* does not mean a 2px outline around every menu.
82
83
  const MENU_BORDER = 1;
83
84
 
85
+ /**
86
+ * The geometry of a popup — rows, padding, separator, text — as one
87
+ * object, so the sizing arithmetic and the layout read one source.
88
+ *
89
+ * Two sources: the drawn menu's own numbers, derived from the text size as
90
+ * they always were, or NSMenu's where the backend renders native controls
91
+ * (`NATIVE_MENU`): 22pt rows, 5pt of padding, an 11pt separator, the 13pt
92
+ * menu font at regular weight, and no pressed step. A context menu on the
93
+ * Cocoa backend is then the menu the system's own controls open, beside
94
+ * which the drawn one — 30pt rows of 14px medium — read as a different
95
+ * toolkit's. The bar keeps its drawn metrics: a menu bar is not an NSMenu.
96
+ */
97
+ function menuMetrics(theme, native, fontSize) {
98
+ if (native) {
99
+ return {
100
+ fontSize: NATIVE_MENU.fontSize,
101
+ weight: NATIVE_MENU.weight,
102
+ row: NATIVE_MENU.row,
103
+ pad: NATIVE_MENU.pad,
104
+ itemPad: NATIVE_MENU.padLeft,
105
+ separator: NATIVE_MENU.separator,
106
+ radius: NATIVE_MENU.radius,
107
+ press: false,
108
+ };
109
+ }
110
+ return {
111
+ fontSize,
112
+ weight: MENU_TEXT_WEIGHT,
113
+ row: menuRowHeight(fontSize),
114
+ pad: MENU_PAD,
115
+ itemPad: MENU_ITEM_PAD,
116
+ separator: MENU_SEPARATOR_HEIGHT,
117
+ radius: rowRadius(theme, MENU_BORDER, MENU_PAD),
118
+ // a drawn row steps to `accentActive` while pressed
119
+ press: true,
120
+ };
121
+ }
122
+
123
+ function useMenuMetrics(fontSize) {
124
+ const theme = useTheme();
125
+ const native = useNativeControls();
126
+ return useMemo(
127
+ () => menuMetrics(theme, native, fontSize),
128
+ [theme, native, fontSize],
129
+ );
130
+ }
131
+
84
132
  // A bar item wears the same pill as a row in the menu it opens, so it takes
85
133
  // the row's padding rather than numbers of its own: a title packed tighter
86
134
  // than its own first row is the tell that the two were measured separately.
@@ -173,13 +221,12 @@ const MENU_SHORTCUT_GAP = 24;
173
221
  const MENU_PAGE_ROWS = 10;
174
222
 
175
223
  /** Total popup height for a menu's items (separators are shorter). */
176
- function menuListHeight(items, fontSize) {
177
- const row = menuRowHeight(fontSize);
224
+ function menuListHeight(items, metrics) {
178
225
  const body = visibleItems(items).reduce(
179
- (sum, item) => sum + (isSeparator(item) ? MENU_SEPARATOR_HEIGHT : row),
226
+ (sum, item) => sum + (isSeparator(item) ? metrics.separator : metrics.row),
180
227
  0,
181
228
  );
182
- return body + (MENU_PAD + MENU_BORDER) * 2;
229
+ return body + (metrics.pad + MENU_BORDER) * 2;
183
230
  }
184
231
 
185
232
  /**
@@ -214,19 +261,19 @@ function menuGutter(items) {
214
261
  }
215
262
 
216
263
  /** Widest label + shortcut, measured, so the popup can be sized up front. */
217
- function menuListWidth(node, items, fontSize) {
264
+ function menuListWidth(node, items, metrics) {
218
265
  let widest = 0;
219
266
  for (const item of visibleItems(items)) {
220
267
  if (isSeparator(item)) continue;
221
268
  const label = measureLabel(node, item.label ?? '', {
222
- size: fontSize,
223
- weight: MENU_TEXT_WEIGHT,
269
+ size: metrics.fontSize,
270
+ weight: metrics.weight,
224
271
  }).width;
225
272
  const accelerator = formatShortcut(item.shortcut);
226
273
  const shortcut = accelerator
227
274
  ? measureLabel(node, accelerator, {
228
- size: fontSize,
229
- weight: MENU_TEXT_WEIGHT,
275
+ size: metrics.fontSize,
276
+ weight: metrics.weight,
230
277
  }).width + MENU_SHORTCUT_GAP
231
278
  : 0;
232
279
  widest = Math.max(widest, label + shortcut);
@@ -235,8 +282,8 @@ function menuListWidth(node, items, fontSize) {
235
282
  MENU_MIN_WIDTH,
236
283
  Math.ceil(widest) +
237
284
  menuGutter(items) +
238
- (MENU_PAD + MENU_BORDER) * 2 +
239
- MENU_ITEM_PAD * 2 +
285
+ (metrics.pad + MENU_BORDER) * 2 +
286
+ metrics.itemPad * 2 +
240
287
  2,
241
288
  );
242
289
  }
@@ -393,7 +440,7 @@ function MenuRow({
393
440
  onHover,
394
441
  onMove,
395
442
  onSelect,
396
- fontSize,
443
+ metrics,
397
444
  // The width of the level's check column — the same number the popup was
398
445
  // sized with, passed down rather than asked of the item, since a row with
399
446
  // no mark of its own still keeps the column its neighbours need.
@@ -411,7 +458,10 @@ function MenuRow({
411
458
  {
412
459
  theme,
413
460
  role: 'separator',
414
- style: { height: MENU_SEPARATOR_HEIGHT, justifyContent: 'center' },
461
+ // The level hears the pointer arrive here too: it cannot land, so
462
+ // whatever was lit goes out, as it does in a native menu.
463
+ onMouseEnter: onHover,
464
+ style: { height: metrics.separator, justifyContent: 'center' },
415
465
  },
416
466
  h('box', { style: { height: 1, backgroundColor: theme.border } }),
417
467
  );
@@ -444,20 +494,25 @@ function MenuRow({
444
494
  'aria-expanded': submenu ? state === 'path' : undefined,
445
495
  disabled: dim || undefined,
446
496
  ref: nodeRef,
447
- onMouseEnter: dim ? undefined : onHover,
448
- onMouseMove: dim ? undefined : onMove,
497
+ // A disabled row reports the pointer as any row does; the level
498
+ // answers by lighting nothing (`hover`). Silence here was the bug: the
499
+ // pointer crossed onto a disabled row and the row it had left stayed
500
+ // lit until it reached the next enabled one — a native menu goes dark
501
+ // the moment the pointer is over anything it cannot choose.
502
+ onMouseEnter: onHover,
503
+ onMouseMove: onMove,
449
504
  onClick: dim ? undefined : () => onSelect(item),
450
505
  style: {
451
- height: menuRowHeight(fontSize),
506
+ height: metrics.row,
452
507
  flexDirection: 'row',
453
508
  alignItems: 'center',
454
- paddingLeft: MENU_ITEM_PAD,
455
- paddingRight: MENU_ITEM_PAD,
509
+ paddingLeft: metrics.itemPad,
510
+ paddingRight: metrics.itemPad,
456
511
  // A pill inside the sheet: the row is inset from the popup edge by
457
512
  // the list's padding, and rounded so that its corner and the sheet's
458
513
  // share a centre — the two curves are then one shape rather than two
459
514
  // that nearly agree.
460
- borderRadius: rowRadius(theme, MENU_BORDER, MENU_PAD),
515
+ borderRadius: metrics.radius,
461
516
  // Nothing at rest: the sheet under it is already that colour, and
462
517
  // now that the row is rounded, repainting it per row would be a
463
518
  // coverage mask drawn to change nothing — with four corners it
@@ -474,8 +529,11 @@ function MenuRow({
474
529
  // the item is already highlighted by the time it can be pressed, so
475
530
  // the press is a further step down rather than a first one — without
476
531
  // it the command runs on the release out of a picture that never
477
- // changed
478
- ...(dim
532
+ // changed. Not under native controls: NSMenu has no pressed look,
533
+ // the highlight simply stays, and the palette's pressed step there
534
+ // is AppKit's deep-pressed cut of the accent — on a dark orange
535
+ // palette, a yellow no native menu ever shows.
536
+ ...(dim || !metrics.press
479
537
  ? null
480
538
  : { ':active': { backgroundColor: theme.accentActive } }),
481
539
  },
@@ -490,11 +548,16 @@ function MenuRow({
490
548
  // stand clear, and the row's own padding already spaces the mark.
491
549
  style: { width: gutter, alignItems: 'flex-start' },
492
550
  },
493
- gutterMark(item, { color: rowInk, fontSize }),
551
+ gutterMark(item, { color: rowInk, fontSize: metrics.fontSize }),
494
552
  ),
495
553
  h(
496
554
  'text',
497
- { style: [capTrim, { fontSize, fontWeight: MENU_TEXT_WEIGHT }] },
555
+ {
556
+ style: [
557
+ capTrim,
558
+ { fontSize: metrics.fontSize, fontWeight: metrics.weight },
559
+ ],
560
+ },
498
561
  item.label,
499
562
  ),
500
563
  h('box', { style: { flexGrow: 1 } }),
@@ -508,7 +571,7 @@ function MenuRow({
508
571
  {
509
572
  style: [
510
573
  capTrim,
511
- { fontSize, fontWeight: MENU_TEXT_WEIGHT },
574
+ { fontSize: metrics.fontSize, fontWeight: metrics.weight },
512
575
  !active && { color: theme.textMuted },
513
576
  ],
514
577
  },
@@ -522,7 +585,7 @@ function MenuRow({
522
585
  // The capitals of the row, not the gutter's 16px column: a chevron
523
586
  // stands as tall as its box, so `MENU_ICON_SIZE` would put an arrow
524
587
  // beside the label taller than the label.
525
- size: capBand(fontSize),
588
+ size: capBand(metrics.fontSize),
526
589
  // no `color`: the arrow is the row saying it has more behind it, as
527
590
  // much a part of the entry as its label, and a muted one reads as a
528
591
  // row that is half disabled rather than one with a submenu
@@ -563,6 +626,7 @@ function MenuLevel({
563
626
  fontSize,
564
627
  }) {
565
628
  const theme = useTheme();
629
+ const metrics = useMenuMetrics(fontSize);
566
630
  const items = levelItems(rootItems, path, depth);
567
631
  // one answer for the level, so every row indents by the same amount the
568
632
  // popup was measured with
@@ -606,13 +670,13 @@ function MenuLevel({
606
670
  // continuation six pixels lower than the row itself. Shifting by
607
671
  // the inset puts the first item exactly beside the item it came
608
672
  // out of, which is where the eye is already looking.
609
- alignOffset: -(MENU_BORDER + MENU_PAD),
673
+ alignOffset: -(MENU_BORDER + metrics.pad),
610
674
  offset: SUBMENU_GAP,
611
- width: menuListWidth(node, childItems, fontSize),
612
- height: menuListHeight(childItems, fontSize),
675
+ width: menuListWidth(node, childItems, metrics),
676
+ height: menuListHeight(childItems, metrics),
613
677
  }),
614
678
  );
615
- }, [childOpen, active, depth, fontSize, rect.x, rect.y]);
679
+ }, [childOpen, active, depth, metrics, rect.x, rect.y]);
616
680
 
617
681
  // "safe polygon" hover: while a submenu is open, the triangle between the
618
682
  // pointer and the submenu's near edge counts as still hovering the parent
@@ -639,7 +703,11 @@ function MenuLevel({
639
703
  setPath(hasSubmenu(items[index]) ? [...base, -1] : base);
640
704
  };
641
705
 
642
- const hover = (index, ev) => {
706
+ const hover = (row, ev) => {
707
+ // Over a row the selection cannot land on — disabled, a separator —
708
+ // nothing is lit: `-1` is the same "no row" the keyboard's Home starts
709
+ // from, and the polygon below still holds an open submenu across it.
710
+ const index = isSelectable(items[row]) ? row : -1;
643
711
  const point = screenPoint(ev);
644
712
  if (
645
713
  index !== active &&
@@ -712,7 +780,7 @@ function MenuLevel({
712
780
  style: {
713
781
  flexGrow: 1,
714
782
  flexShrink: 1,
715
- padding: MENU_PAD,
783
+ padding: metrics.pad,
716
784
  borderWidth: MENU_BORDER,
717
785
  borderColor: theme.border,
718
786
  backgroundColor: theme.surface,
@@ -724,7 +792,7 @@ function MenuLevel({
724
792
  key: isSeparator(item) ? `sep-${index}` : (item.key ?? item.label),
725
793
  item,
726
794
  state: rowState(index, active, handedOn),
727
- fontSize,
795
+ metrics,
728
796
  gutter,
729
797
  nodeRef: index === active ? activeRowRef : undefined,
730
798
  onHover: (ev) => hover(index, ev),
@@ -887,6 +955,7 @@ export function ContextMenu({
887
955
  const [path, setPath] = useState([]);
888
956
  const typeAhead = useTypeAhead();
889
957
  const rtl = useDirection() === 'rtl';
958
+ const metrics = useMenuMetrics(fontSize);
890
959
 
891
960
  const close = () => {
892
961
  setRect(null);
@@ -908,8 +977,8 @@ export function ContextMenu({
908
977
  const openAt = (ev) => {
909
978
  const node = ref.current;
910
979
  if (!node || !items.length) return;
911
- const width = menuListWidth(node, items, fontSize);
912
- const height = menuListHeight(items, fontSize);
980
+ const width = menuListWidth(node, items, metrics);
981
+ const height = menuListHeight(items, metrics);
913
982
  const area = anchorArea(node);
914
983
  // anchored at the pointer rather than at a widget: clamp by hand, since
915
984
  // there is no anchor rect to flip around. Root coordinates are device;
@@ -1006,6 +1075,7 @@ export function MenuBar({
1006
1075
  }) {
1007
1076
  const theme = useTheme();
1008
1077
  const rtl = useDirection() === 'rtl';
1078
+ const metrics = useMenuMetrics(fontSize);
1009
1079
  const [openIndex, setOpenIndex] = useState(-1);
1010
1080
  // The pointer's title, tracked here rather than left to a `:hover` block,
1011
1081
  // because the thing that lights up is no longer the node the pointer is
@@ -1111,8 +1181,8 @@ export function MenuBar({
1111
1181
  const node = refs.current[index];
1112
1182
  const menu = entries[index];
1113
1183
  if (!node || !hasSubmenu(menu)) return;
1114
- const width = menuListWidth(node, menu.items, fontSize);
1115
- const height = menuListHeight(menu.items, fontSize);
1184
+ const width = menuListWidth(node, menu.items, metrics);
1185
+ const height = menuListHeight(menu.items, metrics);
1116
1186
  const next = anchorRect(node, { placement: 'bottom', width, height });
1117
1187
  if (!next) return;
1118
1188
  openRef.current = index;
@@ -1159,8 +1229,8 @@ export function MenuBar({
1159
1229
  if (!node || !hasSubmenu(menu)) return null;
1160
1230
  return {
1161
1231
  placement: 'bottom',
1162
- width: menuListWidth(node, menu.items, fontSize),
1163
- height: menuListHeight(menu.items, fontSize),
1232
+ width: menuListWidth(node, menu.items, metrics),
1233
+ height: menuListHeight(menu.items, metrics),
1164
1234
  };
1165
1235
  },
1166
1236
  setRect,