react-x11 2.40.0 → 2.41.1

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.
@@ -17,8 +17,78 @@ import {
17
17
  scrollbarHit,
18
18
  paintScrollbarThumb,
19
19
  } from './scrollbars.js';
20
+ import { installMethods } from './install.js';
20
21
  import { rangeBands } from './text.js';
21
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
+ }
22
92
 
23
93
  /**
24
94
  * <textarea>: multi-line editable text on the same editing core as
@@ -27,6 +97,8 @@ import { CARET_WIDTH, CARET_RESERVE, TextInputNode } from './textinput.js';
27
97
  * between visual lines keeping a goal column, Home/End are wrap-aware,
28
98
  * selection spans lines, and the view scrolls vertically to follow the
29
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.
30
102
  */
31
103
  export class TextAreaNode extends TextInputNode {
32
104
  /**
@@ -141,11 +213,13 @@ export class TextAreaNode extends TextInputNode {
141
213
  const key = `${width}|${color}|${shown}|${s.family}|${s.size}|${s.weight}|${s.style}|${direction}|${align}|${s.letterSpacing}|${JSON.stringify(s.features ?? null)}`;
142
214
  if (this._valueLayoutKey !== key) {
143
215
  this._valueLayoutKey = key;
144
- this._valueLayoutCache = fonts.layout([{ text: shown, ...s, color }], s, {
145
- maxWidth: container,
146
- align,
147
- direction,
148
- });
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
+ );
149
223
  }
150
224
  return this._valueLayoutCache;
151
225
  }
@@ -265,17 +339,11 @@ export class TextAreaNode extends TextInputNode {
265
339
  return true;
266
340
  }
267
341
  if ((k === XK_HOME || k === XK_END) && layout && this.value.length > 0) {
268
- const pos = layout.caretPosition(this._caret);
269
- const line = layout.lines[pos.line];
270
- const y = line.y + (line.ascent + line.descent) / 2;
271
- // indexAt clamps into the line: far left = line start; just past the
272
- // right edge = end of visible content (before the newline)
273
- const i =
274
- k === XK_HOME
275
- ? layout.indexAt(-1e6, y)
276
- : layout.indexAt(line.x + line.width + 0.01, y);
277
342
  this._goalX = null;
278
- this._moveCaret(i, ev.shiftKey);
343
+ this._moveCaret(
344
+ this._lineEdge(this._caret, k === XK_HOME ? -1 : 1),
345
+ ev.shiftKey,
346
+ );
279
347
  return true;
280
348
  }
281
349
  this._goalX = null;
@@ -391,3 +459,5 @@ export class TextAreaNode extends TextInputNode {
391
459
  ctx.restore();
392
460
  }
393
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';
@@ -32,6 +33,7 @@ import { installMethods } from './install.js';
32
33
  import { Node } from './node.js';
33
34
  import { TextInputPreedit } from './preedit.js';
34
35
  import { rangeBands } from './text.js';
36
+ import { TextInputKeys } from './textkeys.js';
35
37
 
36
38
  /**
37
39
  * How long a caret stays in each of its two states, in milliseconds, on a
@@ -487,7 +489,11 @@ export class TextInputNode extends Node {
487
489
  const previous = this._keyNative;
488
490
  this._keyNative = ev.nativeEvent ?? null;
489
491
  try {
490
- 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();
491
497
  } finally {
492
498
  this._keyNative = previous;
493
499
  }
@@ -1152,4 +1158,9 @@ export class TextInputNode extends Node {
1152
1158
  }
1153
1159
 
1154
1160
  // Undo/redo and IME composition live in their own files (see install.js).
1155
- 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
+ }
package/src/offscreen.js CHANGED
@@ -5,13 +5,14 @@
5
5
  // An app that makes its own surfaces answers `createSurface(options)` — the
6
6
  // Cocoa app over a CG bitmap (src/cocoa/surface.js), the Windows app over a
7
7
  // Direct2D bitmap, the Wayland app over a GL render target
8
- // (src/wayland/surface.js) — and an X connection has no such method and gets
9
- // ntk's pixmap, from ntk's root, which only an X connection loads
10
- // (src/ntkroot.js). The result is whichever implementation answered, not an
11
- // instance of this class: the contract is the shape — `width`/`height`,
12
- // `getContext('2d')`, `render`, `clear`, `copyWithin`, `destroy`, and
13
- // `ctx.drawImage(surface, …)` — (docs/extending.md "Scrolling the pixels, not
14
- // just the offset"), and nothing needs `instanceof`.
8
+ // (src/wayland/surface.js), and ntk's own app over an X connection with its
9
+ // pixmap (ntk >= 8.21). An app without the method — an older ntk deduped
10
+ // into the tree — gets that pixmap from ntk's root, which only an X
11
+ // connection loads (src/ntkroot.js). The result is whichever implementation
12
+ // answered, not an instance of this class: the contract is the shape —
13
+ // `width`/`height`, `getContext('2d')`, `render`, `clear`, `copyWithin`,
14
+ // `destroy`, and `ctx.drawImage(surface, …)` — (docs/extending.md "Scrolling
15
+ // the pixels, not just the offset"), and nothing needs `instanceof`.
15
16
  //
16
17
  // Part of that shape is that a context may be **held**: ntk tells a caller
17
18
  // doing many draws to take `getContext('2d')` once rather than one per frame,
@@ -140,10 +140,14 @@ export interface KeyboardEvent<T = DrawnNode> extends SyntheticEvent<T> {
140
140
  * group and sends no other notice. */
141
141
  group: number;
142
142
  /** Unicode code point, undefined when the key produces no character —
143
- * which includes every key a composition took (see `composing`). */
143
+ * which includes every key a composition took (see `composing`), a dead
144
+ * key on the Cocoa backend, and a key that typed more than one character
145
+ * there, whose text arrives as a composition that starts and ends on it. */
144
146
  codepoint?: number;
145
147
  /** The character the key produced, undefined for non-printing keys and
146
- * for keys a composition took. */
148
+ * for the keys `codepoint` is undefined for. On the Cocoa backend it is
149
+ * what the layout typed with ⌥ applied — `ß` for ⌥S — while `keysym` is
150
+ * still the key. */
147
151
  key?: string;
148
152
  /** Whether this key belongs to an open composition — a dead key, or a key
149
153
  * of a Compose sequence. Its text arrives on the composition events