react-x11 2.39.0 → 2.41.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.
@@ -21,6 +21,15 @@
21
21
  // (src/cocoa/panehost.js), because nothing in a pane process runs AppKit.
22
22
  import { BackendContext2D } from '../backend/context2d.js';
23
23
 
24
+ // How long a pane counts on the host's `pane-shown` for a present before it
25
+ // stops waiting for one (`_answerOverdue`). A host that is only busy answers
26
+ // well inside it — in the browser example on a Mac, a present was looked up
27
+ // under a millisecond after it went at the median, and 180ms after at
28
+ // worst, the host laying out a page at each width of a drag on its first
29
+ // run — and one that never answers, a host stalled or gone, must not stop
30
+ // the pane drawing.
31
+ const ANSWER_TIMEOUT_MS = 250;
32
+
24
33
  let nextPaneId = 1;
25
34
 
26
35
  export class CocoaPaneWindow {
@@ -57,7 +66,23 @@ export class CocoaPaneWindow {
57
66
  this._dirty = false;
58
67
  this._flushDamage = 'full';
59
68
  this._seq = 0;
69
+ // a size from the host not yet presented (`pane-sized`)
70
+ this._owesSized = false;
60
71
  this._presentedAt = -Infinity;
72
+ // the window's own frame clock (`CocoaApp._frameDue`), at the app's
73
+ // interval: a pane has no display of its own to pace by
74
+ this._rafLast = 0;
75
+ // The newest present the host has looked up (`pane-shown`), and the
76
+ // buffers of retired rings still named by a present it has not: freed
77
+ // as it does (`_releaseRing`).
78
+ this._shownSeq = 0;
79
+ this._shownAt = -Infinity;
80
+ // what it had looked up before its latest answers, whose buffer the
81
+ // screen may still show (`_takeBack`)
82
+ this._shownBefore = 0;
83
+ this._pollTimer = null;
84
+ this._kickQueued = false;
85
+ this._retired = [];
61
86
  this._reactX11Node = null;
62
87
  // Where the pane's corner is on the screen, in device pixels, as the
63
88
  // host last said (`setPaneSize`): what a popup the pane anchors to one
@@ -89,6 +114,10 @@ export class CocoaPaneWindow {
89
114
  const moved =
90
115
  screen != null && (screen.x !== this.x || screen.y !== this.y);
91
116
  const resized = w !== this.width || h !== this.height;
117
+ // The host sends the next rect when this one is painted (`pane-sized`,
118
+ // src/frame/index.js): one that changes no size is painted already.
119
+ if (resized) this._owesSized = true;
120
+ else this._post({ type: 'pane-sized' });
92
121
  if (!moved && !resized) return;
93
122
  if (moved) {
94
123
  this.x = screen.x;
@@ -143,8 +172,10 @@ export class CocoaPaneWindow {
143
172
  this.app._paneSend?.(msg);
144
173
  }
145
174
 
175
+ /** On this window's clock, which the clock asks before it runs a frame
176
+ * (`frameHeld`). */
146
177
  requestAnimationFrame(cb) {
147
- return this.app._requestFrame(cb);
178
+ return this.app._requestFrame(cb, this);
148
179
  }
149
180
 
150
181
  /** A `<glarea>`'s frame, after this pane's own (`CocoaWindow`'s). */
@@ -152,34 +183,140 @@ export class CocoaPaneWindow {
152
183
  return this.app._requestFrame(cb, null, true);
153
184
  }
154
185
 
186
+ /** The clock's period (`CocoaApp._frameDue`): the app's, a pane having no
187
+ * display of its own. */
188
+ get _frameInterval() {
189
+ return this.app.frameIntervalFor(null);
190
+ }
191
+
192
+ /** What is on glass is the host's to know; a pane draws what it is
193
+ * asked to (`CocoaApp._tickFrames`). */
194
+ _visible() {
195
+ return true;
196
+ }
197
+
155
198
  /**
156
199
  * Whether the host may still be showing the frame before the last one —
157
200
  * the gate `flushPendingFrames` (src/frames.js) is written around: a
158
201
  * discrete input paints on the spot only when the last frame has landed,
159
202
  * which is what folds a burst into one paced frame instead of a frame per
160
- * event. On X11 the server says when a present was shown; a pane hears
161
- * nothing back from the host, which flips the layer on its next pump tick
162
- * and has Core Animation scan it out at the following refresh — so a
163
- * present counts as in flight for one frame interval. Without a gate here
164
- * every message the host queued while the pane was busy was answered
165
- * with a full frame of its own: a forty-tick resize of a pane whose frame
166
- * costs 300ms stepped through forty sizes for twelve seconds after the
167
- * drag had ended, each one the previous surface stretched to the layer.
203
+ * event. Without a gate here every message the host queued while the
204
+ * pane was busy was answered with a full frame of its own: a forty-tick
205
+ * resize of a pane whose frame costs 300ms stepped through forty sizes
206
+ * for twelve seconds after the drag had ended, each one the previous
207
+ * surface stretched to the layer.
208
+ *
209
+ * On X11 the server says when a present was shown. Here the host says
210
+ * when it has looked one up (`pane-shown`), which is half of it: the host
211
+ * commits on its own thread and the screen shows the frame at the
212
+ * refresh after. So a present is in flight until it is answered, and for
213
+ * a frame interval after it went: measured in the browser example on a
214
+ * 120Hz panel, the answer alone answered an input 4ms sooner, and gave
215
+ * input faster than the display a frame each — 245 a second at 250
216
+ * inputs a second, 355 at 1000 — where the interval keeps it at 118, and
217
+ * the frames that outran the WindowServer drew into buffers it still
218
+ * read. Nothing the pane can ask says when a frame is on glass: a buffer
219
+ * reads in use from the host's lookup, not from the refresh. And no frame
220
+ * is drawn while one is held (`frameHeld`), whatever asks.
168
221
  */
169
222
  frameInFlight() {
223
+ if (this.frameHeld()) return true;
224
+ if (this._seq > this._shownSeq && !this._answerOverdue()) return true;
170
225
  return (
171
226
  performance.now() - this._presentedAt < this.app.frameIntervalFor(null)
172
227
  );
173
228
  }
174
229
 
230
+ /**
231
+ * Whether this pane's next frame has no buffer to go into, which holds it
232
+ * on the clock (`CocoaApp._tickFrames`): every buffer but the one it last
233
+ * presented is named by a present the host is showing or has yet to look
234
+ * up, or the host is done with one and the WindowServer is still reading
235
+ * it. A frame drawn into one of those anyway was a frame half drawn on
236
+ * glass — the host showed the buffer the pane was drawing into, or looked
237
+ * it up after the pane had started, and showed that.
238
+ *
239
+ * The second is the host coming back from a long frame of its own. Its
240
+ * answers say it has looked the queued presents up, not that they are on
241
+ * glass: that is its UI thread's commit and the refresh after, and until
242
+ * then the buffer it showed all along is still what the screen shows —
243
+ * and which buffer that is, the answers cannot say, since a host that
244
+ * stalled between a lookup and its commit left the one before on glass.
245
+ * The WindowServer can: a buffer is taken once it has let go of one, which
246
+ * is asked every millisecond (`_pollHeld`), and for a frame interval after
247
+ * the host's answer at most, since it reads a buffer it no longer shows
248
+ * for as long as it likes and a pane must not wait on that.
249
+ *
250
+ * A new size is never held: it draws into a new ring, which nothing
251
+ * names. Nor is a host that stopped answering (`_answerOverdue`).
252
+ */
253
+ frameHeld() {
254
+ if (!this._ring || this._surface) return false;
255
+ if (
256
+ this._surfaceSize?.width !== this.width ||
257
+ this._surfaceSize?.height !== this.height
258
+ ) {
259
+ return false;
260
+ }
261
+ let done = false;
262
+ for (let i = 0; i < this._ring.length; i += 1) {
263
+ const s = this._ring[i];
264
+ if (i === this._shownIndex || !this._done(s)) continue;
265
+ if (!this._held(s)) return false;
266
+ done = true;
267
+ }
268
+ if (!done) return !this._answerOverdue();
269
+ const wait =
270
+ this._shownAt + this.app.frameIntervalFor(null) - performance.now();
271
+ if (!(wait > 0)) return false;
272
+ this._pollHeld();
273
+ return true;
274
+ }
275
+
276
+ /**
277
+ * Ask again in a millisecond whether the WindowServer has let go of a
278
+ * buffer (`frameHeld`): it says so by no event, and the pump's next tick
279
+ * is up to 8ms off — a frame held that long for a buffer let go of a
280
+ * millisecond later was a frame late.
281
+ */
282
+ _pollHeld() {
283
+ if (this._pollTimer) return;
284
+ this._pollTimer = setTimeout(() => {
285
+ this._pollTimer = null;
286
+ const app = this.app;
287
+ if (!this.destroyed && app._rafQueue.some((e) => e.wnd === this)) {
288
+ app._tickFrames();
289
+ app._presentAll();
290
+ }
291
+ }, 1);
292
+ this._pollTimer.unref?.();
293
+ }
294
+
295
+ /** Whether the host has looked up a present after the last one that named
296
+ * `buffer`, or none ever did: nothing will show it again. */
297
+ _done(buffer) {
298
+ return !buffer.presentedSeq || buffer.presentedSeq < this._shownSeq;
299
+ }
300
+
301
+ /** A present the host has not answered for longer than it would if it
302
+ * were answering at all (`ANSWER_TIMEOUT_MS`). */
303
+ _answerOverdue() {
304
+ return (
305
+ this._seq > this._shownSeq &&
306
+ performance.now() - this._presentedAt >= ANSWER_TIMEOUT_MS
307
+ );
308
+ }
309
+
175
310
  // Three buffers, not two. A pane is cross-process: the host keeps
176
311
  // scanning the last buffer it was handed until it processes the next
177
- // present message, and nothing here waits for that. With two buffers the
178
- // next paint (and the catch-up copy) lands in the very buffer the host is
179
- // still displaying — every present tears it, the flash. A third buffer is
180
- // always at least two presents behind what the host shows, so the pane
181
- // never writes a buffer the host might still be reading. Same-process
182
- // windows need only two because Core Animation latches the front buffer.
312
+ // present message. With two buffers the next paint (and the catch-up
313
+ // copy) lands in the very buffer the host is still displaying — every
314
+ // present tears it, the flash. With three, one present may be on its way
315
+ // while the next frame draws: the buffer the host shows, the one the
316
+ // present on its way names, and the one the host has let go of. A host
317
+ // two presents behind leaves none, and the frame waits for its answer
318
+ // (`frameHeld`, `_takeBack`). Same-process windows need only two because
319
+ // Core Animation latches the front buffer.
183
320
  static RING = 3;
184
321
 
185
322
  /**
@@ -188,11 +325,26 @@ export class CocoaPaneWindow {
188
325
  * drag resizes the pane a tick at a time, and each tick retires a ring
189
326
  * that the finalizer would have held until a collection happened to run.
190
327
  * The host keeps its own reference to whichever buffer its layer shows,
191
- * so the frame on glass survives the free; a present of a retired buffer
192
- * still in the channel finds no surface, and the host drops it
193
- * (`CocoaPaneHost.present`) — the next present is the full frame on the
194
- * new ring anyway. Bridges before 0.4 have no `releaseSurface`; there the
195
- * finalizer is still the only owner, and this is the drop it always was.
328
+ * so the frame on glass survives the free.
329
+ *
330
+ * All but a buffer a present names that the host has not looked up yet.
331
+ * The host finds a buffer by its IOSurface id, and the system hands a
332
+ * freed id to the next surface anyone makes — the three this ring's
333
+ * successor is about to make, the ring of another pane resized in the
334
+ * same layout, the host's own window. A present still in the channel
335
+ * named the id, not the surface, so the host scanned out whatever had it
336
+ * by the time it looked: a frame of this pane's next ring, cleared or
337
+ * half drawn, or another tab's page, for a frame at every few steps of a
338
+ * drag. So that buffer is kept (`_retired`) until the host says it has
339
+ * looked it up (`pane-shown`, `_shown`), and only then freed: its id
340
+ * stays its own until nothing can ask for it. That is the buffers of
341
+ * the presents in flight — one, as a rule, since a present goes a frame
342
+ * at a time and the host answers on its next turn — and never more than
343
+ * a ring's worth: a host that stops answering has the oldest freed
344
+ * anyway, the one it is likeliest to have looked up already.
345
+ *
346
+ * Bridges before 0.4 have no `releaseSurface`; there the finalizer is
347
+ * still the only owner, and this is the drop it always was.
196
348
  */
197
349
  _releaseRing() {
198
350
  const ring = this._ring;
@@ -201,7 +353,58 @@ export class CocoaPaneWindow {
201
353
  if (!ring) return;
202
354
  const release = this._native.releaseSurface;
203
355
  if (typeof release !== 'function') return;
204
- for (const s of ring) release.call(this._native, s.handle);
356
+ for (const s of ring) {
357
+ if (s.presentedSeq > this._shownSeq) this._retired.push(s);
358
+ else release.call(this._native, s.handle);
359
+ }
360
+ while (this._retired.length > CocoaPaneWindow.RING) {
361
+ release.call(this._native, this._retired.shift().handle);
362
+ }
363
+ }
364
+
365
+ /**
366
+ * The host has looked up every present up to `seq` (`pane-shown`): the
367
+ * retired buffers they named are its now, or nobody's, and go.
368
+ */
369
+ _shown(seq) {
370
+ if (!(seq > this._shownSeq)) return;
371
+ // the first of a run of answers: what was looked up before it
372
+ if (!this._kickQueued) this._shownBefore = this._shownSeq;
373
+ this._shownSeq = seq;
374
+ this._shownAt = performance.now();
375
+ if (this._retired.length > 0) {
376
+ const release = this._native.releaseSurface;
377
+ this._retired = this._retired.filter((s) => {
378
+ if (s.presentedSeq > seq) return true;
379
+ release.call(this._native, s.handle);
380
+ return false;
381
+ });
382
+ }
383
+ // A frame the clock held for want of a buffer (`frameHeld`) goes now,
384
+ // not on the next pump tick — once every answer that came with this one
385
+ // is in. A host back from a long frame answers a run of presents at
386
+ // once, the channel hands them over in one turn, and a frame run on the
387
+ // first found only the buffer the host had shown all along.
388
+ if (this._kickQueued) return;
389
+ this._kickQueued = true;
390
+ queueMicrotask(() => {
391
+ this._kickQueued = false;
392
+ const app = this.app;
393
+ if (!this.destroyed && app._rafQueue.some((e) => e.wnd === this)) {
394
+ app._tickFrames();
395
+ app._presentAll();
396
+ }
397
+ });
398
+ }
399
+
400
+ /** Every retired buffer, answered or not: the window is going, and so is
401
+ * the layer the host showed it on. */
402
+ _releaseRetired() {
403
+ const retired = this._retired;
404
+ this._retired = [];
405
+ const release = this._native.releaseSurface;
406
+ if (typeof release !== 'function') return;
407
+ for (const s of retired) release.call(this._native, s.handle);
205
408
  }
206
409
 
207
410
  _ensureSurface() {
@@ -230,10 +433,88 @@ export class CocoaPaneWindow {
230
433
  // decided when the flush reports its rects — see CocoaWindow's
231
434
  // `_ensureSurface` for why not a queued full frame from here
232
435
  if (hadSurface) this._freshSurface = true;
436
+ } else if (!this._surface) {
437
+ this._takeBack();
233
438
  }
234
439
  return this._surface;
235
440
  }
236
441
 
442
+ /**
443
+ * The buffer the frame about to be drawn goes into, taken at its first
444
+ * draw rather than at the present before it, so the host has had as long
445
+ * as it can to answer — CocoaWindow's `_takeBack`, over a ring the host
446
+ * reads from another process.
447
+ *
448
+ * One the host is done with (`_done`): a present after the last one that
449
+ * named it has been looked up, so it is neither on the layer nor on its
450
+ * way there. Of those, one the WindowServer has let go of as well —
451
+ * `surfaceIsInUse` false: it reads a buffer for a refresh or two after
452
+ * the layer has another, 8ms after the pane hears the answer at the
453
+ * median and 22 at the 90th percentile in the browser example — and of
454
+ * those the one presented longest ago. The clock holds a frame that would
455
+ * find none (`frameHeld`). One that comes anyway, once the WindowServer
456
+ * has held on for a frame interval, takes one it still reads — but the
457
+ * one the screen showed before the host's latest answers last, which a
458
+ * host back from a long frame has yet to commit the replacement for; and
459
+ * once the host is overdue, it takes a buffer whose present the host has
460
+ * yet to look up
461
+ * rather than the one on the layer: a host that comes back looks up
462
+ * everything queued in one turn, and only the last of it reaches glass,
463
+ * where the buffer on the layer is on glass now.
464
+ *
465
+ * Then the frame just presented, copied across whole: in a ring of three
466
+ * the buffer taken was last drawn two presents ago or more, and is stale
467
+ * everywhere. After a full frame as much as a partial one: what a frame
468
+ * was says nothing of what the next will be, and skipping the copy there
469
+ * had a partial frame after a full one — a status line, a hover's
470
+ * underline — drawn over the page as it was two frames back. The copy is
471
+ * wasted only where the next frame is full as well.
472
+ */
473
+ _takeBack() {
474
+ const ring = this._ring;
475
+ const last = ring[this._shownIndex];
476
+ let pick = -1;
477
+ let rank = Infinity;
478
+ for (let i = 0; i < ring.length; i += 1) {
479
+ const s = ring[i];
480
+ if (s === last) continue;
481
+ // done and let go of, then done, then on its way, then on the layer:
482
+ // the oldest of the best — but of the done, the one the screen showed
483
+ // before the host's latest answers last
484
+ const kind = this._done(s)
485
+ ? this._held(s)
486
+ ? 1 + Number(s.presentedSeq === this._shownBefore)
487
+ : 0
488
+ : s.presentedSeq > this._shownSeq
489
+ ? 3
490
+ : 4;
491
+ const r = kind * 2 ** 32 + (s.presentedSeq ?? 0);
492
+ if (r < rank) {
493
+ rank = r;
494
+ pick = i;
495
+ }
496
+ }
497
+ const back = ring[pick];
498
+ this._drawIndex = pick;
499
+ this._surface = back.handle;
500
+ // a different native surface owns the graphics state now — the context
501
+ // re-syncs its sticky state off the generation
502
+ this._surfaceGen++;
503
+ this._native.surfaceLock(back.handle);
504
+ if (last) this._native.copySurfaceRegion(last.handle, back.handle, null);
505
+ }
506
+
507
+ /** Whether the WindowServer still reads `buffer`: `IOSurfaceIsInUse`,
508
+ * which counts the host's render server from this process too. A bridge
509
+ * that cannot say (before 0.10) is answered no. */
510
+ _held(buffer) {
511
+ const inUse = this._native.surfaceIsInUse;
512
+ return (
513
+ typeof inUse === 'function' &&
514
+ inUse.call(this._native, buffer.handle) === true
515
+ );
516
+ }
517
+
237
518
  getContext() {
238
519
  if (!this._ctx) {
239
520
  this._ctx = new BackendContext2D(
@@ -278,10 +559,12 @@ export class CocoaPaneWindow {
278
559
  }
279
560
 
280
561
  scrollRegion(rect, dx, dy) {
281
- if (!this._surface) return false;
562
+ if (!this._ring) return false;
282
563
  if (!Number.isInteger(dx) || !Number.isInteger(dy)) return false;
564
+ // the frame's first draw, which takes its buffer (`_takeBack`)
565
+ const surface = this._ensureSurface();
283
566
  const moved = this._native.scrollSurface(
284
- this._surface,
567
+ surface,
285
568
  Math.round(rect.x),
286
569
  Math.round(rect.y),
287
570
  Math.round(rect.width),
@@ -301,33 +584,28 @@ export class CocoaPaneWindow {
301
584
  const shown = this._ring[this._drawIndex];
302
585
  this._native.surfaceUnlock(shown.handle);
303
586
  this._flushDamage = null;
587
+ // the buffer is named in the channel until the host says otherwise
588
+ // (`_releaseRing`, `_done`)
589
+ shown.presentedSeq = ++this._seq;
590
+ this._presentedAt = performance.now();
591
+ this._shownIndex = this._drawIndex;
592
+ // the next frame takes its buffer when it draws (`_takeBack`)
593
+ this._drawIndex = -1;
594
+ this._surface = null;
595
+ // last: the answer runs a held frame (`_shown`), which must find this
596
+ // present made
304
597
  this._post({
305
598
  type: 'pane-present',
306
- seq: ++this._seq,
599
+ seq: this._seq,
307
600
  id: shown.iosurfaceId,
308
601
  width: this.width,
309
602
  height: this.height,
310
603
  });
311
- this._presentedAt = performance.now();
312
- this._shownIndex = this._drawIndex;
313
- // the next buffer round the ring — two behind what the host will be
314
- // showing, so it is safe to write even before the host has switched
315
- this._drawIndex = (this._drawIndex + 1) % CocoaPaneWindow.RING;
316
- const next = this._ring[this._drawIndex];
317
- this._surface = next.handle;
318
- this._surfaceGen++;
319
- this._native.surfaceLock(next.handle);
320
- // The next frame may paint only its damage, so `next` must first hold
321
- // the last complete frame underneath — and the WHOLE of it, not just
322
- // this frame's rects, because in a three-buffer ring `next` was last
323
- // drawn two presents ago and is stale everywhere. A full copy of the
324
- // just-shown buffer is the complete background; the safe target is what
325
- // triple buffering buys. After a full frame as much as a partial one:
326
- // what a frame was says nothing of what the next will be, and skipping
327
- // the copy there had a partial frame after a full one — a status line,
328
- // a hover's underline — drawn over the page as it was two frames back.
329
- // The copy is wasted only where the next frame is full as well.
330
- this._native.copySurfaceRegion(shown.handle, next.handle, null);
604
+ // and the size the host last sent is on screen (`pane-sized`)
605
+ if (this._owesSized) {
606
+ this._owesSized = false;
607
+ this._post({ type: 'pane-sized' });
608
+ }
331
609
  }
332
610
 
333
611
  snapshot() {
@@ -338,7 +616,10 @@ export class CocoaPaneWindow {
338
616
  if (this.destroyed) return;
339
617
  this.destroyed = true;
340
618
  this.app._unregisterWindow(this);
619
+ clearTimeout(this._pollTimer);
620
+ this._pollTimer = null;
341
621
  this._releaseRing();
622
+ this._releaseRetired();
342
623
  }
343
624
  }
344
625
 
@@ -975,7 +975,15 @@ export class CocoaWindow {
975
975
  * painting a state the next event in the same batch replaces.
976
976
  */
977
977
  frameInFlight() {
978
- if (this.app._batching) return true;
978
+ return this.app._batching || this.frameHeld();
979
+ }
980
+
981
+ /**
982
+ * Whether the frame clock holds this window's next frame
983
+ * (`CocoaApp._tickFrames`): the fence half of `frameInFlight`, without the
984
+ * batch, which the clock never ticks inside.
985
+ */
986
+ frameHeld() {
979
987
  return (
980
988
  this._awaiting != null &&
981
989
  this._surfaceSize?.width === this.width &&
@@ -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;