react-x11 2.39.0 → 2.40.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.
@@ -0,0 +1,285 @@
1
+ // `filter` on the native 2d context: canvas's own property, a CSS
2
+ // `<filter-value-list>` (Filter Effects 1), and what this context makes of
3
+ // it — the colour functions, each a matrix over straight RGB with an
4
+ // offset that leaves alpha as it is, and `opacity()`, which scales alpha.
5
+ //
6
+ // They are the functions the context can apply to everything it draws.
7
+ // Each leaves alpha alone, so on premultiplied colour each is a linear map
8
+ // that commutes with source-over: a fill, a stroke, a gradient's stops, a
9
+ // glyph run's ink and a symbol's colour are filtered by filtering the colour
10
+ // they are drawn in, exactly. An image and a surface are filtered pixel by
11
+ // pixel, and a text layout that carries colours of its own on a surface of
12
+ // its own (src/backend/context2d.js). `blur()`, `drop-shadow()` and a
13
+ // `url()` reach past what is drawn, which a colour cannot say; this context
14
+ // does not apply them, so a list with one in it does not stick, and a
15
+ // caller knows from reading the property back.
16
+
17
+ /** A function's amount where none is written: all of the effect for the
18
+ * ones whose argument is how much of it to take, and none for the others
19
+ * (Filter Effects 1, 13). */
20
+ const AMOUNTS = {
21
+ grayscale: 1,
22
+ sepia: 1,
23
+ invert: 1,
24
+ opacity: 1,
25
+ saturate: 1,
26
+ brightness: 1,
27
+ contrast: 1,
28
+ };
29
+
30
+ /** The functions this context applies; any other leaves a list unapplied. */
31
+ const COLOUR = new Set([...Object.keys(AMOUNTS), 'hue-rotate']);
32
+
33
+ const ANGLE_UNITS = {
34
+ deg: 1,
35
+ grad: 0.9,
36
+ rad: 180 / Math.PI,
37
+ turn: 360,
38
+ };
39
+
40
+ /**
41
+ * A filter list as this context applies it: `{ matrices, alpha }`, the
42
+ * colour matrices in the order written, each over straight RGB in 0–1 with
43
+ * an offset — none for a function that changes no colour — and what alpha
44
+ * is multiplied by. Null for `none`; undefined for what is no filter list,
45
+ * or one with a function this context does not apply.
46
+ */
47
+ export function parseCanvasFilter(value) {
48
+ const text = String(value).trim();
49
+ if (text.toLowerCase() === 'none') return null;
50
+ const matrices = [];
51
+ let alpha = 1;
52
+ let rest = text;
53
+ let seen = 0;
54
+ while (rest.length) {
55
+ const m = /^([a-z-]+)\(([^()]*)\)\s*/i.exec(rest);
56
+ if (!m) return undefined;
57
+ rest = rest.slice(m[0].length);
58
+ seen += 1;
59
+ const name = m[1].toLowerCase();
60
+ if (!COLOUR.has(name)) return undefined;
61
+ const arg = m[2].trim();
62
+ if (name === 'hue-rotate') {
63
+ const angle = arg ? angleOf(arg) : 0;
64
+ if (angle === null) return undefined;
65
+ const matrix = hueRotate(angle);
66
+ if (matrix) matrices.push(matrix);
67
+ continue;
68
+ }
69
+ const amount = arg ? amountOf(arg) : AMOUNTS[name];
70
+ if (amount === null || amount < 0) return undefined;
71
+ if (name === 'opacity') {
72
+ alpha *= Math.min(1, amount);
73
+ continue;
74
+ }
75
+ const matrix = matrixOf(name, amount);
76
+ if (matrix) matrices.push(matrix);
77
+ }
78
+ if (!seen) return undefined;
79
+ return { matrices, alpha };
80
+ }
81
+
82
+ /** Whether a parsed filter changes anything drawn. */
83
+ export function filters(filter) {
84
+ return !!filter && (filter.matrices.length > 0 || filter.alpha < 1);
85
+ }
86
+
87
+ /** A number or a percentage of one. */
88
+ function amountOf(arg) {
89
+ const m = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(%?)$/i.exec(arg);
90
+ if (!m) return null;
91
+ const n = Number(m[1]);
92
+ if (!Number.isFinite(n)) return null;
93
+ return m[2] ? n / 100 : n;
94
+ }
95
+
96
+ /** An angle in degrees; a bare 0 is one. */
97
+ function angleOf(arg) {
98
+ const m = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)([a-z]*)$/i.exec(arg);
99
+ if (!m) return null;
100
+ const n = Number(m[1]);
101
+ const unit = m[2].toLowerCase();
102
+ if (!unit) return n === 0 ? 0 : null;
103
+ const scale = ANGLE_UNITS[unit];
104
+ return scale === undefined ? null : n * scale;
105
+ }
106
+
107
+ const unit = (a) => (a < 0 ? 0 : a > 1 ? 1 : a);
108
+
109
+ /** Three rows of red, green and blue weights and an offset, 0–1. */
110
+ function rows(r, g, b) {
111
+ return Float64Array.of(...r, ...g, ...b);
112
+ }
113
+
114
+ /** `feComponentTransfer`'s `linear`: each channel times a slope, plus an
115
+ * intercept. */
116
+ function linear(slope, intercept) {
117
+ return Float64Array.of(
118
+ slope,
119
+ 0,
120
+ 0,
121
+ intercept,
122
+ 0,
123
+ slope,
124
+ 0,
125
+ intercept,
126
+ 0,
127
+ 0,
128
+ slope,
129
+ intercept,
130
+ );
131
+ }
132
+
133
+ /** One function's matrix (Filter Effects 1, 13); null where it changes no
134
+ * colour. */
135
+ function matrixOf(name, amount) {
136
+ switch (name) {
137
+ case 'grayscale': {
138
+ const a = unit(amount);
139
+ if (a === 0) return null;
140
+ const k = 1 - a;
141
+ return rows(
142
+ [0.2126 + 0.7874 * k, 0.7152 - 0.7152 * k, 0.0722 - 0.0722 * k, 0],
143
+ [0.2126 - 0.2126 * k, 0.7152 + 0.2848 * k, 0.0722 - 0.0722 * k, 0],
144
+ [0.2126 - 0.2126 * k, 0.7152 - 0.7152 * k, 0.0722 + 0.9278 * k, 0],
145
+ );
146
+ }
147
+ case 'sepia': {
148
+ const a = unit(amount);
149
+ if (a === 0) return null;
150
+ const k = 1 - a;
151
+ return rows(
152
+ [0.393 + 0.607 * k, 0.769 - 0.769 * k, 0.189 - 0.189 * k, 0],
153
+ [0.349 - 0.349 * k, 0.686 + 0.314 * k, 0.168 - 0.168 * k, 0],
154
+ [0.272 - 0.272 * k, 0.534 - 0.534 * k, 0.131 + 0.869 * k, 0],
155
+ );
156
+ }
157
+ case 'saturate': {
158
+ const s = amount;
159
+ if (s === 1) return null;
160
+ return rows(
161
+ [0.213 + 0.787 * s, 0.715 - 0.715 * s, 0.072 - 0.072 * s, 0],
162
+ [0.213 - 0.213 * s, 0.715 + 0.285 * s, 0.072 - 0.072 * s, 0],
163
+ [0.213 - 0.213 * s, 0.715 - 0.715 * s, 0.072 + 0.928 * s, 0],
164
+ );
165
+ }
166
+ case 'brightness':
167
+ return amount === 1 ? null : linear(amount, 0);
168
+ case 'contrast':
169
+ return amount === 1 ? null : linear(amount, 0.5 - 0.5 * amount);
170
+ case 'invert': {
171
+ const a = unit(amount);
172
+ return a === 0 ? null : linear(1 - 2 * a, a);
173
+ }
174
+ }
175
+ return null;
176
+ }
177
+
178
+ function hueRotate(degrees) {
179
+ if (degrees % 360 === 0) return null;
180
+ const t = (degrees * Math.PI) / 180;
181
+ const c = Math.cos(t);
182
+ const s = Math.sin(t);
183
+ return rows(
184
+ [
185
+ 0.213 + c * 0.787 - s * 0.213,
186
+ 0.715 - c * 0.715 - s * 0.715,
187
+ 0.072 - c * 0.072 + s * 0.928,
188
+ 0,
189
+ ],
190
+ [
191
+ 0.213 - c * 0.213 + s * 0.143,
192
+ 0.715 + c * 0.285 + s * 0.14,
193
+ 0.072 - c * 0.072 - s * 0.283,
194
+ 0,
195
+ ],
196
+ [
197
+ 0.213 - c * 0.213 - s * 0.787,
198
+ 0.715 - c * 0.715 + s * 0.715,
199
+ 0.072 + c * 0.928 + s * 0.072,
200
+ 0,
201
+ ],
202
+ );
203
+ }
204
+
205
+ /** A straight `[r, g, b, a]`, 0–1, through the filter: each function's
206
+ * result clamped before the next, as each one's is. */
207
+ export function filterColour([r, g, b, a], filter) {
208
+ for (const m of filter.matrices) {
209
+ const r1 = m[0] * r + m[1] * g + m[2] * b + m[3];
210
+ const g1 = m[4] * r + m[5] * g + m[6] * b + m[7];
211
+ const b1 = m[8] * r + m[9] * g + m[10] * b + m[11];
212
+ r = unit(r1);
213
+ g = unit(g1);
214
+ b = unit(b1);
215
+ }
216
+ return [r, g, b, a * filter.alpha];
217
+ }
218
+
219
+ /** A gradient's stops, flat as `[offset, r, g, b, a, …]`, through the
220
+ * filter: interpolating straight colours commutes with a matrix over them,
221
+ * but for what a clamp cuts off between two stops. */
222
+ export function filterStops(flat, filter) {
223
+ const out = flat.slice();
224
+ for (let i = 0; i + 4 < out.length; i += 5) {
225
+ const [r, g, b, a] = filterColour(
226
+ [out[i + 1], out[i + 2], out[i + 3], out[i + 4]],
227
+ filter,
228
+ );
229
+ out[i + 1] = r;
230
+ out[i + 2] = g;
231
+ out[i + 3] = b;
232
+ out[i + 4] = a;
233
+ }
234
+ return out;
235
+ }
236
+
237
+ /**
238
+ * Straight RGBA pixels, 0–255, through the filter, `src` into `dst` — the
239
+ * two may be one. A pixel with no alpha keeps none: its colour is no
240
+ * colour.
241
+ */
242
+ export function filterPixels(src, dst, filter) {
243
+ const { matrices } = filter;
244
+ const fade = filter.alpha;
245
+ const one = matrices.length === 1 ? matrices[0] : null;
246
+ for (let i = 0; i < src.length; i += 4) {
247
+ const a = src[i + 3];
248
+ if (a === 0) {
249
+ dst[i] = 0;
250
+ dst[i + 1] = 0;
251
+ dst[i + 2] = 0;
252
+ dst[i + 3] = 0;
253
+ continue;
254
+ }
255
+ let r = src[i];
256
+ let g = src[i + 1];
257
+ let b = src[i + 2];
258
+ if (one) {
259
+ const m = one;
260
+ const r1 = m[0] * r + m[1] * g + m[2] * b + m[3] * 255;
261
+ const g1 = m[4] * r + m[5] * g + m[6] * b + m[7] * 255;
262
+ const b1 = m[8] * r + m[9] * g + m[10] * b + m[11] * 255;
263
+ r = r1;
264
+ g = g1;
265
+ b = b1;
266
+ } else {
267
+ for (const m of matrices) {
268
+ const r1 = m[0] * r + m[1] * g + m[2] * b + m[3] * 255;
269
+ const g1 = m[4] * r + m[5] * g + m[6] * b + m[7] * 255;
270
+ const b1 = m[8] * r + m[9] * g + m[10] * b + m[11] * 255;
271
+ r = r1 < 0 ? 0 : r1 > 255 ? 255 : r1;
272
+ g = g1 < 0 ? 0 : g1 > 255 ? 255 : g1;
273
+ b = b1 < 0 ? 0 : b1 > 255 ? 255 : b1;
274
+ }
275
+ }
276
+ dst[i] = clampByte(r);
277
+ dst[i + 1] = clampByte(g);
278
+ dst[i + 2] = clampByte(b);
279
+ dst[i + 3] = fade === 1 ? a : clampByte(a * fade);
280
+ }
281
+ }
282
+
283
+ function clampByte(v) {
284
+ return v <= 0 ? 0 : v >= 255 ? 255 : Math.round(v);
285
+ }
package/src/cocoa/app.js CHANGED
@@ -286,6 +286,13 @@ export class CocoaApp {
286
286
  // X11's, where every toolkit hovers in a window without the keyboard.
287
287
  this.hoverNeedsActiveWindow = true;
288
288
 
289
+ // Shortcuts are pressed with ⌘ here (src/keysyms.js `primaryModifier`):
290
+ // the text controls' Undo/Cut/Copy/Paste/Select All answer ⌘ rather
291
+ // than ⌃, and the edit menu prints them the way a Mac menu does. The
292
+ // menu bar already read a chord's `Control` as ⌘ (cocoa/globalmenu.js);
293
+ // this is the same rule for everything else that reads one.
294
+ this.primaryModifier = 'Super';
295
+
289
296
  // AppKit-rendered control bezels. Its *presence* is the capability:
290
297
  // `useSupports('nativeControls')` and the widget set's `controls:
291
298
  // 'auto'` policy both test for this property, so a backend without it
@@ -35,6 +35,9 @@ const FLAG_COMMAND = 1 << 20;
35
35
  * user presses. Multi-chord sequences and non-character keys have no
36
36
  * NSMenu spelling and are dropped (the shortcut still works — the app's
37
37
  * own key handling is untouched; only the menu's hint goes unshown).
38
+ *
39
+ * `named` is the modifiers the chord itself asked for, before the ⌘ a bare
40
+ * key is given so that NSMenu has something to show.
38
41
  */
39
42
  function keyEquivalent(shortcut) {
40
43
  const chord = Array.isArray(shortcut) ? shortcut[0] : null;
@@ -48,7 +51,26 @@ function keyEquivalent(shortcut) {
48
51
  else key = part;
49
52
  }
50
53
  if (typeof key !== 'string' || [...key].length !== 1) return null;
51
- return { key: key.toLowerCase(), modifiers: modifiers || FLAG_COMMAND };
54
+ return {
55
+ key: key.toLowerCase(),
56
+ modifiers: modifiers || FLAG_COMMAND,
57
+ named: modifiers,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Does the menu bar answer this `shortcut` itself, on the chord the app
63
+ * wrote? The ones it holds as a key equivalent that carries ⌘ because the
64
+ * chord said `Control` or `Super`: AppKit offers a ⌘ key to the main menu's
65
+ * `performKeyEquivalent:` inside `[NSApp sendEvent:]`, so the item fires
66
+ * with no help from us. A bare key padded out to ⌘ is not one — the chord
67
+ * names the bare key, and the window still answers that.
68
+ *
69
+ * The bridge shows JS every key before AppKit sees it, so a `MenuBar` that
70
+ * matched these too would run the item twice (`useMenuAccelerators`).
71
+ */
72
+ export function answersShortcut(shortcut) {
73
+ return Boolean(keyEquivalent(shortcut)?.named & FLAG_COMMAND);
52
74
  }
53
75
 
54
76
  /**
@@ -117,6 +139,15 @@ export class CocoaGlobalMenuExport {
117
139
  if (this.app._activeGlobalMenu === this) this._install();
118
140
  }
119
141
 
142
+ /**
143
+ * The delegate's half of the key: the shortcuts the menu bar answers on
144
+ * its own, which the window must then leave alone (`answersShortcut`).
145
+ * The D-Bus export has no such method, because a panel delivers no keys.
146
+ */
147
+ answersShortcut(shortcut) {
148
+ return answersShortcut(shortcut);
149
+ }
150
+
120
151
  _install() {
121
152
  this.app._native.setMainMenu(this._spec());
122
153
  }
@@ -12,9 +12,25 @@
12
12
  // And the players its `<video src>`s ask for, which a process with no
13
13
  // AppKit would never hear from: each is a player here, its events and its
14
14
  // frames sent back (`HostedPlayer`, src/cocoa/paneplayer.js).
15
+ import { now } from '../nodes/animation.js';
15
16
  import { HostedPlayer } from './paneplayer.js';
16
17
  import { withoutActions } from './quiet.js';
17
18
 
19
+ /** The live-resize handshake's budget when the app names none
20
+ * (`RESIZE_WAIT_MS`, src/cocoa/app.js). */
21
+ const RESIZE_WAIT_MS = 50;
22
+
23
+ /** How many sizes asked of the pane are remembered until a frame of one
24
+ * answers them (`_asked`): a drag asks one a frame. */
25
+ const ASKED_KEPT = 16;
26
+
27
+ /** How long after the last size asked a new one starts a gesture of its
28
+ * own, which finds the pane keeping up until it shows otherwise: a pane
29
+ * whose layout at a width it was not dragged to took long — a long page
30
+ * laid out whole, as the first step of a drag is — keeps up with the
31
+ * drag itself. */
32
+ const GESTURE_GAP_MS = 500;
33
+
18
34
  /** One shared surface on glass: a sublayer of a window's root layer, its
19
35
  * contents the IOSurface the pane last presented into it. */
20
36
  class PaneLayer {
@@ -29,6 +45,82 @@ class PaneLayer {
29
45
  });
30
46
  this.destroyed = false;
31
47
  this._rect = null;
48
+ // How the frame the layer holds sits in it while one of the size the
49
+ // layer was just given is on its way (`_gravityNow`): the gravity set,
50
+ // Core Animation's own until one is, the sizes asked of the pane with
51
+ // when each was asked, and whether the pane has fallen behind them.
52
+ this._gravity = 'resize';
53
+ this._contentsScale = null;
54
+ this._asked = [];
55
+ this._askedAt = null;
56
+ this._behind = false;
57
+ }
58
+
59
+ /**
60
+ * The gravity a pane's last frame sits in its layer by, until a frame of
61
+ * the layer's new size lands. The host gives the layer its new size the
62
+ * moment the window lays out, and the pane's frame of that size comes a
63
+ * pane frame later: Core Animation's default, `resize`, stretches the
64
+ * frame it has to the new bounds meanwhile, and a page's left column was
65
+ * drawn scaled a little at every step of a window dragged wider, and then
66
+ * at its size again. A pane that keeps up has its frame anchored at the
67
+ * top left instead, at its size, what it does not cover yet showing the
68
+ * window under it for a pane frame; one that falls behind the budget is
69
+ * stretched, which is the better picture for the long wait — a pane
70
+ * repainting at three frames a second shows a squeezed page, not a page
71
+ * and a growing strip. The budget is the window's live-resize handshake's
72
+ * (`createRoot({ cocoa: { resizeWait } })`), how long a frame of a new
73
+ * size may take before the old one is shown in its place: 0 stretches
74
+ * always, as before. A bridge that predates `contentsGravity` ignores it,
75
+ * and stretches.
76
+ */
77
+ _gravityNow() {
78
+ const budget = this.app._resizeWait ?? RESIZE_WAIT_MS;
79
+ if (!(budget > 0)) return 'resize';
80
+ // a size asked longer ago than the budget, and no frame of it yet
81
+ const oldest = this._asked[0];
82
+ if (oldest && now() - oldest.at > budget) this._behind = true;
83
+ return this._behind ? 'resize' : 'topLeft';
84
+ }
85
+
86
+ /** The props that set the gravity, where it changed. */
87
+ _gravityProps() {
88
+ const gravity = this._gravityNow();
89
+ if (gravity === this._gravity) return null;
90
+ this._gravity = gravity;
91
+ // a frame anchored and larger than the layer would hang out of it
92
+ return { contentsGravity: gravity, masksToBounds: gravity !== 'resize' };
93
+ }
94
+
95
+ /**
96
+ * The props that say how many of the pane's pixels make a point of the
97
+ * layer, where that changed: the window's scale, which the pane draws
98
+ * at. A frame stretched to the bounds never asked, and one anchored at
99
+ * its size is shown at it — pixels over `contentsScale` points, so at
100
+ * the default of 1 a page at 2x came out twice its size.
101
+ */
102
+ _scaleProps() {
103
+ const scale = this.wnd.scale ?? 1;
104
+ if (scale === this._contentsScale) return null;
105
+ this._contentsScale = scale;
106
+ return { contentsScale: scale };
107
+ }
108
+
109
+ /** A frame of `width` by `height` device px landed: what it answers of
110
+ * the sizes asked, and how long it took. */
111
+ _answered(width, height) {
112
+ const asked = this._asked;
113
+ // the pane rounds the logical size it was sent back to device pixels
114
+ const slack = Math.max(2, this.wnd.scale ?? 1);
115
+ const at = asked.findIndex(
116
+ (a) =>
117
+ Math.abs(a.width - width) <= slack &&
118
+ Math.abs(a.height - height) <= slack,
119
+ );
120
+ if (at < 0) return;
121
+ this._behind =
122
+ now() - asked[at].at > (this.app._resizeWait ?? RESIZE_WAIT_MS);
123
+ asked.splice(0, at + 1);
32
124
  }
33
125
 
34
126
  /** Geometry in device px, the node's abs — points at the layer. */
@@ -46,6 +138,17 @@ class PaneLayer {
46
138
  return;
47
139
  }
48
140
  this._rect = { ...rect };
141
+ // a size the pane is asked for, answered by the first frame of it
142
+ if (prev && (prev.width !== rect.width || prev.height !== rect.height)) {
143
+ const at = now();
144
+ if (at - (this._askedAt ?? -Infinity) >= GESTURE_GAP_MS) {
145
+ this._asked = [];
146
+ this._behind = false;
147
+ }
148
+ this._askedAt = at;
149
+ if (this._asked.length === ASKED_KEPT) this._asked.shift();
150
+ this._asked.push({ width: rect.width, height: rect.height, at });
151
+ }
49
152
  // Actions off: the layer is ours, not a presenter's, so no frame's
50
153
  // transaction covers it, and a bare set tweens the pane into place at
51
154
  // mount and after every resize — the trap CocoaGLArea._setLayerProps
@@ -57,6 +160,8 @@ class PaneLayer {
57
160
  frame: [rect.x / s, rect.y / s, rect.width / s, rect.height / s],
58
161
  zPosition: 1e7,
59
162
  hidden: false,
163
+ ...this._scaleProps(),
164
+ ...this._gravityProps(),
60
165
  });
61
166
  } finally {
62
167
  native.txCommit();
@@ -78,7 +183,7 @@ class PaneLayer {
78
183
  * already holds a reference to. Nothing else here throws; any other
79
184
  * error is the bug it says it is.
80
185
  */
81
- present(iosurfaceId) {
186
+ present(iosurfaceId, size = null) {
82
187
  if (this.destroyed) return false;
83
188
  try {
84
189
  this._native.setLayerContentsIOSurface(this.layer, iosurfaceId);
@@ -86,6 +191,17 @@ class PaneLayer {
86
191
  if (!/IOSurfaceLookup/.test(err?.message ?? '')) throw err;
87
192
  return false;
88
193
  }
194
+ // how long the pane took to a frame of a size it was asked for, which
195
+ // is what the gravity follows (`_gravityNow`)
196
+ if (size) {
197
+ this._answered(size.width, size.height);
198
+ const gravity = this._gravityProps();
199
+ if (gravity) {
200
+ withoutActions(this._native, () =>
201
+ this._native.setLayerProps(this.layer, gravity),
202
+ );
203
+ }
204
+ }
89
205
  return true;
90
206
  }
91
207
 
@@ -24,9 +24,11 @@ import {
24
24
  useDismissOnWindowBlur,
25
25
  } from './anchor.js';
26
26
  import { typeAheadChar, useTypeAhead } from './typeahead.js';
27
- import { useGlobalMenu } from '../globalmenu.js';
27
+ import { useGlobalMenuExport } from '../globalmenu.js';
28
28
  import { acceleratedItem } from '../accelerators.js';
29
29
  import { useAcceleratorEntry } from '../acceleratorhooks.js';
30
+ import { useAppOrNull } from '../appcontext.js';
31
+ import { primaryModifier } from '../keysyms.js';
30
32
  import { useTopLevelWindow } from '../windowid.js';
31
33
  import { MENU_TEXT_WEIGHT } from '../faces.js';
32
34
  import {
@@ -121,12 +123,17 @@ function menuMetrics(theme, native, fontSize) {
121
123
  };
122
124
  }
123
125
 
126
+ // `primary` rides along with the sizes because it is one too: the shortcut
127
+ // column is measured from the same string the row then draws — `⌘S` on the
128
+ // Cocoa backend, `Ctrl+S` elsewhere — and the binding matches the key that
129
+ // string names (`useMenuAccelerators`).
124
130
  function useMenuMetrics(fontSize) {
125
131
  const theme = useTheme();
126
132
  const native = useNativeControls();
133
+ const primary = primaryModifier(useAppOrNull());
127
134
  return useMemo(
128
- () => menuMetrics(theme, native, fontSize),
129
- [theme, native, fontSize],
135
+ () => ({ ...menuMetrics(theme, native, fontSize), primary }),
136
+ [theme, native, fontSize, primary],
130
137
  );
131
138
  }
132
139
 
@@ -272,7 +279,9 @@ function menuListWidth(node, items, metrics) {
272
279
  size: metrics.fontSize,
273
280
  weight: metrics.weight,
274
281
  }).width;
275
- const accelerator = formatShortcut(item.shortcut);
282
+ const accelerator = formatShortcut(item.shortcut, {
283
+ primary: metrics.primary,
284
+ });
276
285
  const shortcut = accelerator
277
286
  ? measureLabel(node, accelerator, {
278
287
  size: metrics.fontSize,
@@ -471,7 +480,9 @@ function MenuRow({
471
480
  }
472
481
  const dim = !isEnabled(item);
473
482
  const submenu = hasSubmenu(item);
474
- const accelerator = formatShortcut(item.shortcut);
483
+ const accelerator = formatShortcut(item.shortcut, {
484
+ primary: metrics.primary,
485
+ });
475
486
  const rowInk = dim ? theme.textMuted : active ? theme.hoverText : theme.text;
476
487
  if (process.env.NODE_ENV !== 'production') checkShortcut(item);
477
488
  return h(
@@ -489,7 +500,9 @@ function MenuRow({
489
500
  // the label alone: without it, name-from-contents would read the
490
501
  // shortcut and the submenu arrow into the name ("New Ctrl+N")
491
502
  'aria-label': typeof item.label === 'string' ? item.label : undefined,
492
- 'aria-keyshortcuts': ariaKeyShortcuts(item.shortcut),
503
+ 'aria-keyshortcuts': ariaKeyShortcuts(item.shortcut, {
504
+ primary: metrics.primary,
505
+ }),
493
506
  'aria-checked': item.toggleType
494
507
  ? ariaChecked(item.toggleState)
495
508
  : undefined,
@@ -922,17 +935,40 @@ function handleMenuKey(
922
935
  * `anchorRef` is what the binding hangs off — the bar, or the window when
923
936
  * there is no bar to hang off — and decides what a modal `<popup>` takes it
924
937
  * away from (`EventManager._runAccelerators`).
938
+ *
939
+ * `primary` is the app's primary modifier, so a chord's `Control` is ⌘ on
940
+ * the Cocoa backend — the key the row prints, and the key the macOS menu
941
+ * bar installs. Which is where the one exception comes from:
942
+ * `answeredElsewhere(shortcut)` names the chords a delegate fires the item
943
+ * for by itself. The bridge hands JS every key *before* AppKit dispatches
944
+ * it, so ⌘S reaches the window's bindings and then the menu bar's key
945
+ * equivalent; selecting here too would save twice. So such a chord is
946
+ * *reserved*: spent before any binding in the window is offered it, this
947
+ * one included, and left to the menu bar — a `useAccelerator` on ⌘S
948
+ * mounted after the bar would otherwise be the same double run by another
949
+ * route. A chord the menu bar cannot spell (F5, a second alternative,
950
+ * `plus`) is not one, and stays this binding's.
925
951
  */
926
- function useMenuAccelerators(items, select, anchorRef, enabled) {
952
+ function useMenuAccelerators(
953
+ items,
954
+ select,
955
+ anchorRef,
956
+ { enabled, primary, answeredElsewhere },
957
+ ) {
927
958
  useAcceleratorEntry(
928
959
  anchorRef,
929
960
  (ev) => {
930
- const item = acceleratedItem(items, ev);
961
+ const item = acceleratedItem(items, ev, { primary });
931
962
  if (!item) return false;
932
963
  select(item);
933
964
  return true;
934
965
  },
935
966
  enabled,
967
+ answeredElsewhere &&
968
+ ((ev) => {
969
+ const item = acceleratedItem(items, ev, { primary });
970
+ return Boolean(item && answeredElsewhere(item.shortcut));
971
+ }),
936
972
  );
937
973
  }
938
974
 
@@ -970,7 +1006,10 @@ export function ContextMenu({
970
1006
  onSelect?.(item);
971
1007
  };
972
1008
 
973
- useMenuAccelerators(items, select, ref, accelerators);
1009
+ useMenuAccelerators(items, select, ref, {
1010
+ enabled: accelerators,
1011
+ primary: metrics.primary,
1012
+ });
974
1013
 
975
1014
  // the wrapper keeps the focus the right-click gave it, so `onBlur` below
976
1015
  // never fires when the *window* loses focus — and the menu is holding a
@@ -1101,7 +1140,10 @@ export function MenuBar({
1101
1140
  // `visible` as a property, so hiding one there is a patch the panel applies
1102
1141
  // rather than a structural change that renumbers everything after it.
1103
1142
  const bar = useMemo(() => visibleItems(menus), [menus]);
1104
- const delegated = useGlobalMenu(menus, { onSelect, enabled: globalMenu });
1143
+ const { delegated, answersShortcut } = useGlobalMenuExport(menus, {
1144
+ onSelect,
1145
+ enabled: globalMenu,
1146
+ });
1105
1147
 
1106
1148
  // What the bar turned out to be, which is the one thing here that cannot
1107
1149
  // be worked out in a render: layout runs on the frame clock, after the
@@ -1263,7 +1305,11 @@ export function MenuBar({
1263
1305
  }),
1264
1306
  [owner],
1265
1307
  );
1266
- useMenuAccelerators(menus, select, acceleratorAnchor, accelerators);
1308
+ useMenuAccelerators(menus, select, acceleratorAnchor, {
1309
+ enabled: accelerators,
1310
+ primary: metrics.primary,
1311
+ answeredElsewhere: delegated ? answersShortcut : null,
1312
+ });
1267
1313
 
1268
1314
  const moveMenu = (dir) => {
1269
1315
  if (!entries.length) return;