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.
@@ -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
package/src/menuitem.js CHANGED
@@ -112,21 +112,62 @@ const KEY_LABELS = {
112
112
  Next: 'PgDn',
113
113
  };
114
114
 
115
+ // The Mac prints a chord as glyphs, in one fixed order whatever order the
116
+ // chord was written in — ⌃⌥⇧⌘ — and with nothing between them: `⇧⌘Z`, never
117
+ // `Cmd+Shift+Z`. `Control` and `Super` are both ⌘ there, for the reason
118
+ // `primaryModifier` gives below: a cross-platform chord is written in the
119
+ // primary modifier, and the Mac's is Command.
120
+ const MAC_MODIFIER_ORDER = ['Alt', 'Shift', 'Command'];
121
+ const MAC_MODIFIER_GLYPHS = { Alt: '⌥', Shift: '⇧', Command: '⌘' };
122
+
123
+ // The keys a Mac menu draws as a glyph rather than a word. The rest are
124
+ // `KEY_LABELS`' answer, which is already the Mac's for punctuation.
125
+ const MAC_KEY_LABELS = {
126
+ Return: '↩',
127
+ KP_Enter: '⌤',
128
+ Tab: '⇥',
129
+ BackSpace: '⌫',
130
+ Delete: '⌦',
131
+ Escape: '⎋',
132
+ Left: '←',
133
+ Right: '→',
134
+ Up: '↑',
135
+ Down: '↓',
136
+ Prior: '⇞',
137
+ Next: '⇟',
138
+ Home: '↖',
139
+ End: '↘',
140
+ };
141
+
142
+ const keyLabel = (key, labels) =>
143
+ labels[key] ??
144
+ KEY_LABELS[key] ??
145
+ (key.length === 1 ? key.toUpperCase() : key);
146
+
115
147
  /**
116
148
  * One `aas` alternative — `['Control', 'S']` — as the string a menu row
117
- * shows: `Ctrl+S`.
149
+ * shows: `Ctrl+S`, or `⌘S` where the primary modifier is ⌘.
118
150
  *
119
151
  * A single-character key is upper-cased because that is how every toolkit
120
152
  * prints one, and because dbusmenu carries the *key*, not the character it
121
153
  * types: `['Control', 's']` and `['Control', 'S']` are the same binding and
122
154
  * must not render differently.
123
155
  */
