react-x11 2.10.2 → 2.12.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.
Files changed (100) hide show
  1. package/README.md +278 -129
  2. package/package.json +10 -3
  3. package/src/Reconciler.js +15 -17
  4. package/src/a11y.js +2 -2
  5. package/src/anchor.js +7 -5
  6. package/src/bootstrap.js +14 -0
  7. package/src/clientmessage.js +1 -1
  8. package/src/cocoa/app.js +304 -49
  9. package/src/cocoa/bezels.js +175 -30
  10. package/src/cocoa/dnd.js +27 -13
  11. package/src/cocoa/fonts.js +3 -3
  12. package/src/cocoa/glarea.js +20 -3
  13. package/src/cocoa/main.d.ts +8 -0
  14. package/src/cocoa/main.js +43 -0
  15. package/src/cocoa/panehost.js +15 -5
  16. package/src/cocoa/presenter.js +13 -9
  17. package/src/cocoa/promotion.js +4 -7
  18. package/src/cocoa/relaunch.js +207 -0
  19. package/src/cocoa/screencolor.js +62 -0
  20. package/src/cocoa/threaded.js +246 -0
  21. package/src/cocoa/window.js +256 -42
  22. package/src/components/Select.js +2 -2
  23. package/src/components/anchor.js +3 -3
  24. package/src/components/native.js +12 -7
  25. package/src/components/theme.js +2 -2
  26. package/src/debug.js +1 -1
  27. package/src/decorations.js +1 -1
  28. package/src/editmenu.js +2 -2
  29. package/src/errors.js +46 -0
  30. package/src/events.js +6 -6
  31. package/src/foreignnodes.js +3 -2
  32. package/src/frames.js +2 -2
  33. package/src/glnodes.js +1 -1
  34. package/src/grid.js +1653 -0
  35. package/src/host.d.ts +230 -0
  36. package/src/host.js +11 -3
  37. package/src/imagesource.js +1 -1
  38. package/src/index.d.ts +21 -4
  39. package/src/index.js +9 -1
  40. package/src/layouts.js +721 -0
  41. package/src/node.d.ts +4 -2
  42. package/src/node.js +19 -21
  43. package/src/nodes/animation.js +644 -0
  44. package/src/nodes/box.js +21 -0
  45. package/src/nodes/boxpaint.js +473 -0
  46. package/src/nodes/canvas.js +269 -0
  47. package/src/nodes/cascade.js +600 -0
  48. package/src/nodes/damage.js +183 -0
  49. package/src/nodes/edithistory.js +124 -0
  50. package/src/nodes/editmenupopup.js +260 -0
  51. package/src/nodes/hittest.js +185 -0
  52. package/src/nodes/image.js +266 -0
  53. package/src/nodes/install.js +75 -0
  54. package/src/nodes/invalidate.js +465 -0
  55. package/src/nodes/kinds.js +31 -0
  56. package/src/nodes/layout.js +439 -0
  57. package/src/nodes/layouthost.js +949 -0
  58. package/src/nodes/node.js +868 -0
  59. package/src/nodes/paint.js +466 -0
  60. package/src/nodes/position.js +366 -0
  61. package/src/nodes/preedit.js +127 -0
  62. package/src/nodes/queries.js +330 -0
  63. package/src/nodes/rects.js +102 -0
  64. package/src/nodes/scrollable.js +891 -0
  65. package/src/nodes/scrollbars.js +138 -0
  66. package/src/nodes/scrollblit.js +1034 -0
  67. package/src/nodes/selectable.js +142 -0
  68. package/src/nodes/styling.js +225 -0
  69. package/src/nodes/text.js +649 -0
  70. package/src/nodes/textarea.js +391 -0
  71. package/src/nodes/textinput.js +1146 -0
  72. package/src/nodes/util.js +17 -0
  73. package/src/nodes/window/anchoring.js +161 -0
  74. package/src/nodes/window/capabilities.js +190 -0
  75. package/src/nodes/window/debugpaint.js +83 -0
  76. package/src/nodes/window/droptarget.js +145 -0
  77. package/src/nodes/window/floors.js +577 -0
  78. package/src/nodes/window/flush.js +334 -0
  79. package/src/nodes/window/hints.js +482 -0
  80. package/src/nodes/window/listeners.js +222 -0
  81. package/src/nodes/window/popup.js +71 -0
  82. package/src/nodes/window/size.js +591 -0
  83. package/src/nodes/window/window.js +945 -0
  84. package/src/palette.js +1 -1
  85. package/src/registry.js +7 -3
  86. package/src/screencolor.js +212 -38
  87. package/src/screencolorhooks.js +6 -2
  88. package/src/styles.js +137 -15
  89. package/src/svgnodes.js +2 -1
  90. package/src/testing/harness.js +2 -2
  91. package/src/textselection.js +5 -3
  92. package/src/trace-registry.js +1 -1
  93. package/src/types/components.d.ts +38 -6
  94. package/src/types/elements.d.ts +11 -1
  95. package/src/types/nodes.d.ts +33 -5
  96. package/src/types/screencolor.d.ts +20 -14
  97. package/src/types/style.d.ts +94 -3
  98. package/src/windowstate.js +1 -1
  99. package/src/yoga.js +1 -1
  100. package/src/nodes.js +0 -13120
