react-x11 2.38.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.
@@ -27,7 +27,10 @@
27
27
  // `@windowkit/appkit` 0.19 (`transformForms()`) along with the negative
28
28
  // delay a part joined half way through needs, so that is the bridge sprites
29
29
  // ask for: on an older one nothing is asked and every part is drawn by its
30
- // element.
30
+ // element. Or it is CSS's `matrix3d()`, a part turned out of the plane and
31
+ // seen in a perspective, which Core Animation draws through the whole
32
+ // matrix on the GPU: a part none of whose corners is behind the viewer, on a
33
+ // bridge that takes one (`matrix3d`).
31
34
  //
32
35
  // A part none of which shows — scrolled out of its pane, or outside its own
33
36
  // clip — keeps its layer, cut to show nothing, up to a budget of raster for
@@ -68,6 +71,14 @@ const AFTER_CHILDREN = Number.MAX_SAFE_INTEGER;
68
71
 
69
72
  const IDENTITY = Object.freeze([1, 0, 0, 1, 0, 0]);
70
73
 
74
+ // what a part with no bounds may reach (`_underDrawn`)
75
+ const EVERYWHERE = Object.freeze({
76
+ x: -1e9,
77
+ y: -1e9,
78
+ width: 2e9,
79
+ height: 2e9,
80
+ });
81
+
71
82
  const ORIGIN = Object.freeze({ x: 0, y: 0 });
72
83
 
73
84
  // what a part with no part lifted inside it is handed
@@ -98,7 +109,20 @@ const isRect = (r) =>
98
109
  r.width > 0 &&
99
110
  r.height > 0;
100
111
 
101
- const isMatrix = (m) => Array.isArray(m) && m.length === 6 && m.every(finite);
112
+ // CSS's `matrix(a, b, c, d, e, f)`, or its `matrix3d()`'s sixteen numbers
113
+ const isMatrix = (m) =>
114
+ Array.isArray(m) && (m.length === 6 || m.length === 16) && m.every(finite);
115
+
116
+ const isSolid = (m) => m.length === 16;
117
+
118
+ /** Does `sprite` turn out of the plane, at rest or in an animation? */
119
+ function solidSprite(sprite) {
120
+ if (sprite.transform && isSolid(sprite.transform)) return true;
121
+ for (const a of sprite.animations ?? []) {
122
+ if (a.property === 'transform' && a.values.some(isSolid)) return true;
123
+ }
124
+ return false;
125
+ }
102
126
 
103
127
  const isCurve = (t) =>
104
128
  typeof t === 'string' ||
@@ -203,12 +227,15 @@ const originOf = (sprite) =>
203
227
  y: sprite.rect.y + sprite.rect.height / 2,
204
228
  };
205
229
 
