@textui/widgets 0.1.0 → 0.2.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 +17 -15
- package/dist/control/checkbox.d.ts +2 -0
- package/dist/control/checkbox.d.ts.map +1 -1
- package/dist/control/checkbox.js +2 -2
- package/dist/control/radio-group.d.ts +2 -0
- package/dist/control/radio-group.d.ts.map +1 -1
- package/dist/control/radio-group.js +2 -2
- package/dist/control/text-area.d.ts +15 -2
- package/dist/control/text-area.d.ts.map +1 -1
- package/dist/control/text-area.js +402 -22
- package/dist/control/text-input.d.ts.map +1 -1
- package/dist/control/text-input.js +40 -0
- package/dist/data/feed.d.ts +14 -0
- package/dist/data/feed.d.ts.map +1 -1
- package/dist/data/feed.js +28 -2
- package/dist/data/list.d.ts +60 -7
- package/dist/data/list.d.ts.map +1 -1
- package/dist/data/list.js +66 -10
- package/dist/data/markdown-view.d.ts.map +1 -1
- package/dist/data/markdown-view.js +49 -0
- package/dist/display/color-text.d.ts +158 -0
- package/dist/display/color-text.d.ts.map +1 -0
- package/dist/display/color-text.js +246 -0
- package/dist/display/index.d.ts +1 -0
- package/dist/display/index.d.ts.map +1 -1
- package/dist/display/index.js +3 -0
- package/dist/layout/divider.d.ts +6 -1
- package/dist/layout/divider.d.ts.map +1 -1
- package/dist/layout/divider.js +5 -5
- package/dist/layout/scroll-view.d.ts +10 -0
- package/dist/layout/scroll-view.d.ts.map +1 -1
- package/dist/layout/scroll-view.js +2 -2
- package/dist/navigation/menu.d.ts +24 -0
- package/dist/navigation/menu.d.ts.map +1 -1
- package/dist/navigation/menu.js +58 -15
- package/dist/overlay/command-palette.d.ts +32 -1
- package/dist/overlay/command-palette.d.ts.map +1 -1
- package/dist/overlay/command-palette.js +84 -10
- package/dist/shells/workbench-shell.d.ts.map +1 -1
- package/dist/shells/workbench-shell.js +16 -2
- package/package.json +8 -8
- package/src/control/checkbox.ts +4 -2
- package/src/control/radio-group.ts +4 -2
- package/src/control/text-area.ts +394 -31
- package/src/control/text-input.ts +38 -1
- package/src/data/feed.ts +44 -1
- package/src/data/list.ts +125 -18
- package/src/data/markdown-view.ts +56 -0
- package/src/display/color-text.ts +386 -0
- package/src/display/index.ts +3 -0
- package/src/layout/divider.ts +15 -7
- package/src/layout/scroll-view.ts +12 -2
- package/src/navigation/menu.ts +89 -14
- package/src/overlay/command-palette.ts +125 -11
- package/src/shells/workbench-shell.ts +16 -3
package/src/control/text-area.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import type { BoxProps, KeyEvent, SemanticVariant } from '@textui/core';
|
|
1
|
+
import type { BoxProps, KeyEvent, MouseEvent, SemanticVariant, Style } from '@textui/core';
|
|
2
2
|
import {
|
|
3
3
|
defineComponent,
|
|
4
4
|
graphemes,
|
|
5
5
|
h,
|
|
6
6
|
stringWidth,
|
|
7
|
+
useClipboard,
|
|
7
8
|
useEffect,
|
|
8
9
|
useFocus,
|
|
9
10
|
useInput,
|
|
10
11
|
useMeasure,
|
|
12
|
+
useRef,
|
|
11
13
|
useState,
|
|
14
|
+
useTheme,
|
|
12
15
|
useTicker,
|
|
13
16
|
} from '@textui/core';
|
|
14
17
|
|
|
@@ -60,16 +63,29 @@ export interface TextAreaProps extends BoxProps {
|
|
|
60
63
|
*/
|
|
61
64
|
caretTone?: SemanticVariant;
|
|
62
65
|
/**
|
|
63
|
-
* What the caret looks like.
|
|
64
|
-
*
|
|
66
|
+
* What the caret looks like. **The theme's `cursor` by default**, so the
|
|
67
|
+
* drawn caret and the terminal's own are the same shape, and an underline
|
|
68
|
+
* when the theme leaves it to the terminal.
|
|
65
69
|
*
|
|
66
70
|
* Both **mark a cell rather than occupying one**. The caret used to be a
|
|
67
71
|
* glyph pushed in between the text before it and the text after, so every
|
|
68
72
|
* character to its right sat one column off from where it would be once the
|
|
69
73
|
* caret moved on, and the row was a cell wider than its own text. On a
|
|
70
74
|
* wrapped row that extra cell is the one that does not fit.
|
|
75
|
+
*
|
|
76
|
+
* That is also why the theme's `bar` arrives here as an underline: a bar
|
|
77
|
+
* *between* two characters is exactly the caret this one is not.
|
|
71
78
|
*/
|
|
72
79
|
caretStyle?: 'underline' | 'block';
|
|
80
|
+
/**
|
|
81
|
+
* Put a selection on the system clipboard as it is made. On by default.
|
|
82
|
+
*
|
|
83
|
+
* Selecting with the mouse is how text leaves a terminal, and an application
|
|
84
|
+
* that reports mouse events has taken the terminal's own select-and-copy
|
|
85
|
+
* away - so it owes one back. The copy goes out over OSC 52 and into the
|
|
86
|
+
* store, which is the half a paste inside the application can read.
|
|
87
|
+
*/
|
|
88
|
+
copyOnSelect?: boolean;
|
|
73
89
|
/**
|
|
74
90
|
* Blink the caret while the field has the keyboard. On by default.
|
|
75
91
|
*
|
|
@@ -101,9 +117,11 @@ export const TextArea = defineComponent<TextAreaProps>('TextArea', (props) => {
|
|
|
101
117
|
const {
|
|
102
118
|
value, onChange, onSubmit, onCancel, onOverflow, onEdge, placeholder, maxRows = 6,
|
|
103
119
|
maxLength, autoFocus, focusId, disabled, caretTone, blink = true, wrap = 'word',
|
|
104
|
-
caretStyle =
|
|
120
|
+
caretStyle, copyOnSelect = true, ...rest
|
|
105
121
|
} = props;
|
|
106
122
|
|
|
123
|
+
const theme = useTheme();
|
|
124
|
+
const clipboard = useClipboard();
|
|
107
125
|
const focus = useFocus({
|
|
108
126
|
...(focusId ? { id: focusId } : {}),
|
|
109
127
|
disabled,
|
|
@@ -112,6 +130,33 @@ export const TextArea = defineComponent<TextAreaProps>('TextArea', (props) => {
|
|
|
112
130
|
const [caret, setCaret] = useState(value.length);
|
|
113
131
|
const [lit, setLit] = useState(true);
|
|
114
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Where the selection was started from; `null` when there is none.
|
|
135
|
+
*
|
|
136
|
+
* The caret is the other end, so a selection is a pair of offsets that
|
|
137
|
+
* remembers which way round it was made - which is what lets shift+left
|
|
138
|
+
* shrink a rightward selection instead of starting a new one.
|
|
139
|
+
*
|
|
140
|
+
* Kept in a ref as well because a drag can outrun the frame loop: `down`
|
|
141
|
+
* and `up` can both land inside one frame, and the handler that has to
|
|
142
|
+
* decide whether anything was selected would be reading last frame's state.
|
|
143
|
+
*/
|
|
144
|
+
const anchorRef = useRef<number | null>(null);
|
|
145
|
+
/**
|
|
146
|
+
* The last press, so a second one on the same cell can be told from a first.
|
|
147
|
+
*
|
|
148
|
+
* A terminal has no notion of a double click - it reports presses - so the
|
|
149
|
+
* count is kept here and reset by either moving or waiting.
|
|
150
|
+
*/
|
|
151
|
+
const repeat = useRef<{ at: number; x: number; y: number; count: number; dragged: boolean }>(
|
|
152
|
+
{ at: Number.NEGATIVE_INFINITY, x: -1, y: -1, count: 0, dragged: false },
|
|
153
|
+
);
|
|
154
|
+
const [anchor, setAnchorState] = useState<number | null>(null);
|
|
155
|
+
const setAnchor = (next: number | null): void => {
|
|
156
|
+
anchorRef.current = next;
|
|
157
|
+
setAnchorState(next);
|
|
158
|
+
};
|
|
159
|
+
|
|
115
160
|
// Only while it has the keyboard, and solid the moment it loses it: a caret
|
|
116
161
|
// blinking in a field nobody is typing into is two carets on one screen.
|
|
117
162
|
useTicker(() => setLit((on) => !on), { fps: 2, enabled: blink && focus.focused });
|
|
@@ -124,12 +169,42 @@ export const TextArea = defineComponent<TextAreaProps>('TextArea', (props) => {
|
|
|
124
169
|
* whatever the cell already had - a caret over selected or coloured text
|
|
125
170
|
* stays legible instead of painting the theme's cursor colour over it.
|
|
126
171
|
*/
|
|
127
|
-
|
|
172
|
+
// `bar` has no drawn form that does not occupy a cell of its own, so it
|
|
173
|
+
// marks the cell the way an underline does - which is what a bar caret
|
|
174
|
+
// looks like anyway once it has nowhere of its own to stand.
|
|
175
|
+
const shape = caretStyle ?? (theme.cursor === 'block' ? 'block' : 'underline');
|
|
176
|
+
const mark: Style = shape === 'block'
|
|
128
177
|
? { inverse: true, fg: caretTone ?? 'cursor' }
|
|
129
178
|
: { underline: true, fg: caretTone ?? 'cursor' };
|
|
130
179
|
|
|
131
180
|
const chars = graphemes(value);
|
|
132
181
|
const position = Math.min(caret, chars.length);
|
|
182
|
+
|
|
183
|
+
/*
|
|
184
|
+
* The selection, as the two offsets it covers.
|
|
185
|
+
*
|
|
186
|
+
* Clamped against the value the same way the caret is: `value` is a prop, so
|
|
187
|
+
* it can be replaced under a live selection - a history walked with up and
|
|
188
|
+
* down does exactly that - and an anchor past the end of the new text would
|
|
189
|
+
* select backwards into nothing.
|
|
190
|
+
*/
|
|
191
|
+
const anchoredAt = anchor === null ? null : Math.min(anchor, chars.length);
|
|
192
|
+
const selectionStart = anchoredAt === null ? position : Math.min(anchoredAt, position);
|
|
193
|
+
const selectionEnd = anchoredAt === null ? position : Math.max(anchoredAt, position);
|
|
194
|
+
const selected = selectionEnd > selectionStart;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* How a selected cell is drawn.
|
|
198
|
+
*
|
|
199
|
+
* The same pair a selected list row uses, and dimmer once the field loses
|
|
200
|
+
* the keyboard: a selection left visible in an unfocused field says what is
|
|
201
|
+
* on the clipboard, and saying it as loudly as the live one would put two
|
|
202
|
+
* selections on the screen.
|
|
203
|
+
*/
|
|
204
|
+
const selectionStyle: Style = focus.focused
|
|
205
|
+
? { bg: 'selected', fg: 'inverted' }
|
|
206
|
+
: { bg: 'active' };
|
|
207
|
+
|
|
133
208
|
const before = chars.slice(0, position).join('');
|
|
134
209
|
const lines = value === '' ? [''] : value.split('\n');
|
|
135
210
|
const caretLine = before.split('\n').length - 1;
|
|
@@ -152,7 +227,10 @@ export const TextArea = defineComponent<TextAreaProps>('TextArea', (props) => {
|
|
|
152
227
|
* 0 and nothing wraps, which is precisely the behaviour above - and the pass
|
|
153
228
|
* that `useMeasure` schedules draws the wrapped version.
|
|
154
229
|
*/
|
|
155
|
-
|
|
230
|
+
// The content rect, not the border box: rows are drawn inside the padding,
|
|
231
|
+
// so this is the origin a pointer has to be measured against.
|
|
232
|
+
const rect = useMeasure();
|
|
233
|
+
const width = rect.width;
|
|
156
234
|
const mode = width > 0 && (wrap === 'word' || wrap === 'char') ? wrap : 'none';
|
|
157
235
|
|
|
158
236
|
const visual: { line: number; start: number; cells: string[] }[] = [];
|
|
@@ -196,15 +274,142 @@ export const TextArea = defineComponent<TextAreaProps>('TextArea', (props) => {
|
|
|
196
274
|
return lineStart(row.line) + row.start + column;
|
|
197
275
|
};
|
|
198
276
|
|
|
277
|
+
/**
|
|
278
|
+
* The offset a pointer landed on.
|
|
279
|
+
*
|
|
280
|
+
* Cells rather than characters: a wide glyph is two columns, so the column
|
|
281
|
+
* a click is in is not its index in the row. Past the end of a row the
|
|
282
|
+
* caret goes after its last character, which is where a click in the empty
|
|
283
|
+
* part of a line is asking for.
|
|
284
|
+
*/
|
|
285
|
+
const offsetAt = (cellX: number, rowIndex: number): number => {
|
|
286
|
+
const row = visual[Math.max(0, Math.min(rowIndex, visual.length - 1))];
|
|
287
|
+
if (!row) return position;
|
|
288
|
+
let used = 0;
|
|
289
|
+
let column = row.cells.length;
|
|
290
|
+
for (let i = 0; i < row.cells.length; i++) {
|
|
291
|
+
const w = stringWidth(row.cells[i] as string);
|
|
292
|
+
// The half-way point: clicking the right half of a glyph puts the caret
|
|
293
|
+
// after it, which is what every other editor does.
|
|
294
|
+
if (cellX < used + w - (w > 1 ? 0 : 0.5)) { column = i; break; }
|
|
295
|
+
used += w;
|
|
296
|
+
}
|
|
297
|
+
return lineStart(row.line) + row.start + column;
|
|
298
|
+
};
|
|
299
|
+
|
|
199
300
|
const replace = (next: string, at: number): void => {
|
|
200
301
|
const capped = maxLength !== undefined ? next.slice(0, maxLength) : next;
|
|
302
|
+
setAnchor(null);
|
|
201
303
|
onChange(capped);
|
|
202
304
|
setCaret(Math.max(0, Math.min(graphemes(capped).length, at)));
|
|
203
305
|
};
|
|
204
306
|
|
|
307
|
+
/** Typing over a selection replaces it - which is what deletes it, too. */
|
|
205
308
|
const insert = (text: string): void => {
|
|
206
309
|
const inserted = graphemes(text);
|
|
207
|
-
replace(
|
|
310
|
+
replace(
|
|
311
|
+
[...chars.slice(0, selectionStart), ...inserted, ...chars.slice(selectionEnd)].join(''),
|
|
312
|
+
selectionStart + inserted.length,
|
|
313
|
+
);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Move the caret, taking the selection with it when shift is held.
|
|
318
|
+
*
|
|
319
|
+
* Without shift the selection collapses, because that is what an arrow key
|
|
320
|
+
* means everywhere: stop selecting, and go here.
|
|
321
|
+
*/
|
|
322
|
+
const move = (to: number, extend: boolean): void => {
|
|
323
|
+
if (!extend) { setAnchor(null); setCaret(to); return; }
|
|
324
|
+
|
|
325
|
+
const from = anchorRef.current ?? position;
|
|
326
|
+
if (anchorRef.current === null) setAnchor(from);
|
|
327
|
+
setCaret(to);
|
|
328
|
+
// A selection made with the keyboard is a selection, and goes the same
|
|
329
|
+
// place one made with the mouse goes. Copying only on a mouse release
|
|
330
|
+
// meant shift+right showed something highlighted that was not on the
|
|
331
|
+
// clipboard - which is a selection you have to redo with the mouse.
|
|
332
|
+
copy(chars.slice(Math.min(from, to), Math.max(from, to)).join(''));
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const copy = (text: string): void => {
|
|
336
|
+
if (copyOnSelect && text !== '') clipboard.write(text);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/** Select `[from, to)`, and copy it - a word picked out is a word taken. */
|
|
340
|
+
const select = (from: number, to: number): void => {
|
|
341
|
+
setAnchor(from);
|
|
342
|
+
setCaret(to);
|
|
343
|
+
copy(chars.slice(from, to).join(''));
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The run a double click means.
|
|
348
|
+
*
|
|
349
|
+
* Letters with letters, spaces with spaces, punctuation with punctuation -
|
|
350
|
+
* so a double click in the gap between two words takes the gap rather than
|
|
351
|
+
* one of the words, which is what makes "select the word, then extend"
|
|
352
|
+
* behave. A newline is a class of its own and joins nothing: a word
|
|
353
|
+
* selection that ran across one would be a paragraph, and there is a
|
|
354
|
+
* separate gesture for that.
|
|
355
|
+
*/
|
|
356
|
+
const runAt = (at: number): [number, number] => {
|
|
357
|
+
if (chars.length === 0) return [0, 0];
|
|
358
|
+
const index = Math.max(0, Math.min(at, chars.length - 1));
|
|
359
|
+
const kind = classOf(chars[index] as string);
|
|
360
|
+
if (kind === 'break') return [index, index + 1];
|
|
361
|
+
|
|
362
|
+
let from = index;
|
|
363
|
+
while (from > 0 && classOf(chars[from - 1] as string) === kind) from -= 1;
|
|
364
|
+
let to = index + 1;
|
|
365
|
+
while (to < chars.length && classOf(chars[to] as string) === kind) to += 1;
|
|
366
|
+
return [from, to];
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The offset one word away, in `step`'s direction.
|
|
371
|
+
*
|
|
372
|
+
* Skips whatever is under the caret to the far side of it, then any run of
|
|
373
|
+
* whitespace - so ctrl+right lands at the start of the next word rather than
|
|
374
|
+
* in the gap before it, which is where "skip the run you are in" alone would
|
|
375
|
+
* leave it.
|
|
376
|
+
*
|
|
377
|
+
* A newline is one step of its own. Walking right stops at the end of the
|
|
378
|
+
* line, and the next press crosses to the start of the one below - rather
|
|
379
|
+
* than the break and the first word going by together. The end of a line is
|
|
380
|
+
* somewhere people mean to be, which is why it costs a press to leave.
|
|
381
|
+
*/
|
|
382
|
+
const wordStep = (from: number, step: -1 | 1): number => {
|
|
383
|
+
let at = from;
|
|
384
|
+
const peek = (): string | undefined => chars[step < 0 ? at - 1 : at];
|
|
385
|
+
|
|
386
|
+
if (step < 0) {
|
|
387
|
+
while (at > 0 && classOf(peek() as string) === 'space') at -= 1;
|
|
388
|
+
if (at > 0 && classOf(peek() as string) === 'break') return at - 1;
|
|
389
|
+
const kind = at > 0 ? classOf(peek() as string) : 'word';
|
|
390
|
+
while (at > 0 && classOf(peek() as string) === kind) at -= 1;
|
|
391
|
+
return at;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (at < chars.length && classOf(peek() as string) === 'break') return at + 1;
|
|
395
|
+
const kind = at < chars.length ? classOf(peek() as string) : 'word';
|
|
396
|
+
while (at < chars.length && classOf(peek() as string) === kind) at += 1;
|
|
397
|
+
while (at < chars.length && classOf(peek() as string) === 'space') at += 1;
|
|
398
|
+
return at;
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The logical line a triple click means - with its newline, when it has one.
|
|
403
|
+
*
|
|
404
|
+
* The line rather than the row: a wrapped paragraph is one thing somebody
|
|
405
|
+
* wrote, and taking the third of it that happened to fit on one row is not
|
|
406
|
+
* a selection anybody asked for.
|
|
407
|
+
*/
|
|
408
|
+
const lineAt = (at: number): [number, number] => {
|
|
409
|
+
const index = chars.slice(0, at).join('').split('\n').length - 1;
|
|
410
|
+
const start = lineStart(index);
|
|
411
|
+
const end = start + graphemes(lines[index] ?? '').length;
|
|
412
|
+
return [start, Math.min(chars.length, end + (index < lines.length - 1 ? 1 : 0))];
|
|
208
413
|
};
|
|
209
414
|
|
|
210
415
|
useInput(
|
|
@@ -213,17 +418,28 @@ export const TextArea = defineComponent<TextAreaProps>('TextArea', (props) => {
|
|
|
213
418
|
|
|
214
419
|
switch (event.name) {
|
|
215
420
|
case 'left':
|
|
421
|
+
// A word at a time, the way every field with a caret in it does.
|
|
422
|
+
if (event.ctrl || event.alt) { move(wordStep(position, -1), event.shift); return true; }
|
|
423
|
+
// A plain arrow collapses to the edge it is moving towards rather
|
|
424
|
+
// than stepping one from the caret: with three words selected, left
|
|
425
|
+
// means "back to the start of that", not "one before wherever the
|
|
426
|
+
// drag happened to end".
|
|
427
|
+
if (selected && !event.shift) { move(selectionStart, false); return true; }
|
|
216
428
|
// Off the front of the field is the caller's key, the same way up at
|
|
217
429
|
// the top is - so "left, left, left" walks out of the composer.
|
|
218
|
-
if (position === 0) { onEdge?.('start'); return true; }
|
|
219
|
-
|
|
430
|
+
if (position === 0) { if (!event.shift) setAnchor(null); onEdge?.('start'); return true; }
|
|
431
|
+
move(position - 1, event.shift);
|
|
220
432
|
return true;
|
|
221
433
|
case 'right':
|
|
222
|
-
if (
|
|
223
|
-
|
|
434
|
+
if (event.ctrl || event.alt) { move(wordStep(position, 1), event.shift); return true; }
|
|
435
|
+
if (selected && !event.shift) { move(selectionEnd, false); return true; }
|
|
436
|
+
if (position === chars.length) { if (!event.shift) setAnchor(null); onEdge?.('end'); return true; }
|
|
437
|
+
move(position + 1, event.shift);
|
|
438
|
+
return true;
|
|
439
|
+
case 'home': move(lineStart(caretLine), event.shift); return true;
|
|
440
|
+
case 'end':
|
|
441
|
+
move(lineStart(caretLine) + graphemes(lines[caretLine] ?? '').length, event.shift);
|
|
224
442
|
return true;
|
|
225
|
-
case 'home': setCaret(lineStart(caretLine)); return true;
|
|
226
|
-
case 'end': setCaret(lineStart(caretLine) + graphemes(lines[caretLine] ?? '').length); return true;
|
|
227
443
|
|
|
228
444
|
// By *row*, not by line. In a wrapped paragraph moving by line jumps
|
|
229
445
|
// the whole paragraph, and what is above the caret on the screen is
|
|
@@ -231,20 +447,24 @@ export const TextArea = defineComponent<TextAreaProps>('TextArea', (props) => {
|
|
|
231
447
|
case 'up': {
|
|
232
448
|
// At the top there is nowhere to go inside the field, so the caller
|
|
233
449
|
// gets the key - which is where "the last thing you sent" comes from.
|
|
234
|
-
if (caretRow === 0) { onOverflow?.(-1); return true; }
|
|
235
|
-
|
|
450
|
+
if (caretRow === 0) { if (!event.shift) setAnchor(null); onOverflow?.(-1); return true; }
|
|
451
|
+
move(rowCaret(caretRow - 1), event.shift);
|
|
236
452
|
return true;
|
|
237
453
|
}
|
|
238
454
|
case 'down': {
|
|
239
|
-
if (caretRow === visual.length - 1) { onOverflow?.(1); return true; }
|
|
240
|
-
|
|
455
|
+
if (caretRow === visual.length - 1) { if (!event.shift) setAnchor(null); onOverflow?.(1); return true; }
|
|
456
|
+
move(rowCaret(caretRow + 1), event.shift);
|
|
241
457
|
return true;
|
|
242
458
|
}
|
|
243
459
|
|
|
244
460
|
case 'backspace':
|
|
461
|
+
// A selection is what gets deleted when there is one - the character
|
|
462
|
+
// before the caret is only the fallback.
|
|
463
|
+
if (selected) { insert(''); return true; }
|
|
245
464
|
if (position > 0) replace([...chars.slice(0, position - 1), ...chars.slice(position)].join(''), position - 1);
|
|
246
465
|
return true;
|
|
247
466
|
case 'delete':
|
|
467
|
+
if (selected) { insert(''); return true; }
|
|
248
468
|
if (position < chars.length) replace([...chars.slice(0, position), ...chars.slice(position + 1)].join(''), position);
|
|
249
469
|
return true;
|
|
250
470
|
|
|
@@ -254,10 +474,17 @@ export const TextArea = defineComponent<TextAreaProps>('TextArea', (props) => {
|
|
|
254
474
|
// binding it produces a key that works on one machine.
|
|
255
475
|
if (!onSubmit || event.alt || event.ctrl) { insert('\n'); return true; }
|
|
256
476
|
onSubmit(value);
|
|
477
|
+
setAnchor(null);
|
|
257
478
|
setCaret(0);
|
|
258
479
|
return true;
|
|
259
480
|
|
|
260
|
-
|
|
481
|
+
// `onCancel` is documented as escape *when there is nothing inside the
|
|
482
|
+
// field to cancel*, and a live selection is something inside the field
|
|
483
|
+
// to cancel.
|
|
484
|
+
case 'escape':
|
|
485
|
+
if (selected) { setAnchor(null); return true; }
|
|
486
|
+
onCancel?.();
|
|
487
|
+
return true;
|
|
261
488
|
case 'paste': insert(event.char ?? ''); return true;
|
|
262
489
|
default: break;
|
|
263
490
|
}
|
|
@@ -287,11 +514,88 @@ export const TextArea = defineComponent<TextAreaProps>('TextArea', (props) => {
|
|
|
287
514
|
// nothing else, because one row is what the field asked for and the border
|
|
288
515
|
// spent it. Nobody saw it because a field without a border looks the same
|
|
289
516
|
// either way, and `TextInput` fixes no height at all.
|
|
517
|
+
/**
|
|
518
|
+
* A click puts the caret where it landed; a drag selects to where it got to.
|
|
519
|
+
*
|
|
520
|
+
* `onMouse` rather than `onClick` because a field that is not focused has to
|
|
521
|
+
* take the keyboard first - otherwise the caret moves somewhere the next
|
|
522
|
+
* keystroke will not go - and because `onClick` is the button going down,
|
|
523
|
+
* which is a third of a drag.
|
|
524
|
+
*
|
|
525
|
+
* The drag arrives at all because the application holds the pointer for
|
|
526
|
+
* whoever took the button down.
|
|
527
|
+
*
|
|
528
|
+
* The row is deliberately not clamped to what is on screen. Dragging below
|
|
529
|
+
* the field asks for a row below the field, the caret follows it there, and
|
|
530
|
+
* the scroll offset is computed from the caret - so a selection dragged off
|
|
531
|
+
* the bottom scrolls the field, which is the whole reason to drag off the
|
|
532
|
+
* bottom. `offsetAt` clamps to the text, and that is the only clamp there is.
|
|
533
|
+
*/
|
|
534
|
+
const pointerOffset = (event: MouseEvent): number => {
|
|
535
|
+
const column = Math.max(0, event.x - rect.x);
|
|
536
|
+
return Math.max(0, Math.min(chars.length, offsetAt(column, first + (event.y - rect.y))));
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
const onMouse = (event: MouseEvent): boolean => {
|
|
540
|
+
if (disabled || event.button !== 'left' || rect.width <= 0) return false;
|
|
541
|
+
|
|
542
|
+
switch (event.action) {
|
|
543
|
+
case 'down': {
|
|
544
|
+
// Only a press *inside* the field starts anything. Drags and releases
|
|
545
|
+
// are not tested that way: by then the pointer is wherever it has got
|
|
546
|
+
// to, and the gesture is this field's regardless.
|
|
547
|
+
if (event.x < rect.x || event.y < rect.y) return false;
|
|
548
|
+
if (!focus.focused) focus.focus();
|
|
549
|
+
const at = pointerOffset(event);
|
|
550
|
+
|
|
551
|
+
// One cell, one window, or it is a new gesture. Position matters as
|
|
552
|
+
// much as time: two clicks half a second apart on different words are
|
|
553
|
+
// two clicks, and a terminal that reports no timestamp gets `0` for
|
|
554
|
+
// both, which lands on "same instant" - so the cell is what saves it.
|
|
555
|
+
const stamp = event.at ?? 0;
|
|
556
|
+
const same = event.x === repeat.current.x && event.y === repeat.current.y;
|
|
557
|
+
const soon = stamp - repeat.current.at <= 450;
|
|
558
|
+
const count = same && soon ? (repeat.current.count % 3) + 1 : 1;
|
|
559
|
+
repeat.current = { at: stamp, x: event.x, y: event.y, count, dragged: false };
|
|
560
|
+
|
|
561
|
+
if (count === 2) { const [from, to] = runAt(at); select(from, to); return true; }
|
|
562
|
+
if (count === 3) { const [from, to] = lineAt(at); select(from, to); return true; }
|
|
563
|
+
|
|
564
|
+
// Anchored, but empty - so a click that never turns into a drag is a
|
|
565
|
+
// caret and nothing else.
|
|
566
|
+
setAnchor(at);
|
|
567
|
+
setCaret(at);
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
case 'drag':
|
|
571
|
+
repeat.current.dragged = true;
|
|
572
|
+
setCaret(pointerOffset(event));
|
|
573
|
+
return true;
|
|
574
|
+
case 'up': {
|
|
575
|
+
// A word or a line was decided when the button went down, and the
|
|
576
|
+
// release is the same press ending. Re-reading the pointer here would
|
|
577
|
+
// collapse the selection back to the cell it was made from - which is
|
|
578
|
+
// what "double click selects two letters" was.
|
|
579
|
+
if (repeat.current.count > 1 && !repeat.current.dragged) return true;
|
|
580
|
+
|
|
581
|
+
const at = pointerOffset(event);
|
|
582
|
+
const from = anchorRef.current;
|
|
583
|
+
setCaret(at);
|
|
584
|
+
if (from === null || from === at) { setAnchor(null); return true; }
|
|
585
|
+
copy(chars.slice(Math.min(from, at), Math.max(from, at)).join(''));
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
default:
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
|
|
290
593
|
return h('box', {
|
|
291
594
|
id: focus.id,
|
|
292
595
|
role: 'textbox',
|
|
293
596
|
label: placeholder,
|
|
294
597
|
direction: 'column',
|
|
598
|
+
onMouse,
|
|
295
599
|
...rest,
|
|
296
600
|
}, h('box', { direction: 'column', height: rows },
|
|
297
601
|
value === ''
|
|
@@ -316,22 +620,69 @@ export const TextArea = defineComponent<TextAreaProps>('TextArea', (props) => {
|
|
|
316
620
|
})()
|
|
317
621
|
: shown.map((row, i) => {
|
|
318
622
|
const index = first + i;
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
return h('text', { key: index, content: text === '' ? ' ' : text, wrap: 'none', truncate: 'end' });
|
|
322
|
-
}
|
|
623
|
+
const base = lineStart(row.line) + row.start;
|
|
624
|
+
const end = base + row.cells.length;
|
|
323
625
|
// The caret's column inside this row. The row has already been broken
|
|
324
626
|
// to the width it will be drawn at, so the split lands where it looks.
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
const
|
|
627
|
+
const at = index === caretRow && focus.focused ? caretColumn - row.start : -1;
|
|
628
|
+
|
|
629
|
+
// One entry per drawn cell, carrying what is true of it. The caret is
|
|
630
|
+
// a cell the row already has rather than an extra one between two of
|
|
631
|
+
// them - that is what keeps a wrapped row as wide as its own text.
|
|
632
|
+
const cells = row.cells.map((char, column) => ({
|
|
633
|
+
char,
|
|
634
|
+
selected: selected && base + column >= selectionStart && base + column < selectionEnd,
|
|
635
|
+
caret: column === at,
|
|
636
|
+
}));
|
|
637
|
+
|
|
638
|
+
// The cell after the last character. It exists when the caret is past
|
|
639
|
+
// the end and has nothing to mark, when the newline this row ends on
|
|
640
|
+
// is inside the selection - a selected line break has to show, or
|
|
641
|
+
// three selected lines read as three unrelated runs - and when the row
|
|
642
|
+
// is empty and needs something to occupy it.
|
|
643
|
+
const breaks = visual[index + 1]?.line !== row.line;
|
|
644
|
+
const tail = {
|
|
645
|
+
char: ' ',
|
|
646
|
+
selected: selected && breaks && end >= selectionStart && end < selectionEnd,
|
|
647
|
+
caret: at === row.cells.length,
|
|
648
|
+
};
|
|
649
|
+
if (tail.caret || tail.selected || cells.length === 0) cells.push(tail);
|
|
650
|
+
|
|
651
|
+
if (!cells.some((cell) => cell.selected || cell.caret)) {
|
|
652
|
+
return h('text', { key: index, content: cells.map((c) => c.char).join(''), wrap: 'none', truncate: 'end' });
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Neighbouring cells that look the same are drawn as one run, so a
|
|
656
|
+
// selected row is a handful of nodes rather than one per column.
|
|
657
|
+
const runs: { content: string; style: Style }[] = [];
|
|
658
|
+
let previous = '';
|
|
659
|
+
for (const cell of cells) {
|
|
660
|
+
const lift = `${cell.selected ? 's' : ''}${cell.caret && lit ? 'c' : ''}`;
|
|
661
|
+
const last = runs[runs.length - 1];
|
|
662
|
+
if (last && lift === previous) last.content += cell.char;
|
|
663
|
+
else {
|
|
664
|
+
runs.push({
|
|
665
|
+
content: cell.char,
|
|
666
|
+
style: {
|
|
667
|
+
...(cell.selected ? selectionStyle : {}),
|
|
668
|
+
...(cell.caret && lit ? mark : {}),
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
previous = lift;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
331
675
|
return h('box', { key: index, direction: 'row' },
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
676
|
+
...runs.map((run, r) => h('text', {
|
|
677
|
+
key: r,
|
|
678
|
+
content: run.content,
|
|
679
|
+
wrap: 'none',
|
|
680
|
+
truncate: 'end',
|
|
681
|
+
...run.style,
|
|
682
|
+
})),
|
|
683
|
+
// The rest of the row, unstyled: flexing the last run instead would
|
|
684
|
+
// drag a selection's background out to the edge of the field.
|
|
685
|
+
h('text', { key: 'rest', content: '', flex: 1, truncate: 'end' }));
|
|
335
686
|
}),
|
|
336
687
|
));
|
|
337
688
|
});
|
|
@@ -388,3 +739,15 @@ function rowWidth(cells: string[]): number {
|
|
|
388
739
|
for (const cell of cells) width += stringWidth(cell);
|
|
389
740
|
return width;
|
|
390
741
|
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* What kind of character this is, for the purpose of "the run around it".
|
|
745
|
+
*
|
|
746
|
+
* Four classes, and a newline is its own: joining it to the whitespace either
|
|
747
|
+
* side would make a double click in the margin select across two lines.
|
|
748
|
+
*/
|
|
749
|
+
function classOf(cell: string): 'word' | 'space' | 'break' | 'other' {
|
|
750
|
+
if (cell === '\n') return 'break';
|
|
751
|
+
if (/\s/.test(cell)) return 'space';
|
|
752
|
+
return /[\p{L}\p{N}_]/u.test(cell) ? 'word' : 'other';
|
|
753
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { KeyEvent } from '@textui/core';
|
|
1
|
+
import type { KeyEvent, MouseEvent } from '@textui/core';
|
|
2
2
|
import {
|
|
3
3
|
defineComponent,
|
|
4
4
|
graphemes,
|
|
@@ -126,9 +126,46 @@ export const TextInput = defineComponent<TextInputProps>('TextInput', (props) =>
|
|
|
126
126
|
{ focusId: focus.id },
|
|
127
127
|
);
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* A click takes the keyboard and puts the caret where it landed.
|
|
131
|
+
*
|
|
132
|
+
* Without it the field was the one control on the screen that did nothing
|
|
133
|
+
* when it was clicked - and an empty one has nothing in it to say it is a
|
|
134
|
+
* field, so what it looked like was a gap in the layout. `TextArea` has had
|
|
135
|
+
* this from the start; the two disagreeing is what made a form built out of
|
|
136
|
+
* the single-line one unusable with a mouse.
|
|
137
|
+
*
|
|
138
|
+
* `onMouse` rather than `onClick`, the same as there: a field that is not
|
|
139
|
+
* focused has to take the keyboard before the caret moves, or the caret is
|
|
140
|
+
* somewhere the next keystroke will not go.
|
|
141
|
+
*
|
|
142
|
+
* `measured` is the *content* rect - inside the border and the padding - so
|
|
143
|
+
* the value starts at `lead` cells into it, and `start` is what the field
|
|
144
|
+
* has scrolled past.
|
|
145
|
+
*/
|
|
146
|
+
const onMouse = (event: MouseEvent): boolean => {
|
|
147
|
+
if (disabled || event.button !== 'left' || event.action !== 'down') return false;
|
|
148
|
+
if (measured.width <= 0) return false;
|
|
149
|
+
if (event.x < measured.x || event.y < measured.y) return false;
|
|
150
|
+
if (!focus.focused) focus.focus();
|
|
151
|
+
|
|
152
|
+
// Cells to characters. A wide glyph is two columns, and a click on its
|
|
153
|
+
// second column means the same character as a click on its first.
|
|
154
|
+
const column = event.x - measured.x - lead + start;
|
|
155
|
+
let index = 0;
|
|
156
|
+
for (let width = 0; index < chars.length; index++) {
|
|
157
|
+
const next = width + stringWidth(chars[index] as string);
|
|
158
|
+
if (next > column) break;
|
|
159
|
+
width = next;
|
|
160
|
+
}
|
|
161
|
+
move(index);
|
|
162
|
+
return true;
|
|
163
|
+
};
|
|
164
|
+
|
|
129
165
|
return h('box', {
|
|
130
166
|
id: focus.id,
|
|
131
167
|
role: search ? 'searchbox' : 'textbox',
|
|
168
|
+
onMouse,
|
|
132
169
|
label,
|
|
133
170
|
direction: 'row',
|
|
134
171
|
gap: 1,
|