@@ -0,0 +1,1146 @@
1
+ // <textinput>: a single-line editable field — value, caret and selection,
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.
4
+
5
+ import { localTextStyleChanged } from '../styles.js';
6
+ import { Yoga } from '../yoga.js';
7
+ import {
8
+ DEFAULTS as DESKTOP_DEFAULTS,
9
+ desktopSettings,
10
+ } from '../desktopsettings.js';
11
+ import { callHandler } from '../errors.js';
12
+ import { hooks as a11yHooks } from '../a11y.js';
13
+ import { lastInputTime } from '../inputtime.js';
14
+ import {
15
+ ctrlChordLetter,
16
+ MOD,
17
+ XK_BACKSPACE,
18
+ XK_RETURN,
19
+ XK_KP_ENTER,
20
+ XK_HOME,
21
+ XK_LEFT,
22
+ XK_RIGHT,
23
+ XK_END,
24
+ XK_DELETE,
25
+ } from '../keysyms.js';
26
+ import { codePoints, wordBoundary, wordRangeAt } from '../textrange.js';
27
+ import { takeVisibleSelection } from '../textselection.js';
28
+ import { TextInputHistory } from './edithistory.js';
29
+ import { openEditMenu, editMenuOpen } from './editmenupopup.js';
30
+ import { installMethods } from './install.js';
31
+ import { Node } from './node.js';
32
+ import { TextInputPreedit } from './preedit.js';
33
+ import { rangeBands } from './text.js';
34
+
35
+ /**
36
+ * How long a caret stays in each of its two states, in milliseconds, on a
37
+ * desktop that did not say.
38
+ *
39
+ * A desktop that *did* say — `Net/CursorBlinkTime`, and `Net/CursorBlink: 0`
40
+ * for "do not blink at all" — is read through `desktopSettings(app)` at the
41
+ * moment a field takes focus. This is the floor under that, and what a
42
+ * connection with no settings daemon uses; it lives in `desktopsettings.js`
43
+ * beside the rest of them so there is one number rather than two.
44
+ *
45
+ * Exported from `react-x11/node` because an element that edits text draws
46
+ * its own caret and would otherwise hardcode a second cadence — two carets
47
+ * on one screen blinking against each other (issue #251). An element with a
48
+ * live connection to hand should prefer `useDesktopSettings().caretBlinkMs`,
49
+ * which is this value already reconciled with the desktop.
50
+ */
51
+ export const CARET_BLINK_MS = DESKTOP_DEFAULTS.caretBlinkMs;
52
+
53
+ /** The caret's own width, in *logical* pixels — it is a rectangle rather
54
+ * than a line because a hairline disappears in a sea of dense pixels.
55
+ * Multiplied by the node's scale where it is drawn and reserved for, like
56
+ * every paint constant that never passes through a style (src/scale.js). */
57
+ export const CARET_WIDTH = 1.5;
58
+
59
+ /** The room a line of a field's text leaves for the caret that follows it,
60
+ * at the right-hand edge of the content box in both directions — see
61
+ * `TextInputNode._lineOriginX`, which is where the asymmetry is explained.
62
+ * Logical, like CARET_WIDTH. */
63
+ export const CARET_RESERVE = 2;
64
+
65
+ /**
66
+ * <textinput>: single-line editable text. Caret/selection via ntk TextLayout
67
+ * prefix measurement, editing via the EventManager default-action hooks
68
+ * (user onKeyDown/onMouseDown handlers run first and can preventDefault).
69
+ * Clipboard: Ctrl+C/X/V on CLIPBOARD, X11-style middle-click paste and
70
+ * select-to-own on PRIMARY (needs ntk >= 5.4.0 app.clipboard; degrades
71
+ * gracefully without it). Controlled (`value` + `onChange`) or uncontrolled
72
+ * (`defaultValue`). Caret indices are in code points, not UTF-16 units.
73
+ */
74
+ export class TextInputNode extends Node {
75
+ constructor(props, app, kind = 'textinput') {
76
+ super(kind, props, app);
77
+ this.focusableByDefault = true;
78
+ this.defaultCursor = 'text';
79
+ // a field's selection is its own: a `selectable` document around it does
80
+ // not get to light up half of what is being typed, and the two take turns
81
+ // being the one selection on screen (textselection.js)
82
+ this.hasOwnSelection = true;
83
+ this._value =
84
+ props.defaultValue != null ? String(props.defaultValue) : null;
85
+ // set only while an onChange/onSubmit handler is on the stack — see
86
+ // `get value()` and `_fireValueEvent`
87
+ this._pendingValue = null;
88
+ // the X key event driving the current edit, for `ev.nativeEvent`
89
+ this._keyNative = null;
90
+ this._caret = this._chars().length;
91
+ this._anchor = this._caret;
92
+ this._scrollX = 0;
93
+ this._focused = false;
94
+ this._caretOn = false;
95
+ this._blinkTimer = null;
96
+ this._dragging = false;
97
+ // undo/redo: snapshots of every state this input has shown, oldest
98
+ // first, with _historyIndex on the current one
99
+ this._history = [
100
+ { value: this.value, caret: this._caret, anchor: this._anchor },
101
+ ];
102
+ this._historyIndex = 0;
103
+ this._historyValue = this.value;
104
+ this._undoRun = null;
105
+ // the uncommitted composition: what a pending dead key or an open
106
+ // Compose sequence is showing, and where it sits in the value. Never
107
+ // part of `value`, and never in the history — see `defaultComposition`
108
+ this._preedit = '';
109
+ this._preeditAt = 0;
110
+ // the open edit menu, if any (see `openEditMenu`)
111
+ this._editMenu = null;
112
+ }
113
+
114
+ /** A preferred width, capped to whatever is on offer — `Infinity` when
115
+ * nothing is, which is what makes the `Math.min` the whole rule. */
116
+ measureContent({ width }) {
117
+ // `_capBand` rounds, and a trimmed `<text>` rounds the same band the
118
+ // same way — rounding one of them and not the other is a pixel of
119
+ // difference between a field and the button beside it.
120
+ return { width: Math.min(150, width), height: this._capBand() };
121
+ }
122
+
123
+ get value() {
124
+ // While an onChange handler is running, the control's value is the one
125
+ // the edit produced — the DOM behaves the same way, and it is what makes
126
+ // `ev.target.value` right in *controlled* mode, where `props.value` is
127
+ // still the old string until the parent re-renders.
128
+ if (this._pendingValue !== null) return this._pendingValue;
129
+ if (this.props.value != null) return String(this.props.value);
130
+ return this._value ?? '';
131
+ }
132
+
133
+ /**
134
+ * Writing `node.value = 'x'` sets the text the way typing would, minus the
135
+ * `onChange` — assigning to a DOM input's `value` does not fire one
136
+ * either. It exists because form libraries reset a field through the ref:
137
+ * react-hook-form's `register()` does `ref.value = ''` on mount and on
138
+ * `reset()`, and a getter-only `value` made that a TypeError during commit.
139
+ *
140
+ * On a **controlled** input `props.value` still wins the next time the
141
+ * parent renders, exactly as in the DOM.
142
+ */
143
+ set value(next) {
144
+ const text = next == null ? '' : String(next);
145
+ if (text === this._value) return;
146
+ this._value = text;
147
+ const len = Array.from(text).length;
148
+ this._caret = Math.min(this._caret, len);
149
+ this._anchor = Math.min(this._anchor, len);
150
+ // same bookkeeping a value arriving through props gets: its own undo
151
+ // entry, so Ctrl+Z steps back through a programmatic reset too
152
+ this._noteExternalValue();
153
+ this._repaint();
154
+ }
155
+
156
+ /** The `name` prop, so `ev.target.name` reads the way the DOM does. */
157
+ get name() {
158
+ return this.props.name;
159
+ }
160
+
161
+ /**
162
+ * The synthetic event `onChange` and `onSubmit` are handed. Same shape
163
+ * every other handler in the system gets — `_makeEvent` builds it — with
164
+ * the value on both `ev.value` and `ev.target.value`, and `name` mirrored
165
+ * the same way, because that is what every DOM form library reads.
166
+ *
167
+ * `_makeEvent` lives on the owning window's EventManager; a node that is
168
+ * not attached to one (a unit test, an edit that outlives its window) gets
169
+ * an equivalent object rather than nothing.
170
+ */
171
+ _makeValueEvent(type, native) {
172
+ // not dispatched through the tree, so currentTarget is the target —
173
+ // exactly what the DOM reports for a handler on the element itself
174
+ const extra = {
175
+ value: this.value,
176
+ name: this.props.name,
177
+ currentTarget: this,
178
+ };
179
+ const events = this.root?.events;
180
+ if (events) return events._makeEvent(type, native, this, extra);
181
+ const ev = {
182
+ type,
183
+ x: native?.x ?? 0,
184
+ y: native?.y ?? 0,
185
+ target: this,
186
+ nativeEvent: native ?? null,
187
+ shiftKey: Boolean(native?.buttons & MOD.Shift),
188
+ ctrlKey: Boolean(native?.buttons & MOD.Control),
189
+ altKey: Boolean(native?.buttons & MOD.Alt),
190
+ metaKey: Boolean(native?.buttons & MOD.Super),
191
+ defaultPrevented: false,
192
+ propagationStopped: false,
193
+ preventDefault() {
194
+ ev.defaultPrevented = true;
195
+ },
196
+ stopPropagation() {
197
+ ev.propagationStopped = true;
198
+ },
199
+ capturePointer() {},
200
+ releasePointer() {},
201
+ ...extra,
202
+ };
203
+ return ev;
204
+ }
205
+
206
+ /**
207
+ * Call `onChange`/`onSubmit` with `value` as the control's current value,
208
+ * whatever `props.value` still says. Restored in a `finally` so a throwing
209
+ * handler cannot leave the control reporting a value it never took.
210
+ */
211
+ _fireValueEvent(prop, value, native = null) {
212
+ const handler = this.props[prop];
213
+ if (!handler) return;
214
+ native ??= this._keyNative;
215
+ const previous = this._pendingValue;
216
+ this._pendingValue = value;
217
+ const type = prop === 'onSubmit' ? 'submit' : 'change';
218
+ try {
219
+ callHandler(this, prop, handler, this._makeValueEvent(type, native));
220
+ } finally {
221
+ this._pendingValue = previous;
222
+ }
223
+ }
224
+
225
+ _chars() {
226
+ return codePoints(this.value);
227
+ }
228
+
229
+ _layoutOf(text) {
230
+ const fonts = this.app?.fonts;
231
+ if (!fonts) return null;
232
+ const style = this.resolvedTextStyle();
233
+ return fonts.layout(text, style);
234
+ }
235
+
236
+ _lineHeight() {
237
+ const layout = this._layoutOf('Mg');
238
+ if (layout) return layout.height;
239
+ // No fonts to measure with. `resolvedTextStyle()` rather than the style
240
+ // prop alone, so the guess is made at the size this field inherits — the
241
+ // palette's — and not at 14 whatever the theme said.
242
+ return this.resolvedTextStyle().size * 1.4;
243
+ }
244
+
245
+ /**
246
+ * What one line of this field is *worth* vertically: the capitals down to
247
+ * the baseline, which is what its padding is measured from.
248
+ *
249
+ * The same rule every label follows — `textBoxTrim: 'cap-alphabetic'` in
250
+ * styling.md — reached a different way, because a field cannot trim. Its
251
+ * caret and its selection are measured against the full line box, and the
252
+ * glyphs have to be able to hang out of the box for the descenders to be
253
+ * there at all; so the *box* is the cap band and the drawing is clipped to
254
+ * the padding box instead, one step out. A field and a `<Button>` with the
255
+ * same padding are then the same height, which is the whole point: they sit
256
+ * next to each other on every form there has ever been.
257
+ */
258
+ /**
259
+ * The rectangle the text may draw in: the padding box horizontally
260
+ * unchanged, vertically grown out to where the border starts. `<textarea>`
261
+ * keeps the content box, because its box *is* line boxes and nothing hangs
262
+ * out of it.
263
+ */
264
+ _inkClip(content) {
265
+ const box = this.abs;
266
+ const top = box.y + this.yoga.getComputedBorder(Yoga.EDGE_TOP);
267
+ const bottom =
268
+ box.y + box.height - this.yoga.getComputedBorder(Yoga.EDGE_BOTTOM);
269
+ return {
270
+ x: content.x,
271
+ y: Math.min(content.y, top),
272
+ width: content.width,
273
+ height: Math.max(content.height, bottom - Math.min(content.y, top)),
274
+ };
275
+ }
276
+
277
+ /** Whole pixels either way: a field's height is a flex item's main size,
278
+ * and a fractional one costs the tree its content floors (see
279
+ * `TextNode._trim`, issue #411). The face with no `capHeight` to round is
280
+ * the one that reaches the fallback. */
281
+ _capBand() {
282
+ const style = this.resolvedTextStyle();
283
+ const cap = this.app?.fonts
284
+ ?.match?.(style.family, { weight: style.weight, style: style.style })
285
+ ?.metrics?.(style.size)?.capHeight;
286
+ return Math.round(cap || this._lineHeight());
287
+ }
288
+
289
+ /** Shaped layout of the current value, cached per (value, style,
290
+ * direction).
291
+ * Caret math rides ntk >= 3.3.0's TextLayout caret API, which is exact
292
+ * across kerning/shaping boundaries, bidi runs and trailing whitespace
293
+ * (replaces the prefix-width measurement this used before).
294
+ *
295
+ * Laid out at its **natural width**, with no `maxWidth` and no `align`:
296
+ * a single-line field never wraps, and a `maxWidth` is what makes ntk
297
+ * break lines. So the alignment ntk would have applied inside the layout
298
+ * box is applied to the box instead, by `_lineOriginX` — see there. */
299
+ _valueLayout() {
300
+ const fonts = this.app?.fonts;
301
+ if (!fonts) return null;
302
+ const text = this._displayValue();
303
+ const s = this.resolvedTextStyle();
304
+ const direction = this.direction;
305
+ const key = `${text}|${s.family}|${s.size}|${s.weight}|${s.style}|${direction}`;
306
+ if (this._valueLayoutKey !== key) {
307
+ this._valueLayoutKey = key;
308
+ this._valueLayoutCache = fonts.layout(text, s, { direction });
309
+ }
310
+ return this._valueLayoutCache;
311
+ }
312
+
313
+ /** Visual caret x for a logical code-point index **in the value**. */
314
+ _prefixWidth(count) {
315
+ return this._prefixWidthAt(this._displayIndex(count));
316
+ }
317
+
318
+ /** The same, for an index in the displayed string — which is the value
319
+ * unless a composition is showing. */
320
+ _prefixWidthAt(index) {
321
+ const layout = this._valueLayout();
322
+ if (!layout) return 0;
323
+ return layout.caretPosition(index).x;
324
+ }
325
+
326
+ _selection() {
327
+ return [
328
+ Math.min(this._caret, this._anchor),
329
+ Math.max(this._caret, this._anchor),
330
+ ];
331
+ }
332
+
333
+ _selectedText() {
334
+ const [a, b] = this._selection();
335
+ return this._chars().slice(a, b).join('');
336
+ }
337
+
338
+ _repaint() {
339
+ this._caretOn = true;
340
+ this.root?.invalidate(false, this, 'text');
341
+ // every edit, caret move and selection change funnels through here
342
+ a11yHooks.textState?.(this);
343
+ }
344
+
345
+ /**
346
+ * The one place the value changes. `kind` names the edit for undo
347
+ * coalescing (`type`, `delete-back`, `delete-forward`); anything left
348
+ * unnamed — a paste, a cut, a replaced selection, a newline — is its own
349
+ * undo step.
350
+ */
351
+ _commit(nextChars, caret, kind = null) {
352
+ const next = nextChars.join('');
353
+ const previous = this.value;
354
+ const beforeCaret = this._caret;
355
+ const beforeAnchor = this._anchor;
356
+ this._caret = caret;
357
+ this._anchor = caret;
358
+ if (this.props.value == null) this._value = next;
359
+ if (next !== previous) {
360
+ // `this.value` is still the old one in controlled mode — the parent
361
+ // has not answered onChange yet — so record what we computed
362
+ this._recordEdit(kind, {
363
+ value: next,
364
+ caret,
365
+ anchor: caret,
366
+ beforeCaret,
367
+ beforeAnchor,
368
+ });
369
+ this._fireValueEvent('onChange', next);
370
+ }
371
+ this._repaint();
372
+ }
373
+
374
+ /** Single-line: newlines collapse to spaces (textarea overrides). */
375
+ _normalizeInsert(text) {
376
+ return String(text).replace(/[\r\n]+/g, ' ');
377
+ }
378
+
379
+ _insert(text, kind = null) {
380
+ const insert = Array.from(this._normalizeInsert(text));
381
+ if (this.props.maxLength != null) {
382
+ const room =
383
+ this.props.maxLength -
384
+ (this._chars().length - (this._selection()[1] - this._selection()[0]));
385
+ if (insert.length > room) insert.length = Math.max(0, room);
386
+ }
387
+ const chars = this._chars();
388
+ const [a, b] = this._selection();
389
+ this._commit(
390
+ [...chars.slice(0, a), ...insert, ...chars.slice(b)],
391
+ a + insert.length,
392
+ kind,
393
+ );
394
+ }
395
+
396
+ _deleteRange(from, to, kind = null) {
397
+ const chars = this._chars();
398
+ this._commit([...chars.slice(0, from), ...chars.slice(to)], from, kind);
399
+ }
400
+
401
+ _moveCaret(index, extend) {
402
+ const len = this._chars().length;
403
+ this._caret = Math.min(Math.max(0, index), len);
404
+ if (!extend) this._anchor = this._caret;
405
+ // typing that resumes somewhere else is a new edit, not the old one
406
+ this._breakUndoRun();
407
+ this._repaint();
408
+ }
409
+
410
+ _clipboardApi() {
411
+ return this.app?.clipboard ?? null;
412
+ }
413
+
414
+ /**
415
+ * Put the selection on a selection — unless this input is `sensitive`.
416
+ *
417
+ * Every route out of the field funnels through here: Ctrl+C, the copy half
418
+ * of Ctrl+X, the right-click menu, and the select-to-own that hands PRIMARY
419
+ * to a middle click in some other application. One gate covers them all,
420
+ * which is the reason the field is the thing that knows it holds a secret
421
+ * rather than each of the six callers.
422
+ *
423
+ * The reason a *revealed* password field still refuses: what is on screen
424
+ * stops being on screen when the field is hidden again, and what is on the
425
+ * clipboard does not. Any client on the display can ask for it, and a
426
+ * clipboard manager will have written it down.
427
+ */
428
+ _copySelection(selection = 'CLIPBOARD') {
429
+ if (this.props.sensitive) return;
430
+ const text = this._selectedText();
431
+ if (!text) return;
432
+ this._clipboardApi()
433
+ // ICCCM 2.1: the timestamp of the event that triggered the copy is
434
+ // what arbitrates a race with another app copying at the same moment.
435
+ // It also saves ntk a round trip asking the server for one, which on
436
+ // PRIMARY is a round trip per selection-extending keystroke.
437
+ ?.write(text, { selection, time: lastInputTime(this.app) })
438
+ .catch((err) => {
439
+ // Losing the race is now possible rather than theoretical: a real
440
+ // event timestamp can be older than another client's, where the
441
+ // server-time fallback never was. A cut that deleted the text
442
+ // without acquiring the selection should not be silent.
443
+ //
444
+ // `err?.message ?? err` because a throw in here would be an
445
+ // unhandled rejection, which ends the process — the exact failure
446
+ // errors.js exists to keep away from a GUI event path.
447
+ console.warn(
448
+ `react-x11: could not take the ${selection} selection: ${err?.message ?? err}`,
449
+ );
450
+ });
451
+ }
452
+
453
+ _pasteFrom(selection = 'CLIPBOARD') {
454
+ this._clipboardApi()
455
+ // ICCCM 2.4: convert with the timestamp of the event that asked for
456
+ // the paste, so an owner that has replaced its data since can tell
457
+ // which value was wanted (ntk >= 5.4.0)
458
+ ?.read({ selection, time: lastInputTime(this.app) })
459
+ .then((text) => {
460
+ if (!this.destroyed && text) this._insert(text);
461
+ })
462
+ .catch(() => {});
463
+ }
464
+
465
+ // --- default actions (run after user handlers unless preventDefault) ---
466
+
467
+ /**
468
+ * Remember the X event driving the edit so the `onChange` it produces can
469
+ * carry it on `nativeEvent`. Subclasses override `_editKeyDown`, not this,
470
+ * so the bookkeeping cannot be forgotten in one of them.
471
+ *
472
+ * Only keystrokes get this far. A paste resolves a promise, an undo is not
473
+ * an input event at all, and a value pushed from a parent has no X event
474
+ * behind it — those report `nativeEvent: null`, which is the truth.
475
+ *
476
+ * `_editKeyDown` answers **whether it took the key**, and a key it took is
477
+ * consumed the way every default action says so: `preventDefault()`, whose
478
+ * meaning one layer down is "the default action after this one does not
479
+ * run". That is what keeps Ctrl+C in a focused field rather than in the
480
+ * menu's accelerator for it, on the same rule Tab and Space/Enter already
481
+ * follow (#351) — and it is why the ctrl chord below returns false for the
482
+ * letters it does *not* answer, instead of swallowing every chord in the
483
+ * alphabet.
484
+ */
485
+ defaultKeyDown(ev) {
486
+ const previous = this._keyNative;
487
+ this._keyNative = ev.nativeEvent ?? null;
488
+ try {
489
+ if (this._editKeyDown(ev)) ev.preventDefault();
490
+ } finally {
491
+ this._keyNative = previous;
492
+ }
493
+ }
494
+
495
+ /** @returns {boolean} whether the field answered this key. */
496
+ _editKeyDown(ev) {
497
+ const [a, b] = this._selection();
498
+ const hasSelection = a !== b;
499
+ const k = ev.keysym;
500
+
501
+ if (k === XK_RETURN || k === XK_KP_ENTER) {
502
+ this._fireValueEvent('onSubmit', this.value, ev.nativeEvent);
503
+ return true;
504
+ }
505
+ if (k === XK_BACKSPACE) {
506
+ if (hasSelection) this._deleteRange(a, b);
507
+ else if (ev.ctrlKey) this._deleteRange(this._wordBoundary(a, -1), a);
508
+ else if (a > 0) this._deleteRange(a - 1, a, 'delete-back');
509
+ return true;
510
+ }
511
+ if (k === XK_DELETE) {
512
+ if (hasSelection) this._deleteRange(a, b);
513
+ else if (ev.ctrlKey) this._deleteRange(a, this._wordBoundary(a, 1));
514
+ else {
515
+ this._deleteRange(
516
+ a,
517
+ Math.min(a + 1, this._chars().length),
518
+ 'delete-forward',
519
+ );
520
+ }
521
+ return true;
522
+ }
523
+ if (k === XK_LEFT) {
524
+ if (ev.ctrlKey) {
525
+ this._moveCaret(this._wordBoundary(this._caret, -1), ev.shiftKey);
526
+ } else if (!ev.shiftKey && hasSelection) {
527
+ this._moveCaret(a, false);
528
+ } else {
529
+ this._moveCaret(this._caret - 1, ev.shiftKey);
530
+ }
531
+ if (ev.shiftKey) this._copySelection('PRIMARY');
532
+ return true;
533
+ }
534
+ if (k === XK_RIGHT) {
535
+ if (ev.ctrlKey) {
536
+ this._moveCaret(this._wordBoundary(this._caret, 1), ev.shiftKey);
537
+ } else if (!ev.shiftKey && hasSelection) {
538
+ this._moveCaret(b, false);
539
+ } else {
540
+ this._moveCaret(this._caret + 1, ev.shiftKey);
541
+ }
542
+ if (ev.shiftKey) this._copySelection('PRIMARY');
543
+ return true;
544
+ }
545
+ if (k === XK_HOME) {
546
+ this._moveCaret(0, ev.shiftKey);
547
+ return true;
548
+ }
549
+ if (k === XK_END) {
550
+ this._moveCaret(this._chars().length, ev.shiftKey);
551
+ return true;
552
+ }
553
+ if (ev.ctrlKey) {
554
+ const letter = ctrlChordLetter(ev);
555
+ if (letter === 0x61 /* a */) {
556
+ this._selectAll();
557
+ } else if (letter === 0x63 /* c */) {
558
+ this._copySelection();
559
+ } else if (letter === 0x78 /* x */) {
560
+ this._copySelection();
561
+ if (hasSelection) this._deleteRange(a, b);
562
+ } else if (letter === 0x76 /* v */) {
563
+ this._pasteFrom();
564
+ } else if (letter === 0x7a /* z */) {
565
+ // Ctrl+Shift+Z redoes, the way it does in GTK and Qt
566
+ if (ev.shiftKey) this.redo();
567
+ else this.undo();
568
+ } else if (letter === 0x79 /* y */) {
569
+ this.redo();
570
+ } else {
571
+ // A chord this field has no answer for is not the field's: Ctrl+S
572
+ // belongs to whatever bound it, and a text control that swallowed
573
+ // every chord would be a text control no application can put a
574
+ // shortcut behind.
575
+ return false;
576
+ }
577
+ return true;
578
+ }
579
+ if (ev.codepoint != null && ev.codepoint >= 0x20 && ev.codepoint !== 0x7f) {
580
+ const ch = String.fromCodePoint(ev.codepoint);
581
+ this._insert(ch, 'type');
582
+ // undo a word at a time: the space that ends a word joins the run it
583
+ // ends, and the next word starts a fresh one
584
+ if (/\s/.test(ch)) this._breakUndoRun();
585
+ return true;
586
+ }
587
+ return false;
588
+ }
589
+
590
+ /** Click-to-caret for a mouse event. Both kinds answer it the same way —
591
+ * through the field's own geometry accessor, which is the one the caret
592
+ * and the highlight are drawn from. */
593
+ _indexAtPoint(ev) {
594
+ // `textIndexAt` is the device-pixel geometry contract (it is also what
595
+ // the a11y bridge calls with screen points); the synthetic event is
596
+ // logical, so the click goes back through its native coordinates.
597
+ const native = ev.nativeEvent;
598
+ return this.textIndexAt(
599
+ native?.x ?? ev.x * this.scale,
600
+ native?.y ?? ev.y * this.scale,
601
+ );
602
+ }
603
+
604
+ /** Word range around a code-point index (whitespace-delimited). */
605
+ _wordRangeAt(index) {
606
+ return wordRangeAt(this._chars(), index);
607
+ }
608
+
609
+ /** Caret index one word away, the way Ctrl+arrow moves in a text editor. */
610
+ _wordBoundary(from, dir) {
611
+ return wordBoundary(this._chars(), from, dir);
612
+ }
613
+
614
+ // --- geometry (the accessors every text-bearing element answers) --------
615
+
616
+ /**
617
+ * Where a single line of text sits in the field, and the band a mark over
618
+ * it fills. Centred on the **capitals**, not on the line box and not on
619
+ * the ink.
620
+ *
621
+ * The layout box carries the line's leading entirely below the glyphs, so
622
+ * centring that pushes the text visually up (see `halfLeading`). Centring
623
+ * ascent + descent — what this did — fixes the leading but not the
624
+ * asymmetry underneath it: a font's ascent clears its capitals by
625
+ * `ascent - capHeight`, which is not its descent, so a single line of
626
+ * text sits off-centre by a number that belongs to the typeface. At 14px
627
+ * that is 0.7px of extra space above the capitals in SF NS and 2.5px the
628
+ * other way in Helvetica — visible in a field, where there is one short
629
+ * line and a border close on both sides to measure it against.
630
+ *
631
+ * So: put the baseline where the space above the capitals equals the
632
+ * space under it. A `<text>` says the same thing as `textBoxTrim`, but a
633
+ * field cannot trim its box — the caret and the selection are measured
634
+ * against the full line box — so it moves the line instead, and the marks
635
+ * follow because they are derived from the same origin.
636
+ */
637
+ _lineMetrics(layout, content, style) {
638
+ const line = layout.lines?.[0];
639
+ const inkHeight = line ? line.ascent + line.descent : layout.height;
640
+ const ascent = line?.ascent ?? 0;
641
+ // Where the painter puts the first baseline inside the layout box. It is
642
+ // **not** `ascent`: the line carries its leading above the glyphs too, so
643
+ // on a face with a real line gap the two are pixels apart. Verdana's gap
644
+ // is 0.03em and the error rounds away; Hiragino Sans — which is what
645
+ // `sans-serif` resolves to on a macOS box with Homebrew's fontconfig
646
+ // first on PATH (#86) — carries 0.5em, and every field drew its text
647
+ // three pixels low. Positioning by `ascent` was the whole of that bug.
648
+ const baseline = line?.baseline ?? ascent;
649
+ const leading = baseline - ascent;
650
+ const capHeight = this.app?.fonts
651
+ ?.match?.(style.family, {
652
+ weight: style.weight,
653
+ style: style.style,
654
+ })
655
+ ?.metrics?.(style.size)?.capHeight;
656
+ const textY =
657
+ capHeight && line
658
+ ? content.y + (content.height + capHeight) / 2 - baseline
659
+ : content.y + Math.max(0, (content.height - inkHeight) / 2) - leading;
660
+ // The glyphs start a leading below the box they are drawn in, and the
661
+ // marks are measured against the glyphs rather than against the box.
662
+ const inkTop = textY + leading;
663
+ // selection/caret read better with breathing room around the glyphs
664
+ // (a DOM input highlights the whole line box, not just the ink)
665
+ const markPad = Math.min(3, Math.max(0, inkTop - content.y));
666
+ return {
667
+ textY,
668
+ markY: inkTop - markPad,
669
+ markHeight: inkHeight + markPad * 2,
670
+ inkHeight,
671
+ };
672
+ }
673
+
674
+ /**
675
+ * Where a line of the field's text starts, in window coordinates, before
676
+ * the scroll offset — the whole of the field's horizontal placement, and
677
+ * the only place that knows which edge the text is against.
678
+ *
679
+ * The line is laid out at its natural width (`_valueLayout`), so ntk had
680
+ * no container to align it in and `line.x` is 0. This is that alignment:
681
+ * `textAlign` resolved against the base direction, which is what puts an
682
+ * RTL field's value, placeholder and caret on the right-hand side without
683
+ * anybody asking for it.
684
+ *
685
+ * **The caret is why the reserve is at the right in both directions.** A
686
+ * caret is a rectangle drawn *rightwards* from the boundary it marks, so
687
+ * the one position that can fall outside the content box is the rightmost
688
+ * one: the end of the text in LTR — which is what the `+ 2` in the scroll
689
+ * clamp has always been keeping room for — and the *start* of it in RTL,
690
+ * where the text is flush against the right edge and index 0 sits on it.
691
+ * Without the reserve an empty RTL field has no visible caret at all.
692
+ *
693
+ * Alignment only has a say while the text fits. An overflowing field
694
+ * scrolls, and `_scrollX: 0` means "showing the start of the value", so
695
+ * the start edge is where an overflowing line is pinned whatever the
696
+ * alignment says.
697
+ */
698
+ _lineOriginX(layout, content) {
699
+ const rtl = this.direction === 'rtl';
700
+ const free = content.width - CARET_RESERVE * this.scale - layout.width;
701
+ let align = this.style.textAlign ?? 'start';
702
+ if (align === 'start') align = rtl ? 'right' : 'left';
703
+ else if (align === 'end') align = rtl ? 'left' : 'right';
704
+ const offset = align === 'right' ? free : align === 'center' ? free / 2 : 0;
705
+ return content.x + (rtl ? Math.min(free, offset) : Math.max(0, offset));
706
+ }
707
+
708
+ /**
709
+ * How far the text is displaced by the scroll, signed. `_scrollX` counts
710
+ * from the start of the value in both directions — the rule
711
+ * `ScrollableNode` follows for a scroll box — and the start is the
712
+ * right-hand edge when the field reads right to left, so the text it
713
+ * uncovers is to the *left* and the displacement is the other way.
714
+ */
715
+ _scrollShift() {
716
+ return this.direction === 'rtl' ? this._scrollX : -this._scrollX;
717
+ }
718
+
719
+ /** How far a value wider than its box can be scrolled. The reserve is the
720
+ * caret's, the same one `_lineOriginX` keeps: at full scroll the far end
721
+ * of the text stops short of the edge by exactly the width of the caret
722
+ * that sits there. */
723
+ _maxScrollX(layout, content) {
724
+ return Math.max(
725
+ 0,
726
+ layout.width - (content.width - CARET_RESERVE * this.scale),
727
+ );
728
+ }
729
+
730
+ /** The value's layout and where it is drawn, in window coordinates. The
731
+ * placeholder is not in it: the accessors answer about the text the field
732
+ * holds, and an empty field holds none. */
733
+ _placedValue() {
734
+ const layout = this._valueLayout();
735
+ if (!layout) return null;
736
+ const content = this.contentBox();
737
+ const metrics = this._lineMetrics(
738
+ layout,
739
+ content,
740
+ this.resolvedTextStyle(),
741
+ );
742
+ return {
743
+ layout,
744
+ x: this._lineOriginX(layout, content) + this._scrollShift(),
745
+ y: metrics.textY,
746
+ };
747
+ }
748
+
749
+ /** The value. The indices below are into this string, in code points — an
750
+ * open composition is spliced into what is *drawn* and never into what the
751
+ * field holds, so it is not in here either. */
752
+ textContent() {
753
+ return this.value;
754
+ }
755
+
756
+ textIndexAt(x, y) {
757
+ const placed = this._placedValue();
758
+ if (!placed) return this._chars().length;
759
+ return this._valueIndex(placed.layout.indexAt(x - placed.x, y - placed.y));
760
+ }
761
+
762
+ textCaretRect(index) {
763
+ const placed = this._placedValue();
764
+ if (!placed) return null;
765
+ const caret = placed.layout.caretPosition(this._displayIndex(index));
766
+ return {
767
+ x: placed.x + caret.x,
768
+ y: placed.y + caret.y,
769
+ width: 0,
770
+ height: caret.height,
771
+ };
772
+ }
773
+
774
+ textRangeRects(start, end) {
775
+ const placed = this._placedValue();
776
+ if (!placed) return [];
777
+ return rangeBands(
778
+ placed.layout,
779
+ this._displayValue(),
780
+ this._displayIndex(start),
781
+ this._displayIndex(end),
782
+ ).map((band) => ({
783
+ x: placed.x + band.x,
784
+ y: placed.y + band.y,
785
+ width: band.width,
786
+ height: band.height,
787
+ }));
788
+ }
789
+
790
+ defaultMouseDown(ev) {
791
+ // wherever the caret lands, editing resumes as a new undo entry
792
+ this._breakUndoRun();
793
+ if (ev.button === 3) {
794
+ // A right-click is about to open a menu that acts on the selection,
795
+ // so it must not be the thing that throws the selection away: a click
796
+ // inside one keeps it, and only a click outside moves the caret. Both
797
+ // GTK and Qt behave this way, and it is why this cannot fall through
798
+ // to the caret placement below — that also started a drag nobody
799
+ // asked for.
800
+ const i = this._indexAtPoint(ev);
801
+ const [a, b] = this._selection();
802
+ if (i < a || i > b) {
803
+ this._caret = i;
804
+ this._anchor = i;
805
+ this._repaint();
806
+ }
807
+ return;
808
+ }
809
+ if (ev.button === 2) {
810
+ // X11 middle-click: paste the PRIMARY selection at the click position
811
+ const i = this._indexAtPoint(ev);
812
+ this._caret = i;
813
+ this._anchor = i;
814
+ this._pasteFrom('PRIMARY');
815
+ return;
816
+ }
817
+ const i = this._indexAtPoint(ev);
818
+ if (ev.detail >= 3) {
819
+ this._anchor = 0;
820
+ this._caret = this._chars().length;
821
+ this._ownSelection();
822
+ return;
823
+ }
824
+ if (ev.detail === 2) {
825
+ const [a, b] = this._wordRangeAt(i);
826
+ this._anchor = a;
827
+ this._caret = b;
828
+ this._ownSelection();
829
+ return;
830
+ }
831
+ // shift+click extends from the existing anchor rather than starting a
832
+ // fresh selection, and keeps dragging from there
833
+ if (ev.shiftKey) {
834
+ this._caret = i;
835
+ this._dragging = true;
836
+ this._ownSelection();
837
+ return;
838
+ }
839
+ this._caret = i;
840
+ this._anchor = i;
841
+ this._dragging = true;
842
+ this._repaint();
843
+ }
844
+
845
+ /** Select everything, and take PRIMARY with it. Both ways in — Ctrl+A and
846
+ * the menu row — come through here: every other selection gesture owns
847
+ * PRIMARY, and GTK and Qt both do it for select-all too, so a middle-click
848
+ * paste after Ctrl+A pastes what is on screen rather than whatever was
849
+ * selected before it. */
850
+ _selectAll() {
851
+ this._anchor = 0;
852
+ this._caret = this._chars().length;
853
+ this._breakUndoRun();
854
+ this._ownSelection();
855
+ }
856
+
857
+ _ownSelection() {
858
+ this._repaint();
859
+ if (this._caret === this._anchor) return;
860
+ // Whatever else on screen was showing a selection stops: the highlight
861
+ // is a single-owner thing across the whole app, the way PRIMARY is
862
+ // across the whole display (issue #259).
863
+ takeVisibleSelection(this);
864
+ this._copySelection('PRIMARY');
865
+ }
866
+
867
+ /** Another surface took the visible selection. Collapse, rather than only
868
+ * stop drawing: two lit ranges on one screen is the state this exists to
869
+ * prevent, and a field that kept its own would light it up again the next
870
+ * time it was focused. */
871
+ _selectionLost() {
872
+ if (this._caret === this._anchor) return;
873
+ this._anchor = this._caret;
874
+ this._repaint();
875
+ }
876
+
877
+ /** The selection stays lit while its own menu is up: the popup holds the
878
+ * keyboard, so `_focused` is false, but the text the menu is about to act
879
+ * on has to stay visibly selected. */
880
+ _showsSelection() {
881
+ return this._focused || editMenuOpen(this);
882
+ }
883
+
884
+ defaultMouseDrag(ev) {
885
+ if (!this._dragging) return;
886
+ this._caret = this._indexAtPoint(ev);
887
+ this._repaint();
888
+ }
889
+
890
+ defaultMouseUp() {
891
+ if (!this._dragging) return;
892
+ this._dragging = false;
893
+ this._ownSelection();
894
+ }
895
+
896
+ // --- the built-in edit menu -----------------------------------------
897
+ //
898
+ // The menu itself is `openEditMenu` (above): core's, exported, and shared
899
+ // with anything else that edits or selects text (#256). What is left here
900
+ // is the only part that is the field's — which verbs it offers, and what
901
+ // each of them is worth right now. `contextMenu={false}` opts out, as does
902
+ // `preventDefault()` in an `onContextMenu` handler.
903
+
904
+ /** The verbs the standard menu is opened with. Every one of them is the
905
+ * keyboard path's own entry point, so a row can never drift from what its
906
+ * shortcut does. */
907
+ _editActions() {
908
+ const [a, b] = this._selection();
909
+ const length = this._chars().length;
910
+ return {
911
+ canUndo: this.canUndo,
912
+ undo: () => this.undo(),
913
+ canRedo: this.canRedo,
914
+ redo: () => this.redo(),
915
+ hasSelection: a !== b,
916
+ // A `sensitive` field offers neither Cut nor Copy: they are not
917
+ // disabled rows, they are absent, because a greyed Copy over a
918
+ // password reads as a bug in the application rather than as a
919
+ // decision. Paste stays — a secret still has to be got in.
920
+ ...(this.props.sensitive
921
+ ? null
922
+ : {
923
+ cut: () => {
924
+ const [from, to] = this._selection();
925
+ this._copySelection();
926
+ if (from !== to) this._deleteRange(from, to);
927
+ },
928
+ copy: () => this._copySelection(),
929
+ }),
930
+ paste: () => this._pasteFrom(),
931
+ canSelectAll: length > 0 && !(a === 0 && b === length),
932
+ selectAll: () => this._selectAll(),
933
+ };
934
+ }
935
+
936
+ defaultContextMenu(ev) {
937
+ if (this.props.contextMenu === false) return;
938
+ openEditMenu(this, { x: ev.x, y: ev.y }, this._editActions());
939
+ }
940
+
941
+ defaultFocus() {
942
+ this._focused = true;
943
+ this._caretOn = true;
944
+ // The desktop's cadence, read at focus rather than at import: XSETTINGS
945
+ // is started but not awaited by createRoot, so this is the first moment
946
+ // it is reliably in — and a field focused before that gets the default
947
+ // and the desktop's answer from the next focus on.
948
+ const { caretBlink, caretBlinkMs } = desktopSettings(this.root?.app);
949
+ // `Net/CursorBlink: 0` is an accessibility setting, not a preference: a
950
+ // solid caret is still a caret, so the field draws one and never arms a
951
+ // timer for it.
952
+ if (caretBlink) {
953
+ this._blinkTimer = setInterval(() => {
954
+ // A field can still hold focus when the connection goes — an app
955
+ // closing its own client, a server exit, a test closing the app it
956
+ // lent the root. Nothing blurs the field on that route, so this is
957
+ // the timer's own exit: the next tick would paint a caret onto a
958
+ // closing connection and throw out of the frame clock, where there
959
+ // is nothing to catch it.
960
+ if (this.destroyed || this.app?.X?._closing) {
961
+ clearInterval(this._blinkTimer);
962
+ this._blinkTimer = null;
963
+ return;
964
+ }
965
+ this._caretOn = !this._caretOn;
966
+ // twice a second, forever, for as long as a field has focus: the one
967
+ // repaint that most wants to cost only the field it happens in
968
+ this.root?.invalidate(false, this, 'caret');
969
+ }, caretBlinkMs);
970
+ this._blinkTimer.unref?.();
971
+ }
972
+ this.root?.invalidate(false, this, 'focus');
973
+ }
974
+
975
+ defaultBlur() {
976
+ this._focused = false;
977
+ this._caretOn = false;
978
+ // the EventManager ends an open composition before focus moves, so this
979
+ // is the belt to that braces: a field that lost focus by some other
980
+ // route must not keep drawing an accent nobody can finish
981
+ this._preedit = '';
982
+ // coming back to a field later is a new edit, not more of the old one
983
+ this._breakUndoRun();
984
+ clearInterval(this._blinkTimer);
985
+ this._blinkTimer = null;
986
+ this.root?.invalidate(false, this, 'focus');
987
+ }
988
+
989
+ destroySubtree() {
990
+ clearInterval(this._blinkTimer);
991
+ this._blinkTimer = null;
992
+ super.destroySubtree();
993
+ }
994
+
995
+ applyProps(newProps, oldProps) {
996
+ const before = oldProps ?? this.props;
997
+ const beforeStyle = this.style;
998
+ super.applyProps(newProps, oldProps);
999
+ const len = Array.from(
1000
+ newProps.value != null ? String(newProps.value) : (this._value ?? ''),
1001
+ ).length;
1002
+ this._caret = Math.min(this._caret, len);
1003
+ this._anchor = Math.min(this._anchor, len);
1004
+ this._noteExternalValue();
1005
+ // The face and the size arrive through `_textStyleMoved`, whether they
1006
+ // came from this commit or from an ancestor; what is left here is the
1007
+ // node-local half of the text vocabulary.
1008
+ if (localTextStyleChanged(this.style, beforeStyle)) {
1009
+ this.invalidateMeasure('props');
1010
+ } else if (newProps.value !== before.value) {
1011
+ // painting clips to the content box and the measure function reads
1012
+ // only font metrics, so a value change is confined to the field —
1013
+ // the same claim applyProps makes for any paint-only prop
1014
+ this.root?.invalidate(false, this, 'text');
1015
+ }
1016
+ }
1017
+
1018
+ paintContent(ctx) {
1019
+ const fonts = this.app?.fonts;
1020
+ if (!fonts) return;
1021
+ const content = this.contentBox();
1022
+ if (content.width <= 0 || content.height <= 0) return;
1023
+
1024
+ const style = this.resolvedTextStyle();
1025
+ const text = this._displayValue();
1026
+ const isEmpty = text.length === 0;
1027
+ const shown = isEmpty ? (this.props.placeholder ?? '') : text;
1028
+ const color = isEmpty
1029
+ ? (this.props.placeholderColor ?? this.theme.textMuted)
1030
+ : style.color;
1031
+ // The placeholder is the *field's* chrome rather than the user's
1032
+ // content, so it is laid out and placed at the field's own direction —
1033
+ // an English hint in an Arabic form starts at the right-hand edge with
1034
+ // everything else in the window.
1035
+ const layout = fonts.layout([{ text: shown, ...style, color }], style, {
1036
+ direction: this.direction,
1037
+ });
1038
+ const { textY, markY, markHeight } = this._lineMetrics(
1039
+ layout,
1040
+ content,
1041
+ style,
1042
+ );
1043
+
1044
+ // Keep the caret inside the viewport — but only while the field is being
1045
+ // edited. The caret starts at the *end* of the value, so chasing it
1046
+ // unconditionally meant a field whose text is wider than its box rendered
1047
+ // scrolled to the end before anyone had touched it: the first characters
1048
+ // were simply missing, which reads as a rendering bug rather than as a
1049
+ // scroll position. An unfocused field shows the beginning of its value,
1050
+ // the way a DOM input does.
1051
+ //
1052
+ // The chase is in *visual* coordinates — how far the caret is from the
1053
+ // left of the content box once the value has been placed and scrolled —
1054
+ // because that is the question the viewport asks, and it is the same
1055
+ // question in both directions. Which way the scroll displaces the text
1056
+ // is `_scrollShift`'s business.
1057
+ const valueLayout = this._valueLayout();
1058
+ const caretX = this._prefixWidth(this._caret);
1059
+ // where the caret sits before the scroll, relative to the content box
1060
+ const caretAt = valueLayout
1061
+ ? this._lineOriginX(valueLayout, content) + caretX - content.x
1062
+ : caretX;
1063
+ const limit = content.width - CARET_RESERVE * this.scale;
1064
+ if (!this._focused) this._scrollX = 0;
1065
+ else {
1066
+ let shift = this._scrollShift();
1067
+ if (caretAt + shift > limit) shift = limit - caretAt;
1068
+ if (caretAt + shift < 0) shift = -caretAt;
1069
+ this._scrollX = this.direction === 'rtl' ? shift : -shift;
1070
+ }
1071
+ // The extent is the *value*'s, never the placeholder's: a hint longer
1072
+ // than the box is clipped, not scrolled, since nothing can move the caret
1073
+ // through it.
1074
+ this._scrollX = Math.max(
1075
+ 0,
1076
+ Math.min(
1077
+ this._scrollX,
1078
+ valueLayout ? this._maxScrollX(valueLayout, content) : 0,
1079
+ ),
1080
+ );
1081
+
1082
+ ctx.save();
1083
+ ctx.beginPath();
1084
+ // Clipped to the **padding** box, not the content box: the content box is
1085
+ // the cap band, and an ascender or a descender is outside it by
1086
+ // construction. The padding is where a field's own border stops the text
1087
+ // anyway, so this is the edge that was always meant.
1088
+ const clip = this._inkClip(content);
1089
+ ctx.rect(clip.x, clip.y, clip.width, clip.height);
1090
+ ctx.clip();
1091
+ const shift = this._scrollShift();
1092
+ // Where the ink goes, and where the marks over the value go. They are
1093
+ // the same origin whenever there is a value to mark: the two differ only
1094
+ // for an empty field, where the ink is the placeholder — as wide as the
1095
+ // hint and placed for it — and the caret still belongs to the value,
1096
+ // which is empty and sits at the start edge.
1097
+ const originX = this._lineOriginX(layout, content) + shift;
1098
+ const valueX = valueLayout
1099
+ ? this._lineOriginX(valueLayout, content) + shift
1100
+ : originX;
1101
+
1102
+ const [a, b] = this._selection();
1103
+ if (this._showsSelection() && a !== b && !isEmpty && valueLayout) {
1104
+ // A **translucent** accent rather than an opaque light blue. The ink
1105
+ // on top is `style.color`, which this fill does not control, so an
1106
+ // opaque highlight has to be picked to contrast with it — and no one
1107
+ // colour does that on both a light and a dark palette. `#b3d4fc`
1108
+ // under the dark palette's near-white ink is 1.3:1, which is nothing.
1109
+ // Tinting the surface instead leaves the ink's own contrast intact.
1110
+ ctx.fillStyle = this.props.selectionColor ?? this.theme.selection;
1111
+ // One band per direction run, not one rectangle between the two caret
1112
+ // positions: a range is contiguous in logical order and a line is laid
1113
+ // out in visual order, so a selection that crosses into an Arabic word
1114
+ // covers two disjoint stretches of pixels and the single rect this
1115
+ // used to draw painted over text nobody had selected. The bands are
1116
+ // `textRangeRects`'s, so the highlight is the geometry the accessors
1117
+ // report; only the vertical extent is the field's own — a field
1118
+ // highlights the cap band with breathing room rather than the line box.
1119
+ for (const band of rangeBands(
1120
+ valueLayout,
1121
+ this._displayValue(),
1122
+ this._displayIndex(a),
1123
+ this._displayIndex(b),
1124
+ )) {
1125
+ ctx.fillRect(valueX + band.x, markY, band.width, markHeight);
1126
+ }
1127
+ }
1128
+
1129
+ layout.draw(ctx, originX, textY);
1130
+ this._paintPreedit(ctx, valueX, textY, style);
1131
+
1132
+ if (this._focused && this._caretOn && a === b) {
1133
+ ctx.fillStyle = this.props.caretColor ?? this.theme.caret ?? style.color;
1134
+ ctx.fillRect(
1135
+ valueX + caretX,
1136
+ markY,
1137
+ CARET_WIDTH * this.scale,
1138
+ markHeight,
1139
+ );
1140
+ }
1141
+ ctx.restore();
1142
+ }
1143
+ }
1144
+
1145
+ // Undo/redo and IME composition live in their own files (see install.js).
1146
+ installMethods(TextInputNode, TextInputHistory, TextInputPreedit);