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.
@@ -10,14 +10,85 @@ 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,
16
17
  scrollbarHit,
17
18
  paintScrollbarThumb,
18
19
  } from './scrollbars.js';
20
+ import { installMethods } from './install.js';
19
21
  import { rangeBands } from './text.js';
20
22
  import { CARET_WIDTH, CARET_RESERVE, TextInputNode } from './textinput.js';
23
+ import { TextAreaKeys } from './textkeys.js';
24
+
25
+ /** A hard line break at the end of the value — the breaks ntk's TextLayout
26
+ * and CoreText both end a line at. */
27
+ const FINAL_BREAK = /[\n\r\u2028\u2029]$/;
28
+
29
+ /**
30
+ * The layout of a value that ends in a line break, with the empty line the
31
+ * break opens: the line Return at the end of a textarea puts the caret on.
32
+ *
33
+ * Neither engine sets it. ntk's TextLayout and CoreText both end a paragraph
34
+ * at its last break, which is right for a `<text>` — CSS draws no line after
35
+ * a final newline either — and wrong for an editor, where that line is
36
+ * where the next letter goes. Without it the caret after Return was drawn at
37
+ * the end of the line above, Up from there skipped a line, Down never
38
+ * reached it, and the scroll that follows the caret stopped a line short.
39
+ *
40
+ * The line is the one the engine sets for an empty paragraph with a break
41
+ * after it — `lineBreak()`, a lone `\n` laid out in the value's style and
42
+ * options — moved down under the last line, so its height, baseline and
43
+ * place across the box (`textAlign`, the base direction), and the caret
44
+ * drawn on it, are that engine's: typing the next letter moves the caret
45
+ * along the line and nothing else. A lone break rather than no text, because
46
+ * ntk resolves an empty string's direction from paragraphs it does not have,
47
+ * and puts an RTL one against the left edge. An engine that already sets the
48
+ * line — its last line starts where the text ends — is left alone.
49
+ */
50
+ export function withFinalLine(layout, text, lineBreak) {
51
+ const lines = layout.lines;
52
+ const last = lines?.[lines.length - 1];
53
+ if (!FINAL_BREAK.test(text) || !last || last.start >= text.length) {
54
+ return layout;
55
+ }
56
+ const lone = lineBreak();
57
+ const model = lone.lines?.[0];
58
+ if (!model) return layout;
59
+ const dy = last.y + last.height - model.y;
60
+ const line = {
61
+ x: model.x,
62
+ y: model.y + dy,
63
+ width: 0,
64
+ height: model.height,
65
+ baseline: model.baseline + dy,
66
+ ascent: model.ascent,
67
+ descent: model.descent,
68
+ start: text.length,
69
+ end: text.length,
70
+ runs: [],
71
+ };
72
+ const index = lines.length;
73
+ const end = Array.from(text).length;
74
+ const caret = lone.caretPosition(0);
75
+ return Object.create(layout, {
76
+ lines: { value: [...lines, line] },
77
+ height: { value: Math.max(layout.height, line.y + line.height) },
78
+ caretPosition: {
79
+ value: (cp) =>
80
+ cp >= end
81
+ ? { x: caret.x, y: caret.y + dy, height: caret.height, line: index }
82
+ : layout.caretPosition(cp),
83
+ },
84
+ indexAt: {
85
+ value: (x, y) => (y >= line.y ? end : layout.indexAt(x, y)),
86
+ },
87
+ // the engine draws, and measures the coverage of, the text it laid out
88
+ draw: { value: (ctx, x, y) => layout.draw(ctx, x, y) },
89
+ coverage: { value: (options) => layout.coverage?.(options) ?? null },
90
+ });
91
+ }
21
92
 