124
- function formatChord(chord) {
156
+ function formatChord(chord, primary) {
125
157
  const { modifiers, key } = splitChord(chord);
126
- const parts = [...modifiers].map((token) => MODIFIER_LABELS[token]);
127
- if (key != null) {
128
- parts.push(KEY_LABELS[key] ?? (key.length === 1 ? key.toUpperCase() : key));
158
+ if (primary === 'Super') {
159
+ const held = new Set(
160
+ [...modifiers].map((token) =>
161
+ token === 'Control' || token === 'Super' ? 'Command' : token,
162
+ ),
163
+ );
164
+ const glyphs = MAC_MODIFIER_ORDER.filter((token) => held.has(token))
165
+ .map((token) => MAC_MODIFIER_GLYPHS[token])
166
+ .join('');
167
+ return glyphs + (key == null ? '' : keyLabel(key, MAC_KEY_LABELS));
129
168
  }
169
+ const parts = [...modifiers].map((token) => MODIFIER_LABELS[token]);
170
+ if (key != null) parts.push(keyLabel(key, {}));
130
171
  return parts.join('+');
131
172
  }
132
173
 
@@ -137,11 +178,14 @@ function formatChord(chord) {
137
178
  * modifier tokens ending in the key. Only the first alternative is drawn —
138
179
  * a row has one shortcut column, and a menu that printed `Ctrl+S / F2` in it
139
180
  * would be wider than the menu for the benefit of the rarer binding.
181
+ *
182
+ * `primary` is the app's `primaryModifier` (keysyms.js): `'Super'` draws the
183
+ * chord the way a Mac menu does, `⇧⌘Z` for `['Control', 'Shift', 'Z']`.
140
184
  */
141
- export function formatShortcut(shortcut) {
185
+ export function formatShortcut(shortcut, { primary = 'Control' } = {}) {
142
186
  const first = shortcut?.[0];
143
187
  if (!Array.isArray(first) || first.length === 0) return '';
144
- return formatChord(first);
188
+ return formatChord(first, primary);
145
189
  }
146
190
 
147
191
  // What UI Events calls the modifiers, which is what `aria-keyshortcuts` is
@@ -160,19 +204,21 @@ const ARIA_MODIFIERS = {
160
204
  * dbusmenu's `aas` already has — so unlike the drawn menu, **every**
161
205
  * alternative is announced rather than only the first: a screen reader is
162
206
  * reading them out, not fitting them in a column.
207
+ *
208
+ * `primary` is `formatShortcut`'s: under `'Super'` a `Control` is ⌘, which
209
+ * UI Events calls `Meta`, so what is announced is what is drawn and bound.
163
210
  */
164
- export function ariaKeyShortcuts(shortcut) {
211
+ export function ariaKeyShortcuts(shortcut, { primary = 'Control' } = {}) {
165
212
  if (!isValidShortcut(shortcut)) return undefined;
213
+ const name = (token) => {
214
+ if (!MODIFIER_TOKENS.has(token)) return KEY_LABELS[token] ?? token;
215
+ return primary === 'Super' && token === 'Control'
216
+ ? 'Meta'
217
+ : ARIA_MODIFIERS[token];
218
+ };
219
+ // a chord naming both `Control` and `Super` is one ⌘ there, said once
166
220
  return shortcut
167
- .map((chord) =>
168
- chord
169
- .map((token) =>
170
- MODIFIER_TOKENS.has(token)
171
- ? ARIA_MODIFIERS[token]
172
- : (KEY_LABELS[token] ?? token),
173
- )
174
- .join('+'),
175
- )
221
+ .map((chord) => [...new Set(chord.map(name))].join('+'))
176
222
  .join(' ');
177
223
  }
178
224
 
package/src/node.d.ts CHANGED
@@ -73,6 +73,29 @@ export interface Context2D {
73
73
  * `'globalCompositeOperation' in ctx`.
74
74
  */
75
75
  globalCompositeOperation?: string;
76
+ /**
77
+ * Canvas's: whether an image drawn scaled or turned is smoothed, or
78
+ * drawn as its nearest pixels. Native contexts only (macOS, Windows), and
79
+ * only where the bridge can set it: assign and read back to know.
80
+ */
81
+ imageSmoothingEnabled?: boolean;
82
+ /**
83
+ * Canvas's: how it is smoothed, `'low'` being bilinear. A native context
84
+ * starts at `'medium'`, which on macOS resamples the whole source for a
85
+ * draw through a matrix whatever the clip — so a surface drawn a tile at
86
+ * a time is far cheaper at `'low'`.
87
+ */
88
+ imageSmoothingQuality?: 'low' | 'medium' | 'high';
89
+ /**
90
+ * Canvas's: a CSS filter list applied to what is drawn after it. Native
91
+ * contexts only (macOS, Windows), and only the colour functions —
92
+ * `grayscale()`, `sepia()`, `saturate()`, `hue-rotate()`, `invert()`,
93
+ * `brightness()`, `contrast()` and `opacity()` — over a bridge that reads
94
+ * a surface's pixels back. Anything else does not stick: assign and read
95
+ * back to know. Absent on X11, whose server runs no colour matrix and
96
+ * answers a read a round trip later.
97
+ */
98
+ filter?: string;
76
99
  save(): void;
77
100
  restore(): void;
78
101
  translate(x: number, y: number): void;
@@ -5,6 +5,7 @@
5
5
  import { deviceAnchorArea, windowOrigin } from '../anchor.js';
6
6
  import { armPasteState, canPaste } from '../pastestate.js';
7
7
  import {
8
+ primaryModifier,
8
9
  XK_RETURN,
9
10
  XK_KP_ENTER,
10
11
  XK_UP,
@@ -125,6 +126,7 @@ export function openEditMenu(node, at, actions = {}) {
125
126
  items,
126
127
  (text) => app?.fonts?.layout(text, style)?.width,
127
128
  s,
129
+ { primary: primaryModifier(app) },
128
130
  );
129
131
  const deviceAt = at && {
130
132
  ...at,
@@ -221,6 +223,17 @@ export function openEditMenu(node, at, actions = {}) {
221
223
  node._editMenu = popup;
222
224
  // read *before* the menu takes the keyboard, and handed back on close
223
225
  node._editMenuRestore = node._focusManager()?.focused ?? null;
226
+ // The window losing focus closes it — the user went to another
227
+ // application, ⌘-Tab or a click on its window. Neither reaches us as a
228
+ // press: the grab redirects presses only between windows of ours on Cocoa,
229
+ // and a popup panel never takes the keyboard there, so this is the one
230
+ // event that says so. The node keeps its focus across the blur, so nothing
231
+ // in the tree hears it either; the same answer `Menu` and `Select` give
232
+ // (`useDismissOnWindowBlur`, components/anchor.js).
233
+ node._editMenuOffBlur =
234
+ node.root?.onWindowFocusChange?.((focused) => {
235
+ if (!focused) closeEditMenu(node);
236
+ }) ?? null;
224
237
  // the menu takes the keyboard so arrows and Escape reach it rather than
225
238
  // the element behind it
226
239
  popup.events?.focus?.(canvas);
@@ -241,6 +254,8 @@ export function closeEditMenu(node) {
241
254
  const popup = node?._editMenu;
242
255
  if (!popup) return;
243
256
  node._editMenu = null;
257
+ node._editMenuOffBlur?.();
258
+ node._editMenuOffBlur = null;
244
259
  const restore = node._editMenuRestore;
245
260
  node._editMenuRestore = null;
246
261
  // the popup is a child, so a node destroyed while its menu was up took the
@@ -255,6 +270,10 @@ export function closeEditMenu(node) {
255
270
  // menu and a ring appearing on the way back would be news to nobody. A
256
271
  // surface that was not focusable in the first place, or that stopped being
257
272
  // on screen while the menu was up, gets nothing back rather than the
258
- // destroyed menu canvas keeping the keyboard.
259
- events.focus(events._canRestoreTo(restore) ? restore : null, 'pointer');
273
+ // destroyed menu canvas keeping the keyboard. And a restore, which takes
274
+ // the window no keyboard: a menu closed because the user went to another
275
+ // application must not drag them back to this one.
276
+ events.focus(events._canRestoreTo(restore) ? restore : null, 'pointer', {
277
+ restoring: true,
278
+ });
260
279
  }
@@ -10,6 +10,7 @@ import {
10
10
  XK_PAGE_UP,
11
11
  XK_PAGE_DOWN,
12
12
  XK_END,
13
+ primaryModifierHeld,
13
14
  } from '../keysyms.js';
14
15
  import {
15
16
  scrollbarGeometry,
@@ -235,7 +236,8 @@ export class TextAreaNode extends TextInputNode {
235
236
  const layout = this._valueLayout();
236
237
 
237
238
  if (k === XK_RETURN || k === XK_KP_ENTER) {
238
- if (ev.ctrlKey) {
239
+ // Ctrl+Enter, ⌘↩ on the Cocoa backend
240
+ if (primaryModifierHeld(ev, this.app)) {
239
241
  this._fireValueEvent('onSubmit', this.value, ev.nativeEvent);
240
242
  return true;
241
243
  }
@@ -14,6 +14,7 @@ import { lastInputTime } from '../inputtime.js';
14
14
  import {
15
15
  ctrlChordLetter,
16
16
  MOD,
17
+ primaryModifierHeld,
17
18
  XK_BACKSPACE,
18
19
  XK_RETURN,
19
20
  XK_KP_ENTER,
@@ -550,7 +551,9 @@ export class TextInputNode extends Node {
550
551
  this._moveCaret(this._chars().length, ev.shiftKey);
551
552
  return true;
552
553
  }
553
- if (ev.ctrlKey) {
554
+ // Ctrl, or ⌘ on the Cocoa backend (`primaryModifierHeld`): the keys the
555
+ // edit menu prints beside its rows are the keys that do what they say
556
+ if (primaryModifierHeld(ev, this.app)) {
554
557
  const letter = ctrlChordLetter(ev);
555
558
  if (letter === 0x61 /* a */) {
556
559
  this._selectAll();
@@ -562,10 +565,12 @@ export class TextInputNode extends Node {
562
565
  } else if (letter === 0x76 /* v */) {
563
566
  this._pasteFrom();
564
567
  } else if (letter === 0x7a /* z */) {
565
- // Ctrl+Shift+Z redoes, the way it does in GTK and Qt
568
+ // Ctrl+Shift+Z redoes, the way it does in GTK and Qt — and ⇧⌘Z is
569
+ // the Mac's own redo
566
570
  if (ev.shiftKey) this.redo();
567
571
  else this.undo();
568
- } else if (letter === 0x79 /* y */) {
572
+ } else if (letter === 0x79 /* y */ && !ev.metaKey) {
573
+ // Ctrl+Y is Windows' redo; ⌘Y is nobody's, so it is not taken
569
574
  this.redo();
570
575
  } else {
571
576
  // A chord this field has no answer for is not the field's: Ctrl+S
@@ -576,6 +581,11 @@ export class TextInputNode extends Node {
576
581
  }
577
582
  return true;
578
583
  }
584
+ // …and a chord on the other one is not text either. AppKit hands over
585
+ // the key's bare character with ⌘ or ⌃ held, so ⌘S in a focused field
586
+ // typed an `s` before this, and ⌃A typed an `a` once ⌘ became the
587
+ // primary — Ctrl and Super chords are commands on every backend.
588
+ if (ev.ctrlKey || ev.metaKey) return false;
579
589
  if (ev.codepoint != null && ev.codepoint >= 0x20 && ev.codepoint !== 0x7f) {
580
590
  const ch = String.fromCodePoint(ev.codepoint);
581
591
  this._insert(ch, 'type');
@@ -30,7 +30,7 @@
30
30
 
31
31
  import { callHandler } from './errors.js';
32
32
  import { lastInputTime } from './inputtime.js';
33
- import { ctrlChordLetter } from './keysyms.js';
33
+ import { ctrlChordLetter, primaryModifierHeld } from './keysyms.js';
34
34
  import { codePoints, wordRangeAt } from './textrange.js';
35
35
  // --- who is showing a selection ------------------------------------------
36
36
  //
@@ -201,14 +201,14 @@ export class TextSelection {
201
201
  }
202
202
 
203
203
  /**
204
- * Ctrl+A and Ctrl+C, run as this element's default action — which is why a
205
- * surface is a focus target (a11y.js): the copy has to have somewhere to
206
- * arrive. Nothing else is bound. A read-only document has no caret, so
207
- * shift+arrows would be caret browsing, which is a mode rather than a
208
- * default.
204
+ * Ctrl+A and Ctrl+C — ⌘A and ⌘C on the Cocoa backend — run as this
205
+ * element's default action, which is why a surface is a focus target
206
+ * (a11y.js): the copy has to have somewhere to arrive. Nothing else is
207
+ * bound. A read-only document has no caret, so shift+arrows would be caret
208
+ * browsing, which is a mode rather than a default.
209
209
  */
210
210
  keyDown(ev) {
211
- if (!ev.ctrlKey) return;
211
+ if (!primaryModifierHeld(ev, this.node.app)) return;
212
212
  const letter = ctrlChordLetter(ev);
213
213
  if (letter === 0x61 /* a */) {
214
214
  this.selectAll();
@@ -579,6 +579,10 @@ export interface MenuItem {
579
579
  * the menu is mounted, without the menu being opened (#351). `enabled`
580
580
  * and `visible` gate the binding with the row. `accelerators={false}` on
581
581
  * the menu turns that off for an app with a dispatcher of its own.
582
+ *
583
+ * `Control` is the primary modifier: on the Cocoa backend `[['Control',
584
+ * 'S']]` is drawn `⌘S`, bound to ⌘S, and installed in the macOS menu bar
585
+ * as ⌘S — which then answers it by itself (docs/events.md).
582
586
  */
583
587
  shortcut?: MenuShortcut;
584
588
  /** With `toggleState`, draws a check mark or a radio dot in the gutter. */
@@ -654,8 +658,10 @@ export interface MenuBarProps extends WidgetProps {
654
658
  /**
655
659
  * Honour the items' own `shortcut`s while this bar is mounted — including
656
660
  * when the desktop's panel has taken the menu over, since the panel draws
657
- * the rows but the key is pressed in this window. On by default; `false`
658
- * leaves the chords to an app that dispatches its own.
661
+ * the rows but the key is pressed in this window. The macOS menu bar is the
662
+ * exception: it answers the ⌘ chords it holds as key equivalents itself,
663
+ * and the window leaves those alone. On by default; `false` leaves the
664
+ * chords to an app that dispatches its own.
659
665
  */
660
666
  accelerators?: boolean;
661
667
  fontSize?: number;
@@ -739,7 +739,7 @@ export interface TextInputProps extends DrawnProps<TextInputNode> {
739
739
  * both read naturally.
740
740
  */
741
741
  onChange?: (ev: ChangeEvent<TextInputNode>) => void;
742
- /** Enter — or Ctrl+Enter in a `<textarea>`. */
742
+ /** Enter — or Ctrl+Enter in a `<textarea>` (⌘↩ on the Cocoa backend). */
743
743
  onSubmit?: (ev: SubmitEvent<TextInputNode>) => void;
744
744
  placeholder?: string;
745
745
  placeholderColor?: Color;
@@ -7,6 +7,7 @@
7
7
  import type { RefObject } from 'react';
8
8
  import type { DrawnNode, NtkWindow, TextInputNode } from './nodes.js';
9
9
  import type { MenuShortcut } from './components.js';
10
+ import type { PrimaryModifier } from '../keysyms.js';
10
11
 
11
12
  /** The raw ntk/X11 event a synthetic one was made from. */
12
13
  export interface NativeEvent {
@@ -331,7 +332,7 @@ export interface ChangeEvent<T = TextInputNode> extends Omit<
331
332
  }
332
333
 
333
334
  /**
334
- * `<textinput onSubmit>` — Enter, or Ctrl+Enter in a `<textarea>`. Same
335
+ * `<textinput onSubmit>` — Enter, or Ctrl+Enter in a `<textarea>` (⌘↩ on Cocoa). Same
335
336
  * shape as {@link ChangeEvent}; `nativeEvent` is the X key event.
336
337
  */
337
338
  export interface SubmitEvent<T = TextInputNode> extends Omit<
@@ -556,7 +557,8 @@ export interface AcceleratorOptions {
556
557
  * Exact on Control/Alt/Shift/Super, indifferent to Caps Lock and Num Lock,
557
558
  * matched against the Latin keysym so a layout switch does not turn it off,
558
559
  * and behind whatever a focused element consumed with `preventDefault()`.
559
- * See docs/events.md.
560
+ * `Control` is the app's primary modifier: ⌘ on the Cocoa backend, the key
561
+ * its menus print. See docs/events.md.
560
562
  *
561
563
  * With no `scope` the binding belongs to the tree's top-level `<window>`, or,
562
564
  * in an app that has none, to the root-level `<popup>` holding the keyboard —
@@ -569,10 +571,25 @@ export function useAccelerator(
569
571
  options?: AcceleratorOptions,
570
572
  ): void;
571
573
 
574
+ export interface MatchesShortcutOptions {
575
+ /**
576
+ * The app's primary modifier — `primaryModifier(app)` from
577
+ * `react-x11/keysyms`. `'Super'` reads a chord's `Control` (and its
578
+ * `Super`) as ⌘ and wants ⌃ up, which is the Cocoa backend's rule;
579
+ * `'Control'`, the default, reads the chord literally.
580
+ */
581
+ primary?: PrimaryModifier;
582
+ }
583
+
572
584
  /**
573
585
  * Whether a key event presses any alternative of a `shortcut` — the matcher
574
586
  * the menus and {@link useAccelerator} both run on, for an application
575
- * dispatching chords its own way.
587
+ * dispatching chords its own way. Pass the app's primary modifier to match
588
+ * the way they do:
589
+ *
590
+ * ```ts
591
+ * matchesShortcut(ev, [['Control', 'S']], { primary: primaryModifier(app) });
592
+ * ```
576
593
  */
577
594
  export function matchesShortcut(
578
595
  ev: {
@@ -584,4 +601,5 @@ export function matchesShortcut(
584
601
  metaKey?: boolean;
585
602
  },
586
603
  shortcut: MenuShortcut | undefined,
604
+ options?: MatchesShortcutOptions,
587
605
  ): boolean;