react-x11 2.11.0 → 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.
- package/README.md +278 -129
- package/package.json +10 -3
- package/src/Reconciler.js +15 -17
- package/src/a11y.js +2 -2
- package/src/anchor.js +7 -5
- package/src/bootstrap.js +14 -0
- package/src/clientmessage.js +1 -1
- package/src/cocoa/app.js +292 -49
- package/src/cocoa/bezels.js +175 -30
- package/src/cocoa/dnd.js +27 -13
- package/src/cocoa/fonts.js +3 -3
- package/src/cocoa/glarea.js +20 -3
- package/src/cocoa/main.d.ts +8 -0
- package/src/cocoa/main.js +43 -0
- package/src/cocoa/panehost.js +15 -5
- package/src/cocoa/presenter.js +13 -9
- package/src/cocoa/promotion.js +4 -7
- package/src/cocoa/relaunch.js +207 -0
- package/src/cocoa/threaded.js +246 -0
- package/src/cocoa/window.js +256 -42
- package/src/components/Select.js +2 -2
- package/src/components/anchor.js +3 -3
- package/src/components/native.js +12 -7
- package/src/components/theme.js +2 -2
- package/src/debug.js +1 -1
- package/src/decorations.js +1 -1
- package/src/editmenu.js +2 -2
- package/src/errors.js +46 -0
- package/src/events.js +6 -6
- package/src/foreignnodes.js +3 -2
- package/src/frames.js +2 -2
- package/src/glnodes.js +1 -1
- package/src/grid.js +1653 -0
- package/src/host.d.ts +230 -0
- package/src/host.js +11 -3
- package/src/imagesource.js +1 -1
- package/src/index.d.ts +21 -4
- package/src/index.js +9 -1
- package/src/layouts.js +721 -0
- package/src/node.d.ts +4 -2
- package/src/node.js +19 -21
- package/src/nodes/animation.js +644 -0
- package/src/nodes/box.js +21 -0
- package/src/nodes/boxpaint.js +473 -0
- package/src/nodes/canvas.js +269 -0
- package/src/nodes/cascade.js +600 -0
- package/src/nodes/damage.js +183 -0
- package/src/nodes/edithistory.js +124 -0
- package/src/nodes/editmenupopup.js +260 -0
- package/src/nodes/hittest.js +185 -0
- package/src/nodes/image.js +266 -0
- package/src/nodes/install.js +75 -0
- package/src/nodes/invalidate.js +465 -0
- package/src/nodes/kinds.js +31 -0
- package/src/nodes/layout.js +439 -0
- package/src/nodes/layouthost.js +949 -0
- package/src/nodes/node.js +868 -0
- package/src/nodes/paint.js +466 -0
- package/src/nodes/position.js +366 -0
- package/src/nodes/preedit.js +127 -0
- package/src/nodes/queries.js +330 -0
- package/src/nodes/rects.js +102 -0
- package/src/nodes/scrollable.js +891 -0
- package/src/nodes/scrollbars.js +138 -0
- package/src/nodes/scrollblit.js +1034 -0
- package/src/nodes/selectable.js +142 -0
- package/src/nodes/styling.js +225 -0
- package/src/nodes/text.js +649 -0
- package/src/nodes/textarea.js +391 -0
- package/src/nodes/textinput.js +1146 -0
- package/src/nodes/util.js +17 -0
- package/src/nodes/window/anchoring.js +161 -0
- package/src/nodes/window/capabilities.js +190 -0
- package/src/nodes/window/debugpaint.js +83 -0
- package/src/nodes/window/droptarget.js +145 -0
- package/src/nodes/window/floors.js +577 -0
- package/src/nodes/window/flush.js +334 -0
- package/src/nodes/window/hints.js +482 -0
- package/src/nodes/window/listeners.js +222 -0
- package/src/nodes/window/popup.js +71 -0
- package/src/nodes/window/size.js +591 -0
- package/src/nodes/window/window.js +945 -0
- package/src/palette.js +1 -1
- package/src/registry.js +7 -3
- package/src/styles.js +137 -15
- package/src/svgnodes.js +2 -1
- package/src/testing/harness.js +2 -2
- package/src/textselection.js +5 -3
- package/src/trace-registry.js +1 -1
- package/src/types/components.d.ts +38 -6
- package/src/types/elements.d.ts +11 -1
- package/src/types/nodes.d.ts +17 -2
- package/src/types/style.d.ts +94 -3
- package/src/windowstate.js +1 -1
- package/src/yoga.js +1 -1
- 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);
|