22
93
  /**
23
94
  * <textarea>: multi-line editable text on the same editing core as
@@ -26,6 +97,8 @@ import { CARET_WIDTH, CARET_RESERVE, TextInputNode } from './textinput.js';
26
97
  * between visual lines keeping a goal column, Home/End are wrap-aware,
27
98
  * selection spans lines, and the view scrolls vertically to follow the
28
99
  * caret (wheel scrolls too). `rows` (default 3) sets the preferred height.
100
+ * Where the app's shortcuts are ⌘, the keys are the Mac's (textkeys.js):
101
+ * ⌘↑/⌘↓ for the ends of the value, ⌥↑/⌥↓ for a paragraph's.
29
102
  */
30
103
  export class TextAreaNode extends TextInputNode {
31
104
  /**
@@ -140,11 +213,13 @@ export class TextAreaNode extends TextInputNode {
140
213
  const key = `${width}|${color}|${shown}|${s.family}|${s.size}|${s.weight}|${s.style}|${direction}|${align}|${s.letterSpacing}|${JSON.stringify(s.features ?? null)}`;
141
214
  if (this._valueLayoutKey !== key) {
142
215
  this._valueLayoutKey = key;
143
- this._valueLayoutCache = fonts.layout([{ text: shown, ...s, color }], s, {
144
- maxWidth: container,
145
- align,
146
- direction,
147
- });
216
+ const options = { maxWidth: container, align, direction };
217
+ const layout = fonts.layout([{ text: shown, ...s, color }], s, options);
218
+ this._valueLayoutCache = isEmpty
219
+ ? layout
220
+ : withFinalLine(layout, shown, () =>
221
+ fonts.layout([{ text: '\n', ...s, color }], s, options),
222
+ );
148
223
  }
149
224
  return this._valueLayoutCache;
150
225
  }
@@ -235,7 +310,8 @@ export class TextAreaNode extends TextInputNode {
235
310
  const layout = this._valueLayout();
236
311
 
237
312
  if (k === XK_RETURN || k === XK_KP_ENTER) {
238
- if (ev.ctrlKey) {
313
+ // Ctrl+Enter, ⌘↩ on the Cocoa backend
314
+ if (primaryModifierHeld(ev, this.app)) {
239
315
  this._fireValueEvent('onSubmit', this.value, ev.nativeEvent);
240
316
  return true;
241
317
  }
@@ -263,17 +339,11 @@ export class TextAreaNode extends TextInputNode {
263
339
  return true;
264
340
  }
265
341
  if ((k === XK_HOME || k === XK_END) && layout && this.value.length > 0) {
266
- const pos = layout.caretPosition(this._caret);
267
- const line = layout.lines[pos.line];
268
- const y = line.y + (line.ascent + line.descent) / 2;
269
- // indexAt clamps into the line: far left = line start; just past the
270
- // right edge = end of visible content (before the newline)
271
- const i =
272
- k === XK_HOME
273
- ? layout.indexAt(-1e6, y)
274
- : layout.indexAt(line.x + line.width + 0.01, y);
275
342
  this._goalX = null;
276
- this._moveCaret(i, ev.shiftKey);
343
+ this._moveCaret(
344
+ this._lineEdge(this._caret, k === XK_HOME ? -1 : 1),
345
+ ev.shiftKey,
346
+ );
277
347
  return true;
278
348
  }
279
349
  this._goalX = null;
@@ -389,3 +459,5 @@ export class TextAreaNode extends TextInputNode {
389
459
  ctx.restore();
390
460
  }
391
461
  }
462
+
463
+ installMethods(TextAreaNode, TextAreaKeys);
@@ -1,6 +1,7 @@
1
1
  // <textinput>: a single-line editable field — value, caret and selection,
2
2
  // the keys, the mouse, the clipboard, the edit menu and painting. Undo/redo
3
- // is in edithistory.js and IME composition in preedit.js, installed below.
3
+ // is in edithistory.js, IME composition in preedit.js and the Mac's editing
4
+ // keys in textkeys.js, installed below.
4
5
 
5
6
  import { localTextStyleChanged } from '../styles.js';
6
7
  import { Yoga } from '../yoga.js';
@@ -14,6 +15,7 @@ import { lastInputTime } from '../inputtime.js';
14
15
  import {
15
16
  ctrlChordLetter,
16
17
  MOD,
18
+ primaryModifierHeld,
17
19
  XK_BACKSPACE,
18
20
  XK_RETURN,
19
21
  XK_KP_ENTER,
@@ -31,6 +33,7 @@ import { installMethods } from './install.js';
31
33
  import { Node } from './node.js';
32
34
  import { TextInputPreedit } from './preedit.js';
33
35
  import { rangeBands } from './text.js';
36
+ import { TextInputKeys } from './textkeys.js';
34
37
 
35
38
  /**
36
39
  * How long a caret stays in each of its two states, in milliseconds, on a
@@ -486,7 +489,11 @@ export class TextInputNode extends Node {
486
489
  const previous = this._keyNative;
487
490
  this._keyNative = ev.nativeEvent ?? null;
488
491
  try {
489
- if (this._editKeyDown(ev)) ev.preventDefault();
492
+ // Where the app's shortcuts are ⌘ the keys are the Mac's (textkeys.js),
493
+ // and they come first: ⌃← and ⌃A mean something else to them than
494
+ // to the bindings in `_editKeyDown`.
495
+ const answered = this._macKeyDown(ev) ?? this._editKeyDown(ev);
496
+ if (answered) ev.preventDefault();
490
497
  } finally {
491
498
  this._keyNative = previous;
492
499
  }
@@ -550,7 +557,9 @@ export class TextInputNode extends Node {
550
557
  this._moveCaret(this._chars().length, ev.shiftKey);
551
558
  return true;
552
559
  }
553
- if (ev.ctrlKey) {
560
+ // Ctrl, or ⌘ on the Cocoa backend (`primaryModifierHeld`): the keys the
561
+ // edit menu prints beside its rows are the keys that do what they say
562
+ if (primaryModifierHeld(ev, this.app)) {
554
563
  const letter = ctrlChordLetter(ev);
555
564
  if (letter === 0x61 /* a */) {
556
565
  this._selectAll();
@@ -562,10 +571,12 @@ export class TextInputNode extends Node {
562
571
  } else if (letter === 0x76 /* v */) {
563
572
  this._pasteFrom();
564
573
  } else if (letter === 0x7a /* z */) {
565
- // Ctrl+Shift+Z redoes, the way it does in GTK and Qt
574
+ // Ctrl+Shift+Z redoes, the way it does in GTK and Qt — and ⇧⌘Z is
575
+ // the Mac's own redo
566
576
  if (ev.shiftKey) this.redo();
567
577
  else this.undo();
568
- } else if (letter === 0x79 /* y */) {
578
+ } else if (letter === 0x79 /* y */ && !ev.metaKey) {
579
+ // Ctrl+Y is Windows' redo; ⌘Y is nobody's, so it is not taken
569
580
  this.redo();
570
581
  } else {
571
582
  // A chord this field has no answer for is not the field's: Ctrl+S
@@ -576,6 +587,11 @@ export class TextInputNode extends Node {
576
587
  }
577
588
  return true;
578
589
  }
590
+ // …and a chord on the other one is not text either. AppKit hands over
591
+ // the key's bare character with ⌘ or ⌃ held, so ⌘S in a focused field
592
+ // typed an `s` before this, and ⌃A typed an `a` once ⌘ became the
593
+ // primary — Ctrl and Super chords are commands on every backend.
594
+ if (ev.ctrlKey || ev.metaKey) return false;
579
595
  if (ev.codepoint != null && ev.codepoint >= 0x20 && ev.codepoint !== 0x7f) {
580
596
  const ch = String.fromCodePoint(ev.codepoint);
581
597
  this._insert(ch, 'type');
@@ -1142,4 +1158,9 @@ export class TextInputNode extends Node {
1142
1158
  }
1143
1159
 
1144
1160
  // Undo/redo and IME composition live in their own files (see install.js).
1145
- installMethods(TextInputNode, TextInputHistory, TextInputPreedit);
1161
+ installMethods(
1162
+ TextInputNode,
1163
+ TextInputHistory,
1164
+ TextInputPreedit,
1165
+ TextInputKeys,
1166
+ );
@@ -0,0 +1,322 @@
1
+ // The Mac's text-editing keys for <textinput> and <textarea>, and the caret
2
+ // geometry they move by: the ends of a line, of a paragraph and of the
3
+ // value, and a line up or down.
4
+ //
5
+ // The bindings are AppKit's own (StandardKeyBinding.dict, the table every
6
+ // Cocoa field reads): ⌥ moves and deletes by word, ⌘ goes to the ends of the
7
+ // line and of the document, ⌘⌫ deletes to the start of the line, and ⌃ is
8
+ // the Emacs set — ⌃A and ⌃E to the ends of the paragraph, ⌃K kills to its
9
+ // end and ⌃Y yanks it back. They apply where the app's shortcuts are pressed
10
+ // with ⌘ (`app.primaryModifier`, the Cocoa app), which is a fact about the
11
+ // backend rather than the platform: an X11 app under XQuartz is an X client
12
+ // and keeps the bindings in textinput.js's `_editKeyDown` — GTK's, Qt's and
13
+ // Windows', where Ctrl moves by word. The two sets disagree about Ctrl
14
+ // outright, which is why the Mac's are not a layer over the others: ⌃← is
15
+ // the start of the line here and a word there, and ⌃A is the start of the
16
+ // paragraph here and Select All there.
17
+ //
18
+ // Directions are logical, as the rest of the field's keys are: ← is towards
19
+ // the start of the value, also in a right-to-left field.
20
+
21
+ import {
22
+ ctrlChordLetter,
23
+ primaryModifier,
24
+ XK_BACKSPACE,
25
+ XK_DELETE,
26
+ XK_DOWN,
27
+ XK_LEFT,
28
+ XK_RIGHT,
29
+ XK_UP,
30
+ } from '../keysyms.js';
31
+
32
+ /** A chord a Mac field answers by doing nothing — and typing nothing. */
33
+ const NOTHING = 'noop';
34
+
35
+ /**
36
+ * The AppKit command a key is bound to in a Mac text field — the selector's
37
+ * name without the colon or `AndModifySelection`, since Shift is the
38
+ * `extend` the command runs with — `NOTHING` for a chord the field types
39
+ * nothing for, or null for a key the field's own handling covers: typing, a
40
+ * plain arrow, Return, Tab, a ⌘ chord (Undo, Copy and the rest), and every
41
+ * ⌃ chord the Mac does not bind, which `_editKeyDown` types nothing for.
42
+ */
43
+ function macBinding(ev) {
44
+ const cmd = ev.metaKey;
45
+ const ctrl = ev.ctrlKey;
46
+ const opt = ev.altKey;
47
+ switch (ev.keysym) {
48
+ case XK_LEFT:
49
+ case XK_RIGHT: {
50
+ const left = ev.keysym === XK_LEFT;
51
+ // ⌘⌃← sets the paragraph's writing direction, which a field leaves
52
+ // to its `direction` style
53
+ if (cmd && ctrl) return NOTHING;
54
+ // ⌃← is Mission Control's before it is the field's, and when the
55
+ // system lets it through AppKit binds it beside ⌘←, not to a word
56
+ if (cmd || ctrl) {
57
+ return left ? 'moveToLeftEndOfLine' : 'moveToRightEndOfLine';
58
+ }
59
+ if (opt) return left ? 'moveWordLeft' : 'moveWordRight';
60
+ return null;
61
+ }
62
+ case XK_UP:
63
+ case XK_DOWN: {
64
+ const up = ev.keysym === XK_UP;
65
+ if (cmd) return up ? 'moveToBeginningOfDocument' : 'moveToEndOfDocument';
66
+ // ⌃↑ scrolls a page without moving the caret, when Mission Control
67
+ // has not taken it
68
+ if (ctrl) return NOTHING;
69
+ if (opt) return up ? 'moveParagraphBackward' : 'moveParagraphForward';
70
+ return up ? 'moveUp' : 'moveDown';
71
+ }
72
+ case XK_BACKSPACE:
73
+ if (opt) return 'deleteWordBackward'; // ⌥⌫, and ⌥⌃⌫
74
+ if (cmd) return 'deleteToBeginningOfLine';
75
+ if (ctrl) return 'deleteBackwardByDecomposingPreviousCharacter';
76
+ return null;
77
+ case XK_DELETE:
78
+ if (opt) return 'deleteWordForward';
79
+ if (ctrl) return 'deleteForward';
80
+ return null;
81
+ }
82
+ // ⌃ and a letter: the Emacs set
83
+ if (!ctrl || cmd || opt) return null;
84
+ switch (ctrlChordLetter(ev)) {
85
+ case 0x61 /* a */:
86
+ return 'moveToBeginningOfParagraph';
87
+ case 0x65 /* e */:
88
+ return 'moveToEndOfParagraph';
89
+ case 0x62 /* b */:
90
+ return 'moveBackward';
91
+ case 0x66 /* f */:
92
+ return 'moveForward';
93
+ case 0x70 /* p */:
94
+ return 'moveUp';
95
+ case 0x6e /* n */:
96
+ return 'moveDown';
97
+ case 0x68 /* h */:
98
+ return 'deleteBackward';
99
+ case 0x64 /* d */:
100
+ return 'deleteForward';
101
+ case 0x6b /* k */:
102
+ return 'deleteToEndOfParagraph';
103
+ case 0x79 /* y */:
104
+ return 'yank';
105
+ }
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * What ⌃K killed, per application: AppKit's kill buffer, which is its own
111
+ * and not the pasteboard — a kill does not replace what ⌘C copied, and ⌃Y
112
+ * in one field yanks what ⌃K killed in another.
113
+ */
114
+ const killBuffers = new WeakMap();
115
+ const NO_APP = {};
116
+
117
+ /** Mac text keys for `TextInputNode`, installed by textinput.js. */
118
+ export class TextInputKeys {
119
+ /**
120
+ * The Mac binding for this key, run, ahead of `_editKeyDown`. Answers
121
+ * whether the field took the key, as `_editKeyDown` does — false for a
122
+ * chord the field types nothing for — and undefined where the key is not
123
+ * a Mac binding, or this app does not edit with them.
124
+ */
125
+ _macKeyDown(ev) {
126
+ // the Mac's keys go with the Mac's shortcuts, read off the app on every
127
+ // key rather than cached, like every capability an app object carries
128
+ if (primaryModifier(this.app) !== 'Super') return undefined;
129
+ const command = macBinding(ev);
130
+ if (command == null) return undefined;
131
+ if (command === NOTHING) return false;
132
+ this._runTextCommand(command, Boolean(ev.shiftKey));
133
+ return true;
134
+ }
135
+
136
+ /** One AppKit editing command; `extend` keeps the anchor where it is. */
137
+ _runTextCommand(command, extend) {
138
+ const [a, b] = this._selection();
139
+ const hasSelection = a !== b;
140
+ const caret = this._caret;
141
+ const length = this._chars().length;
142
+ const move = (index) => this._moveCaret(index, extend);
143
+ switch (command) {
144
+ case 'moveBackward':
145
+ return move(!extend && hasSelection ? a : caret - 1);
146
+ case 'moveForward':
147
+ return move(!extend && hasSelection ? b : caret + 1);
148
+ case 'moveWordLeft':
149
+ return move(this._wordBoundary(caret, -1));
150
+ case 'moveWordRight':
151
+ return move(this._wordBoundary(caret, 1));
152
+ case 'moveToLeftEndOfLine':
153
+ return move(this._lineEdge(caret, -1));
154
+ case 'moveToRightEndOfLine':
155
+ return move(this._lineEdge(caret, 1));
156
+ case 'moveToBeginningOfParagraph':
157
+ return move(this._paragraphEdge(caret, -1));
158
+ case 'moveToEndOfParagraph':
159
+ return move(this._paragraphEdge(caret, 1));
160
+ // ⌥↑ is AppKit's (moveBackward:, moveToBeginningOfParagraph:): a caret
161
+ // already at the start of a paragraph goes on to the previous one's
162
+ case 'moveParagraphBackward':
163
+ return move(
164
+ this._paragraphEdge(!extend && hasSelection ? a : caret - 1, -1),
165
+ );
166
+ case 'moveParagraphForward':
167
+ return move(
168
+ this._paragraphEdge(!extend && hasSelection ? b : caret + 1, 1),
169
+ );
170
+ case 'moveToBeginningOfDocument':
171
+ return move(0);
172
+ case 'moveToEndOfDocument':
173
+ return move(length);
174
+ case 'moveUp':
175
+ return move(this._verticalStep(-1));
176
+ case 'moveDown':
177
+ return move(this._verticalStep(1));
178
+ }
179
+ if (hasSelection && command !== 'yank') {
180
+ if (command === 'deleteToEndOfParagraph') this._kill(a, b);
181
+ else this._deleteRange(a, b);
182
+ return;
183
+ }
184
+ switch (command) {
185
+ case 'deleteBackward':
186
+ if (caret > 0) this._deleteRange(caret - 1, caret, 'delete-back');
187
+ return;
188
+ case 'deleteForward':
189
+ if (caret < length) {
190
+ this._deleteRange(caret, caret + 1, 'delete-forward');
191
+ }
192
+ return;
193
+ case 'deleteWordBackward':
194
+ return this._deleteRange(this._wordBoundary(caret, -1), caret);
195
+ case 'deleteWordForward':
196
+ return this._deleteRange(caret, this._wordBoundary(caret, 1));
197
+ case 'deleteToBeginningOfLine':
198
+ return this._deleteRange(this._lineEdge(caret, -1), caret);
199
+ case 'deleteToEndOfParagraph': {
200
+ // at the end of a paragraph, the kill takes the line break, which
201
+ // joins the next paragraph to this one — Emacs's ⌃K, and AppKit's
202
+ const end = this._paragraphEdge(caret, 1);
203
+ this._kill(caret, end > caret ? end : Math.min(caret + 1, length));
204
+ return;
205
+ }
206
+ case 'deleteBackwardByDecomposingPreviousCharacter':
207
+ return this._deleteDecomposing(caret);
208
+ case 'yank': {
209
+ const text = killBuffers.get(this.app ?? NO_APP)?.text;
210
+ if (text) this._insert(text);
211
+ return;
212
+ }
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Delete `[from, to)` into the kill buffer. Kills one after another — ⌃K,
218
+ * ⌃K, with nothing in between — add up, so the two that take a line and
219
+ * then its break yank back as the line.
220
+ *
221
+ * A `sensitive` field deletes and keeps nothing, for the reason it offers
222
+ * no Copy: the text would outlive the field.
223
+ */
224
+ _kill(from, to) {
225
+ if (to <= from) return;
226
+ const chars = this._chars();
227
+ const killed = chars.slice(from, to).join('');
228
+ // What the kill leaves, worked out rather than read back: a controlled
229
+ // field's `value` is the parent's until it re-renders.
230
+ const left = [...chars.slice(0, from), ...chars.slice(to)].join('');
231
+ const key = this.app ?? NO_APP;
232
+ const buffer = killBuffers.get(key);
233
+ const last = this._lastKill;
234
+ const appends =
235
+ buffer != null &&
236
+ last?.buffer === buffer &&
237
+ last.value === this.value &&
238
+ last.caret === from;
239
+ this._deleteRange(from, to);
240
+ if (this.props.sensitive) return;
241
+ const next = { text: appends ? buffer.text + killed : killed };
242
+ killBuffers.set(key, next);
243
+ this._lastKill = { buffer: next, value: left, caret: from };
244
+ }
245
+
246
+ /**
247
+ * ⌃⌫: take the last mark off the character before the caret — `é` back to
248
+ * `e` — and delete the character only when it has none.
249
+ */
250
+ _deleteDecomposing(caret) {
251
+ if (caret === 0) return;
252
+ const chars = this._chars();
253
+ const parts = Array.from(chars[caret - 1].normalize('NFD'));
254
+ if (parts.length < 2) {
255
+ this._deleteRange(caret - 1, caret, 'delete-back');
256
+ return;
257
+ }
258
+ const left = Array.from(parts.slice(0, -1).join('').normalize('NFC'));
259
+ this._commit(
260
+ [...chars.slice(0, caret - 1), ...left, ...chars.slice(caret)],
261
+ caret - 1 + left.length,
262
+ );
263
+ }
264
+
265
+ /** The start (`dir` < 0) or end of the paragraph `index` is in: the
266
+ * value's ends in a single line, a line break's two sides in a textarea. */
267
+ _paragraphEdge(index, dir) {
268
+ const chars = this._chars();
269
+ let i = Math.min(Math.max(0, index), chars.length);
270
+ if (dir < 0) {
271
+ while (i > 0 && chars[i - 1] !== '\n') i--;
272
+ } else {
273
+ while (i < chars.length && chars[i] !== '\n') i++;
274
+ }
275
+ return i;
276
+ }
277
+
278
+ /** The start or end of the line `index` is on as it is drawn. One line
279
+ * here; a textarea's wraps. */
280
+ _lineEdge(index, dir) {
281
+ return this._paragraphEdge(index, dir);
282
+ }
283
+
284
+ /** Where ↑ and ↓ take the caret: in a single line, as in an NSTextField,
285
+ * to its start and its end. */
286
+ _verticalStep(dir) {
287
+ return dir < 0 ? 0 : this._chars().length;
288
+ }
289
+ }
290
+
291
+ /** Mac text keys for `TextAreaNode`, installed by textarea.js. */
292
+ export class TextAreaKeys {
293
+ /** ↑ and ↓ keep the goal column they set; every other command is a fresh
294
+ * start, as every other key is in `_editKeyDown`. */
295
+ _runTextCommand(command, extend) {
296
+ if (command !== 'moveUp' && command !== 'moveDown') this._goalX = null;
297
+ return super._runTextCommand(command, extend);
298
+ }
299
+
300
+ /** The wrapped line's ends, as Home and End find them. */
301
+ _lineEdge(index, dir) {
302
+ const layout = this._valueLayout();
303
+ if (!layout || this.value.length === 0) {
304
+ return super._lineEdge(index, dir);
305
+ }
306
+ const pos = layout.caretPosition(index);
307
+ const line = layout.lines[pos.line];
308
+ const y = line.y + (line.ascent + line.descent) / 2;
309
+ // indexAt clamps into the line: far left = line start; just past the
310
+ // right edge = end of visible content (before the newline)
311
+ return dir < 0
312
+ ? layout.indexAt(-1e6, y)
313
+ : layout.indexAt(line.x + line.width + 0.01, y);
314
+ }
315
+
316
+ /** A visual line up or down, keeping the goal column. */
317
+ _verticalStep(dir) {
318
+ const layout = this._valueLayout();
319
+ if (!layout || this.value.length === 0) return this._caret;
320
+ return this._verticalMove(layout, dir);
321
+ }
322
+ }
@@ -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;