206
- /** `rect` through `m` about `origin`: the bounds of its four corners. */
230
+ /** `rect` through `m` about `origin`: the bounds of its four corners, or
231
+ * null where `m` is a matrix3d that puts a corner behind the viewer, past
232
+ * which the plane's picture has no bounds. */
207
233
  function mapRect(rect, m, origin) {
208
234
  let x0 = Infinity;
209
235
  let y0 = Infinity;
210
236
  let x1 = -Infinity;
211
237
  let y1 = -Infinity;
238
+ const solid = isSolid(m);
212
239
  for (const [px, py] of [
213
240
  [rect.x, rect.y],
214
241
  [rect.x + rect.width, rect.y],
@@ -217,8 +244,18 @@ function mapRect(rect, m, origin) {
217
244
  ]) {
218
245
  const dx = px - origin.x;
219
246
  const dy = py - origin.y;
220
- const x = m[0] * dx + m[2] * dy + m[4] + origin.x;
221
- const y = m[1] * dx + m[3] * dy + m[5] + origin.y;
247
+ let x;
248
+ let y;
249
+ if (solid) {
250
+ // column-major, as `matrix3d()` writes it: the point (dx, dy, 0, 1)
251
+ const w = m[3] * dx + m[7] * dy + m[15];
252
+ if (!(w > W_NEAR)) return null;
253
+ x = (m[0] * dx + m[4] * dy + m[12]) / w + origin.x;
254
+ y = (m[1] * dx + m[5] * dy + m[13]) / w + origin.y;
255
+ } else {
256
+ x = m[0] * dx + m[2] * dy + m[4] + origin.x;
257
+ y = m[1] * dx + m[3] * dy + m[5] + origin.y;
258
+ }
222
259
  if (x < x0) x0 = x;
223
260
  if (y < y0) y0 = y;
224
261
  if (x > x1) x1 = x;
@@ -227,6 +264,10 @@ function mapRect(rect, m, origin) {
227
264
  return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
228
265
  }
229
266
 
267
+ // A corner whose w is this close to the viewer's plane is as good as behind
268
+ // it: projected that near, it lands off any window.
269
+ const W_NEAR = 1e-6;
270
+
230
271
  function unionRect(a, b) {
231
272
  if (!a) return b;
232
273
  const x = Math.min(a.x, b.x);
@@ -242,7 +283,8 @@ function unionRect(a, b) {
242
283
  /**
243
284
  * Everywhere `sprite` can draw while its animations run: its reach through
244
285
  * its own transform and every transform it is animated through, out to
245
- * whole pixels and the antialiasing a raster is padded for. Between two
286
+ * whole pixels and the antialiasing a raster is padded for — or null, where
287
+ * one of those puts a corner of it behind the viewer. Between two
246
288
  * keyframes Core Animation interpolates the matrices by their parts, which
247
289
  * stays within a pixel of the two for the densely sampled keyframes a CSS
248
290
  * transform arrives as; an element whose keyframes are far apart, or whose
@@ -271,12 +313,18 @@ function extentOf(sprite) {
271
313
  if (about?.key !== key) {
272
314
  let box = mapRect(rel, m0, ORIGIN);
273
315
  for (const a of sprite.animations ?? []) {
274
- if (a.property !== 'transform') continue;
275
- for (const m of a.values) box = unionRect(box, mapRect(rel, m, ORIGIN));
316
+ if (a.property !== 'transform' || !box) continue;
317
+ for (const m of a.values) {
318
+ const at = mapRect(rel, m, ORIGIN);
319
+ box = at && unionRect(box, at);
320
+ if (!box) break;
321
+ }
276
322
  }
277
323
  about = { key, box };
278
324
  if (turns) EXTENTS.set(turns, about);
279
325
  }
326
+ // a corner behind the viewer, at rest or on the way: nowhere bounded
327
+ if (!about.box) return null;
280
328
  const box = {
281
329
  x: about.box.x + origin.x,
282
330
  y: about.box.y + origin.y,
@@ -311,7 +359,31 @@ function rasterRectOf(sprite) {
311
359
  }
312
360
 
313
361
  const sameMatrix = (a, b) =>
314
- a === b || (a != null && b != null && a.every((v, i) => v === b[i]));
362
+ a === b ||
363
+ (a != null &&
364
+ b != null &&
365
+ a.length === b.length &&
366
+ a.every((v, i) => v === b[i]));
367
+
368
+ /**
369
+ * A part's matrix in device pixels as the bridge takes it, in points: its
370
+ * translation over the scale and, for a matrix3d, its perspective row times
371
+ * it — S⁻¹·M·S, with S scaling x, y and z, so that a turn and a perspective
372
+ * of so many CSS pixels look the same at any scale.
373
+ */
374
+ function inPoints(m, s) {
375
+ if (!isSolid(m)) {
376
+ return { matrix: [m[0], m[1], m[2], m[3], m[4] / s, m[5] / s] };
377
+ }
378
+ const p = m.slice();
379
+ p[12] /= s;
380
+ p[13] /= s;
381
+ p[14] /= s;
382
+ p[3] *= s;
383
+ p[7] *= s;
384
+ p[11] *= s;
385
+ return { matrix3d: p };
386
+ }
315
387
 
316
388
  /**
317
389
  * The lifted parts of every element in one window: a layer each, its raster,
@@ -326,9 +398,10 @@ export class SpriteLayers {
326
398
  this.app = promotion.app;
327
399
  // a matrix and a negative delay are what a CSS animation needs from the
328
400
  // bridge, and @windowkit/appkit 0.19 is where both are
329
- this.supported = Boolean(
330
- this.native.transformForms?.()?.includes?.('matrix'),
331
- );
401
+ const forms = this.native.transformForms?.();
402
+ this.supported = Boolean(forms?.includes?.('matrix'));
403
+ // and a part turned out of the plane, its matrix3d, the same release
404
+ this.solid = Boolean(forms?.includes?.('matrix3d'));
332
405
  // and a video's frames on a layer need its video surfaces
333
406
  this.videos = canLiftVideo(this.native);
334
407
  this.hosts = new Map(); // element -> Map(key -> state)
@@ -429,7 +502,12 @@ export class SpriteLayers {
429
502
  radius: sprite.clip ? (sprite.clipRadius ?? 0) : 0,
430
503
  bounds: null,
431
504
  };
505
+ // turned out of the plane, on a bridge that takes no matrix3d
506
+ if (!this.solid && solidSprite(sprite)) continue;
432
507
  const extent = extentOf(sprite);
508
+ // a corner behind the viewer: the element draws it, which it can cut
509
+ // where the plane meets the viewer's
510
+ if (!extent) continue;
433
511
  const bounds = cut.clip ? intersectRects(extent, cut.clip) : extent;
434
512
  if (parent) {
435
513
  // the parent was asked about everywhere it can be, and its layer
@@ -544,8 +622,9 @@ export class SpriteLayers {
544
622
  drawn.push(state.extent);
545
623
  continue;
546
624
  }
547
- // the element draws it, where its own clip lets it show
548
- const extent = extentOf(sprite);
625
+ // the element draws it, where its own clip lets it show — anywhere,
626
+ // for one with a corner behind the viewer and no clip
627
+ const extent = extentOf(sprite) ?? EVERYWHERE;
549
628
  const shows = sprite.clip ? intersectRects(extent, sprite.clip) : extent;
550
629
  if (shows) drawn.push(shows);
551
630
  }
@@ -776,7 +855,7 @@ export class SpriteLayers {
776
855
  }
777
856
  }
778
857
  if (!sameMatrix(prev.matrix, m)) {
779
- out.transform = { matrix: [m[0], m[1], m[2], m[3], m[4] / s, m[5] / s] };
858
+ out.transform = inPoints(m, s);
780
859
  any = true;
781
860
  }
782
861
  if (any) this.native.setLayerProps(state.layer, out);
@@ -843,9 +922,7 @@ export class SpriteLayers {
843
922
  const opts = {
844
923
  values:
845
924
  a.property === 'transform'
846
- ? a.values.map((m) => ({
847
- matrix: [m[0], m[1], m[2], m[3], m[4] / s, m[5] / s],
848
- }))
925
+ ? a.values.map((m) => inPoints(m, s))
849
926
  : a.values,
850
927
  duration: a.duration / 1000,
851
928
  id: caId,
@@ -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;
@@ -4,7 +4,7 @@
4
4
 
5
5
  import React, { useEffect, useRef, useState } from 'react';
6
6
  import { createStyles } from '../styles.js';
7
- import { useClipboard } from '../appcontext.js';
7
+ import { useAppOrNull, useClipboard } from '../appcontext.js';
8
8
  import { windowIdOf } from '../windowid.js';
9
9
  import { useTheme } from './theme.js';
10
10
  import { Icon } from './Icon.js';
@@ -19,6 +19,7 @@ import {
19
19
  XK_KP_ENTER,
20
20
  XK_RETURN,
21
21
  ctrlChordLetter,
22
+ primaryModifierHeld,
22
23
  } from '../keysyms.js';
23
24
 
24
25
  const h = React.createElement;
@@ -169,6 +170,7 @@ export function PasswordInput({
169
170
  const inputRef = useRef(null);
170
171
  const hideTimer = useRef(null);
171
172
  const clipboard = useClipboard();
173
+ const app = useAppOrNull();
172
174
  const [ownValue, setOwnValue] = useState(defaultValue ?? '');
173
175
  const [ownRevealed, setOwnRevealed] = useState(false);
174
176
  const [focused, setFocused] = useState(false);
@@ -282,13 +284,16 @@ export function PasswordInput({
282
284
  if (ev.shiftKey) paste('CLIPBOARD');
283
285
  return;
284
286
  }
285
- if (ev.ctrlKey) {
287
+ if (ev.ctrlKey || ev.metaKey) {
286
288
  const letter = ctrlChordLetter(ev);
287
289
  // Ctrl+U clears the line, as readline and every pinentry on the desktop
288
- // do; Ctrl+V pastes. Nothing for C, X or A — there is no selection to
289
- // copy, and the secret does not leave by the clipboard.
290
- if (letter === CTRL_U) commit('');
291
- else if (letter === CTRL_V) paste('CLIPBOARD');
290
+ // do; Ctrl+V pastes — ⌘V on the Cocoa backend, where ⌃U still clears,
291
+ // as it does in a Mac terminal. Nothing for C, X or A — there is no
292
+ // selection to copy, and the secret does not leave by the clipboard.
293
+ // Any other chord is a command and never a character of the secret.
294
+ if (letter === CTRL_U && ev.ctrlKey) commit('');
295
+ else if (letter === CTRL_V && primaryModifierHeld(ev, app))
296
+ paste('CLIPBOARD');
292
297
  return;
293
298
  }
294
299
  // A printable character and only that: a control code is not text, and
package/src/editmenu.js CHANGED
@@ -86,6 +86,9 @@ export function editMenuColors(theme) {
86
86
  export function editMenuItems(actions = {}, { canPaste = true } = {}) {
87
87
  const has = (verb) => typeof actions[verb] === 'function';
88
88
  const selected = Boolean(actions.hasSelection);
89
+ // `Control` is the primary modifier here, as it is in a menu bar's chord:
90
+ // ⌘ on the Cocoa backend, which is what the editors answer there and what
91
+ // `editMenuGeometry` prints when it is told so
89
92
  const groups = [
90
93
  [
91
94
  has('undo') && {
@@ -147,8 +150,12 @@ export function editMenuItems(actions = {}, { canPaste = true } = {}) {
147
150
  * @param {Array<{id?, label?, shortcut?, type?, enabled?}>} items
148
151
  * @param {(text: string) => number|null} [measure] text width, or null when
149
152
  * nothing can be measured yet
153
+ * @param {number} [scale]
154
+ * @param {{primary?: 'Control'|'Super'}} [options] the app's primary
155
+ * modifier (keysyms.js): `'Super'` prints the shortcuts the Mac way —
156
+ * `⌘Z`, `⇧⌘Z` — since ⌘ is what the text controls answer there
150
157
  */
151
- export function editMenuGeometry(items, measure, scale = 1) {
158
+ export function editMenuGeometry(items, measure, scale = 1, { primary } = {}) {
152
159
  const widthOf = (text) => {
153
160
  if (!text) return 0;
154
161
  const w = measure?.(text);
@@ -161,7 +168,7 @@ export function editMenuGeometry(items, measure, scale = 1) {
161
168
  let widest = 0;
162
169
  for (const item of items) {
163
170
  const separator = isSeparator(item);
164
- const shortcut = formatShortcut(item.shortcut);
171
+ const shortcut = formatShortcut(item.shortcut, { primary });
165
172
  const height = (separator ? SEPARATOR_HEIGHT : ROW_HEIGHT) * scale;
166
173
  rows.push({ ...item, separator, shortcut, y, height });
167
174
  y += height;
package/src/events.js CHANGED
@@ -1705,6 +1705,11 @@ export class EventManager {
1705
1705
  *
1706
1706
  * `entry.handle(ev)` answers whether it took the key.
1707
1707
  *
1708
+ * `entry.reserves(ev)`, optional, answers whether something *outside* the
1709
+ * window is going to answer this key whatever we do — the macOS menu
1710
+ * bar's key equivalents (`MenuBar`, delegated). Such a key is spent
1711
+ * before any binding is offered it (`_runAccelerators`).
1712
+ *
1708
1713
  * Registrations live on the **top-level** manager, which is the one thing
1709
1714
  * a `<popup>`, a nested `<window>` and the window itself all share — the
1710
1715
  * same grouping `_keyManager` resolves keys through. So a chord reaches
@@ -1740,11 +1745,30 @@ export class EventManager {
1740
1745
  * A menu that is *open* suppresses them by another route entirely: it
1741
1746
  * `preventDefault()`s the keys it is being driven with, one layer above
1742
1747
  * this (`components/Menu.js`).
1748
+ *
1749
+ * And a key one of them *reserves* reaches none of them. The Cocoa bridge
1750
+ * shows us every key before `[NSApp sendEvent:]`, where the menu bar's
1751
+ * key equivalent then runs its item regardless — so a binding here that
1752
+ * answered ⌘S too would be a second command on one key, whichever of the
1753
+ * two mounted last. Neither gate applies to that: the menu bar is not in
1754
+ * the window, and a modal `<popup>` does not take keys from it.
1743
1755
  */
1744
1756
  _runAccelerators(ev) {
1745
1757
  const owner = this.topLevelManager;
1746
1758
  const entries = owner._accelerators;
1747
1759
  if (!entries?.size) return;
1760
+ for (const entry of entries) {
1761
+ let reserved = false;
1762
+ try {
1763
+ reserved = Boolean(entry.reserves?.(ev));
1764
+ } catch (error) {
1765
+ reportHandlerError(entry.anchor(), 'an accelerator', error);
1766
+ }
1767
+ if (reserved) {
1768
+ ev.preventDefault();
1769
+ return;
1770
+ }
1771
+ }
1748
1772
  const scopeRoot = owner._scopeRoot();
1749
1773
  const trapped = scopeRoot !== owner.node;
1750
1774
  for (const entry of [...entries].reverse()) {
@@ -624,7 +624,7 @@ function PaneHostView({
624
624
  host.windowCursor?.(msg);
625
625
  } else if (msg?.type === 'pane-present') {
626
626
  host.setRect(node.abs);
627
- host.present(msg.id);
627
+ host.present(msg.id, msg);
628
628
  } else if (msg?.type === 'pane-cursor') {
629
629
  // The pane names its cursor as an element that draws names one for
630
630
  // a point: this box is the node the host's pointer is over, so the
package/src/globalmenu.js CHANGED
@@ -54,7 +54,7 @@
54
54
  // third-party menus consume dbusmenu. A second exporter is roughly double the
55
55
  // work for no consumer that the first one does not already reach.
56
56
 
57
- import { useEffect, useRef, useState } from 'react';
57
+ import { useCallback, useEffect, useRef, useState } from 'react';
58
58
 
59
59
  import { loadTransport, sessionBus } from './bus.js';
60
60
  import { desktopIntegrationEnabled } from './desktopintegration.js';
@@ -539,7 +539,20 @@ export function _resetGlobalMenuState() {
539
539
  * it. It is exported for a component that draws its own bar and wants the
540
540
  * same behaviour.
541
541
  */
542
- export function useGlobalMenu(
542
+ export function useGlobalMenu(menus, options) {
543
+ return useGlobalMenuExport(menus, options).delegated;
544
+ }
545
+
546
+ /**
547
+ * `useGlobalMenu`, with the one more thing `MenuBar` needs to know about the
548
+ * delegate: which shortcuts it answers by itself. A panel answers none — it
549
+ * draws the rows, and the key is pressed in our window — but the macOS menu
550
+ * bar answers the ⌘ chords it holds as key equivalents, and a bar that
551
+ * matched them as well would run the item twice
552
+ * (`CocoaGlobalMenuExport.answersShortcut`). Read at key time, so it is
553
+ * whatever delegate is live then. Not public.
554
+ */
555
+ export function useGlobalMenuExport(
543
556
  menus,
544
557
  { onSelect, onAboutToShow, enabled = true } = {},
545
558
  ) {
@@ -586,5 +599,10 @@ export function useGlobalMenu(
586
599
  exportRef.current?.update(menus ?? []);
587
600
  }, [menus]);
588
601
 
589
- return enabled && exported;
602
+ const answersShortcut = useCallback(
603
+ (shortcut) => Boolean(exportRef.current?.answersShortcut?.(shortcut)),
604
+ [],
605
+ );
606
+
607
+ return { delegated: enabled && exported, answersShortcut };
590
608
  }
package/src/index.d.ts CHANGED
@@ -290,7 +290,10 @@ export interface RootOptions {
290
290
  * `resizeWait` is how long, in ms, AppKit may hold a live-resize tick for
291
291
  * the app's frame at the new size under `react-x11/cocoa-main`, where
292
292
  * the frame is painted on another thread (50 by default; 0 lets the edge
293
- * move without waiting).
293
+ * move without waiting). A `<Frame>` pane's frames of a new size are held
294
+ * to it as well: within it, the pane's last frame stays at its size while
295
+ * the next comes, and past it, it is stretched to the pane's new size, as
296
+ * every one is at 0.
294
297
  * `screenPoll` is how often the screen layout is re-read while a
295
298
  * {@link useScreens} subscriber is mounted, in ms — 500 by default, 0 for
296
299
  * never. macOS has no event for a display plugged in, unplugged or
package/src/keysyms.d.ts CHANGED
@@ -50,6 +50,36 @@ export function ctrlChordLetter(ev: {
50
50
  codepoint?: number | null;
51
51
  }): number | null;
52
52
 
53
+ /** The modifier token a shortcut's primary means, in a menu `shortcut`'s
54
+ * vocabulary. */
55
+ export type PrimaryModifier = 'Control' | 'Super';
56
+
57
+ /**
58
+ * The modifier this app's shortcuts are pressed with: `'Super'` — ⌘ — where
59
+ * the backend says so (the Cocoa app does, as `app.primaryModifier`), and
60
+ * `'Control'` everywhere else. A backend fact, not a platform one: an X11
61
+ * app under XQuartz keeps Ctrl, like every other X client on that display.
62
+ */
63
+ export function primaryModifier(
64
+ app?: { primaryModifier?: PrimaryModifier } | null,
65
+ ): PrimaryModifier;
66
+
67
+ /**
68
+ * Is the primary modifier down for this event — ⌘ on the Cocoa backend, Ctrl
69
+ * everywhere else? What the built-in editors test before their
70
+ * Undo/Cut/Copy/Paste/Select All chords, so the keys the edit menu prints
71
+ * are the keys that do it.
72
+ *
73
+ * ```js
74
+ * if (primaryModifierHeld(ev, node.app) && ctrlChordLetter(ev) === keysymOf('d'))
75
+ * duplicateLine();
76
+ * ```
77
+ */
78
+ export function primaryModifierHeld(
79
+ ev: { ctrlKey?: boolean; metaKey?: boolean } | null | undefined,
80
+ app?: { primaryModifier?: PrimaryModifier } | null,
81
+ ): boolean;
82
+
53
83
  /**
54
84
  * The keysym an X11 key *name* stands for — `'Return'`, `'plus'`, `'F5'`,
55
85
  * `'s'` — or `undefined` for a name nothing here knows.
package/src/keysyms.js CHANGED
@@ -247,6 +247,35 @@ export function ctrlChordLetter(ev) {
247
247
  return code >= 0x41 && code <= 0x5a ? code + 0x20 : code;
248
248
  }
249
249
 
250
+ /**
251
+ * The modifier this app's shortcuts are pressed with: `'Super'` — ⌘ — where
252
+ * the backend says so (the Cocoa app does, as `app.primaryModifier`), and
253
+ * `'Control'` everywhere else.
254
+ *
255
+ * A backend fact, not a platform one: an X11 app under XQuartz keeps Ctrl,
256
+ * which is what every other X client on that display answers to.
257
+ */
258
+ export function primaryModifier(app) {
259
+ return app?.primaryModifier === 'Super' ? 'Super' : 'Control';
260
+ }
261
+
262
+ /**
263
+ * Is the primary modifier down for this event — ⌘ on the Cocoa backend,
264
+ * Ctrl everywhere else? The test the built-in editors put in front of their
265
+ * Undo/Cut/Copy/Paste/Select All chords, so the keys the edit menu prints
266
+ * beside each row are the keys that do it.
267
+ *
268
+ * ```js
269
+ * if (primaryModifierHeld(ev, node.app) && ctrlChordLetter(ev) === keysymOf('d'))
270
+ * duplicateLine();
271
+ * ```
272
+ */
273
+ export function primaryModifierHeld(ev, app) {
274
+ return primaryModifier(app) === 'Super'
275
+ ? Boolean(ev?.metaKey)
276
+ : Boolean(ev?.ctrlKey);
277
+ }
278
+
250
279
  /**
251
280
  * The X11 modifier mask bits, as they arrive on `ev.nativeEvent.buttons`
252
281
  * and as `fireEvent` takes them. Bit 3 (Mod1) is Alt and bit 6 (Mod4) is