@textui/widgets 0.1.0 → 0.3.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 +88 -4
- 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/data/shared.d.ts +18 -1
- package/dist/data/shared.d.ts.map +1 -1
- package/dist/data/shared.js +12 -2
- 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 +22 -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 +102 -3
- package/src/data/list.ts +125 -18
- package/src/data/markdown-view.ts +56 -0
- package/src/data/shared.ts +25 -3
- 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 +22 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineComponent, graphemes, h, stringWidth, useEffect, useFocus, useInput, useMeasure, useState, useTicker, } from '@textui/core';
|
|
1
|
+
import { defineComponent, graphemes, h, stringWidth, useClipboard, useEffect, useFocus, useInput, useMeasure, useRef, useState, useTheme, useTicker, } from '@textui/core';
|
|
2
2
|
/**
|
|
3
3
|
* A field that is a paragraph.
|
|
4
4
|
*
|
|
@@ -17,7 +17,9 @@ import { defineComponent, graphemes, h, stringWidth, useEffect, useFocus, useInp
|
|
|
17
17
|
* cannot have single-letter global commands, and why it does not need to.
|
|
18
18
|
*/
|
|
19
19
|
export const TextArea = defineComponent('TextArea', (props) => {
|
|
20
|
-
const { value, onChange, onSubmit, onCancel, onOverflow, onEdge, placeholder, maxRows = 6, maxLength, autoFocus, focusId, disabled, caretTone, blink = true, wrap = 'word', caretStyle =
|
|
20
|
+
const { value, onChange, onSubmit, onCancel, onOverflow, onEdge, placeholder, maxRows = 6, maxLength, autoFocus, focusId, disabled, caretTone, blink = true, wrap = 'word', caretStyle, copyOnSelect = true, ...rest } = props;
|
|
21
|
+
const theme = useTheme();
|
|
22
|
+
const clipboard = useClipboard();
|
|
21
23
|
const focus = useFocus({
|
|
22
24
|
...(focusId ? { id: focusId } : {}),
|
|
23
25
|
disabled,
|
|
@@ -25,6 +27,30 @@ export const TextArea = defineComponent('TextArea', (props) => {
|
|
|
25
27
|
});
|
|
26
28
|
const [caret, setCaret] = useState(value.length);
|
|
27
29
|
const [lit, setLit] = useState(true);
|
|
30
|
+
/**
|
|
31
|
+
* Where the selection was started from; `null` when there is none.
|
|
32
|
+
*
|
|
33
|
+
* The caret is the other end, so a selection is a pair of offsets that
|
|
34
|
+
* remembers which way round it was made - which is what lets shift+left
|
|
35
|
+
* shrink a rightward selection instead of starting a new one.
|
|
36
|
+
*
|
|
37
|
+
* Kept in a ref as well because a drag can outrun the frame loop: `down`
|
|
38
|
+
* and `up` can both land inside one frame, and the handler that has to
|
|
39
|
+
* decide whether anything was selected would be reading last frame's state.
|
|
40
|
+
*/
|
|
41
|
+
const anchorRef = useRef(null);
|
|
42
|
+
/**
|
|
43
|
+
* The last press, so a second one on the same cell can be told from a first.
|
|
44
|
+
*
|
|
45
|
+
* A terminal has no notion of a double click - it reports presses - so the
|
|
46
|
+
* count is kept here and reset by either moving or waiting.
|
|
47
|
+
*/
|
|
48
|
+
const repeat = useRef({ at: Number.NEGATIVE_INFINITY, x: -1, y: -1, count: 0, dragged: false });
|
|
49
|
+
const [anchor, setAnchorState] = useState(null);
|
|
50
|
+
const setAnchor = (next) => {
|
|
51
|
+
anchorRef.current = next;
|
|
52
|
+
setAnchorState(next);
|
|
53
|
+
};
|
|
28
54
|
// Only while it has the keyboard, and solid the moment it loses it: a caret
|
|
29
55
|
// blinking in a field nobody is typing into is two carets on one screen.
|
|
30
56
|
useTicker(() => setLit((on) => !on), { fps: 2, enabled: blink && focus.focused });
|
|
@@ -36,11 +62,38 @@ export const TextArea = defineComponent('TextArea', (props) => {
|
|
|
36
62
|
* whatever the cell already had - a caret over selected or coloured text
|
|
37
63
|
* stays legible instead of painting the theme's cursor colour over it.
|
|
38
64
|
*/
|
|
39
|
-
|
|
65
|
+
// `bar` has no drawn form that does not occupy a cell of its own, so it
|
|
66
|
+
// marks the cell the way an underline does - which is what a bar caret
|
|
67
|
+
// looks like anyway once it has nowhere of its own to stand.
|
|
68
|
+
const shape = caretStyle ?? (theme.cursor === 'block' ? 'block' : 'underline');
|
|
69
|
+
const mark = shape === 'block'
|
|
40
70
|
? { inverse: true, fg: caretTone ?? 'cursor' }
|
|
41
71
|
: { underline: true, fg: caretTone ?? 'cursor' };
|
|
42
72
|
const chars = graphemes(value);
|
|
43
73
|
const position = Math.min(caret, chars.length);
|
|
74
|
+
/*
|
|
75
|
+
* The selection, as the two offsets it covers.
|
|
76
|
+
*
|
|
77
|
+
* Clamped against the value the same way the caret is: `value` is a prop, so
|
|
78
|
+
* it can be replaced under a live selection - a history walked with up and
|
|
79
|
+
* down does exactly that - and an anchor past the end of the new text would
|
|
80
|
+
* select backwards into nothing.
|
|
81
|
+
*/
|
|
82
|
+
const anchoredAt = anchor === null ? null : Math.min(anchor, chars.length);
|
|
83
|
+
const selectionStart = anchoredAt === null ? position : Math.min(anchoredAt, position);
|
|
84
|
+
const selectionEnd = anchoredAt === null ? position : Math.max(anchoredAt, position);
|
|
85
|
+
const selected = selectionEnd > selectionStart;
|
|
86
|
+
/**
|
|
87
|
+
* How a selected cell is drawn.
|
|
88
|
+
*
|
|
89
|
+
* The same pair a selected list row uses, and dimmer once the field loses
|
|
90
|
+
* the keyboard: a selection left visible in an unfocused field says what is
|
|
91
|
+
* on the clipboard, and saying it as loudly as the live one would put two
|
|
92
|
+
* selections on the screen.
|
|
93
|
+
*/
|
|
94
|
+
const selectionStyle = focus.focused
|
|
95
|
+
? { bg: 'selected', fg: 'inverted' }
|
|
96
|
+
: { bg: 'active' };
|
|
44
97
|
const before = chars.slice(0, position).join('');
|
|
45
98
|
const lines = value === '' ? [''] : value.split('\n');
|
|
46
99
|
const caretLine = before.split('\n').length - 1;
|
|
@@ -62,7 +115,10 @@ export const TextArea = defineComponent('TextArea', (props) => {
|
|
|
62
115
|
* 0 and nothing wraps, which is precisely the behaviour above - and the pass
|
|
63
116
|
* that `useMeasure` schedules draws the wrapped version.
|
|
64
117
|
*/
|
|
65
|
-
|
|
118
|
+
// The content rect, not the border box: rows are drawn inside the padding,
|
|
119
|
+
// so this is the origin a pointer has to be measured against.
|
|
120
|
+
const rect = useMeasure();
|
|
121
|
+
const width = rect.width;
|
|
66
122
|
const mode = width > 0 && (wrap === 'word' || wrap === 'char') ? wrap : 'none';
|
|
67
123
|
const visual = [];
|
|
68
124
|
lines.forEach((line, index) => {
|
|
@@ -100,40 +156,198 @@ export const TextArea = defineComponent('TextArea', (props) => {
|
|
|
100
156
|
const column = Math.min(caretColumn - (visual[caretRow]?.start ?? 0), row.cells.length);
|
|
101
157
|
return lineStart(row.line) + row.start + column;
|
|
102
158
|
};
|
|
159
|
+
/**
|
|
160
|
+
* The offset a pointer landed on.
|
|
161
|
+
*
|
|
162
|
+
* Cells rather than characters: a wide glyph is two columns, so the column
|
|
163
|
+
* a click is in is not its index in the row. Past the end of a row the
|
|
164
|
+
* caret goes after its last character, which is where a click in the empty
|
|
165
|
+
* part of a line is asking for.
|
|
166
|
+
*/
|
|
167
|
+
const offsetAt = (cellX, rowIndex) => {
|
|
168
|
+
const row = visual[Math.max(0, Math.min(rowIndex, visual.length - 1))];
|
|
169
|
+
if (!row)
|
|
170
|
+
return position;
|
|
171
|
+
let used = 0;
|
|
172
|
+
let column = row.cells.length;
|
|
173
|
+
for (let i = 0; i < row.cells.length; i++) {
|
|
174
|
+
const w = stringWidth(row.cells[i]);
|
|
175
|
+
// The half-way point: clicking the right half of a glyph puts the caret
|
|
176
|
+
// after it, which is what every other editor does.
|
|
177
|
+
if (cellX < used + w - (w > 1 ? 0 : 0.5)) {
|
|
178
|
+
column = i;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
used += w;
|
|
182
|
+
}
|
|
183
|
+
return lineStart(row.line) + row.start + column;
|
|
184
|
+
};
|
|
103
185
|
const replace = (next, at) => {
|
|
104
186
|
const capped = maxLength !== undefined ? next.slice(0, maxLength) : next;
|
|
187
|
+
setAnchor(null);
|
|
105
188
|
onChange(capped);
|
|
106
189
|
setCaret(Math.max(0, Math.min(graphemes(capped).length, at)));
|
|
107
190
|
};
|
|
191
|
+
/** Typing over a selection replaces it - which is what deletes it, too. */
|
|
108
192
|
const insert = (text) => {
|
|
109
193
|
const inserted = graphemes(text);
|
|
110
|
-
replace([...chars.slice(0,
|
|
194
|
+
replace([...chars.slice(0, selectionStart), ...inserted, ...chars.slice(selectionEnd)].join(''), selectionStart + inserted.length);
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* Move the caret, taking the selection with it when shift is held.
|
|
198
|
+
*
|
|
199
|
+
* Without shift the selection collapses, because that is what an arrow key
|
|
200
|
+
* means everywhere: stop selecting, and go here.
|
|
201
|
+
*/
|
|
202
|
+
const move = (to, extend) => {
|
|
203
|
+
if (!extend) {
|
|
204
|
+
setAnchor(null);
|
|
205
|
+
setCaret(to);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const from = anchorRef.current ?? position;
|
|
209
|
+
if (anchorRef.current === null)
|
|
210
|
+
setAnchor(from);
|
|
211
|
+
setCaret(to);
|
|
212
|
+
// A selection made with the keyboard is a selection, and goes the same
|
|
213
|
+
// place one made with the mouse goes. Copying only on a mouse release
|
|
214
|
+
// meant shift+right showed something highlighted that was not on the
|
|
215
|
+
// clipboard - which is a selection you have to redo with the mouse.
|
|
216
|
+
copy(chars.slice(Math.min(from, to), Math.max(from, to)).join(''));
|
|
217
|
+
};
|
|
218
|
+
const copy = (text) => {
|
|
219
|
+
if (copyOnSelect && text !== '')
|
|
220
|
+
clipboard.write(text);
|
|
221
|
+
};
|
|
222
|
+
/** Select `[from, to)`, and copy it - a word picked out is a word taken. */
|
|
223
|
+
const select = (from, to) => {
|
|
224
|
+
setAnchor(from);
|
|
225
|
+
setCaret(to);
|
|
226
|
+
copy(chars.slice(from, to).join(''));
|
|
227
|
+
};
|
|
228
|
+
/**
|
|
229
|
+
* The run a double click means.
|
|
230
|
+
*
|
|
231
|
+
* Letters with letters, spaces with spaces, punctuation with punctuation -
|
|
232
|
+
* so a double click in the gap between two words takes the gap rather than
|
|
233
|
+
* one of the words, which is what makes "select the word, then extend"
|
|
234
|
+
* behave. A newline is a class of its own and joins nothing: a word
|
|
235
|
+
* selection that ran across one would be a paragraph, and there is a
|
|
236
|
+
* separate gesture for that.
|
|
237
|
+
*/
|
|
238
|
+
const runAt = (at) => {
|
|
239
|
+
if (chars.length === 0)
|
|
240
|
+
return [0, 0];
|
|
241
|
+
const index = Math.max(0, Math.min(at, chars.length - 1));
|
|
242
|
+
const kind = classOf(chars[index]);
|
|
243
|
+
if (kind === 'break')
|
|
244
|
+
return [index, index + 1];
|
|
245
|
+
let from = index;
|
|
246
|
+
while (from > 0 && classOf(chars[from - 1]) === kind)
|
|
247
|
+
from -= 1;
|
|
248
|
+
let to = index + 1;
|
|
249
|
+
while (to < chars.length && classOf(chars[to]) === kind)
|
|
250
|
+
to += 1;
|
|
251
|
+
return [from, to];
|
|
252
|
+
};
|
|
253
|
+
/**
|
|
254
|
+
* The offset one word away, in `step`'s direction.
|
|
255
|
+
*
|
|
256
|
+
* Skips whatever is under the caret to the far side of it, then any run of
|
|
257
|
+
* whitespace - so ctrl+right lands at the start of the next word rather than
|
|
258
|
+
* in the gap before it, which is where "skip the run you are in" alone would
|
|
259
|
+
* leave it.
|
|
260
|
+
*
|
|
261
|
+
* A newline is one step of its own. Walking right stops at the end of the
|
|
262
|
+
* line, and the next press crosses to the start of the one below - rather
|
|
263
|
+
* than the break and the first word going by together. The end of a line is
|
|
264
|
+
* somewhere people mean to be, which is why it costs a press to leave.
|
|
265
|
+
*/
|
|
266
|
+
const wordStep = (from, step) => {
|
|
267
|
+
let at = from;
|
|
268
|
+
const peek = () => chars[step < 0 ? at - 1 : at];
|
|
269
|
+
if (step < 0) {
|
|
270
|
+
while (at > 0 && classOf(peek()) === 'space')
|
|
271
|
+
at -= 1;
|
|
272
|
+
if (at > 0 && classOf(peek()) === 'break')
|
|
273
|
+
return at - 1;
|
|
274
|
+
const kind = at > 0 ? classOf(peek()) : 'word';
|
|
275
|
+
while (at > 0 && classOf(peek()) === kind)
|
|
276
|
+
at -= 1;
|
|
277
|
+
return at;
|
|
278
|
+
}
|
|
279
|
+
if (at < chars.length && classOf(peek()) === 'break')
|
|
280
|
+
return at + 1;
|
|
281
|
+
const kind = at < chars.length ? classOf(peek()) : 'word';
|
|
282
|
+
while (at < chars.length && classOf(peek()) === kind)
|
|
283
|
+
at += 1;
|
|
284
|
+
while (at < chars.length && classOf(peek()) === 'space')
|
|
285
|
+
at += 1;
|
|
286
|
+
return at;
|
|
287
|
+
};
|
|
288
|
+
/**
|
|
289
|
+
* The logical line a triple click means - with its newline, when it has one.
|
|
290
|
+
*
|
|
291
|
+
* The line rather than the row: a wrapped paragraph is one thing somebody
|
|
292
|
+
* wrote, and taking the third of it that happened to fit on one row is not
|
|
293
|
+
* a selection anybody asked for.
|
|
294
|
+
*/
|
|
295
|
+
const lineAt = (at) => {
|
|
296
|
+
const index = chars.slice(0, at).join('').split('\n').length - 1;
|
|
297
|
+
const start = lineStart(index);
|
|
298
|
+
const end = start + graphemes(lines[index] ?? '').length;
|
|
299
|
+
return [start, Math.min(chars.length, end + (index < lines.length - 1 ? 1 : 0))];
|
|
111
300
|
};
|
|
112
301
|
useInput((event) => {
|
|
113
302
|
if (disabled)
|
|
114
303
|
return false;
|
|
115
304
|
switch (event.name) {
|
|
116
305
|
case 'left':
|
|
306
|
+
// A word at a time, the way every field with a caret in it does.
|
|
307
|
+
if (event.ctrl || event.alt) {
|
|
308
|
+
move(wordStep(position, -1), event.shift);
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
// A plain arrow collapses to the edge it is moving towards rather
|
|
312
|
+
// than stepping one from the caret: with three words selected, left
|
|
313
|
+
// means "back to the start of that", not "one before wherever the
|
|
314
|
+
// drag happened to end".
|
|
315
|
+
if (selected && !event.shift) {
|
|
316
|
+
move(selectionStart, false);
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
117
319
|
// Off the front of the field is the caller's key, the same way up at
|
|
118
320
|
// the top is - so "left, left, left" walks out of the composer.
|
|
119
321
|
if (position === 0) {
|
|
322
|
+
if (!event.shift)
|
|
323
|
+
setAnchor(null);
|
|
120
324
|
onEdge?.('start');
|
|
121
325
|
return true;
|
|
122
326
|
}
|
|
123
|
-
|
|
327
|
+
move(position - 1, event.shift);
|
|
124
328
|
return true;
|
|
125
329
|
case 'right':
|
|
330
|
+
if (event.ctrl || event.alt) {
|
|
331
|
+
move(wordStep(position, 1), event.shift);
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
if (selected && !event.shift) {
|
|
335
|
+
move(selectionEnd, false);
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
126
338
|
if (position === chars.length) {
|
|
339
|
+
if (!event.shift)
|
|
340
|
+
setAnchor(null);
|
|
127
341
|
onEdge?.('end');
|
|
128
342
|
return true;
|
|
129
343
|
}
|
|
130
|
-
|
|
344
|
+
move(position + 1, event.shift);
|
|
131
345
|
return true;
|
|
132
346
|
case 'home':
|
|
133
|
-
|
|
347
|
+
move(lineStart(caretLine), event.shift);
|
|
134
348
|
return true;
|
|
135
349
|
case 'end':
|
|
136
|
-
|
|
350
|
+
move(lineStart(caretLine) + graphemes(lines[caretLine] ?? '').length, event.shift);
|
|
137
351
|
return true;
|
|
138
352
|
// By *row*, not by line. In a wrapped paragraph moving by line jumps
|
|
139
353
|
// the whole paragraph, and what is above the caret on the screen is
|
|
@@ -142,25 +356,39 @@ export const TextArea = defineComponent('TextArea', (props) => {
|
|
|
142
356
|
// At the top there is nowhere to go inside the field, so the caller
|
|
143
357
|
// gets the key - which is where "the last thing you sent" comes from.
|
|
144
358
|
if (caretRow === 0) {
|
|
359
|
+
if (!event.shift)
|
|
360
|
+
setAnchor(null);
|
|
145
361
|
onOverflow?.(-1);
|
|
146
362
|
return true;
|
|
147
363
|
}
|
|
148
|
-
|
|
364
|
+
move(rowCaret(caretRow - 1), event.shift);
|
|
149
365
|
return true;
|
|
150
366
|
}
|
|
151
367
|
case 'down': {
|
|
152
368
|
if (caretRow === visual.length - 1) {
|
|
369
|
+
if (!event.shift)
|
|
370
|
+
setAnchor(null);
|
|
153
371
|
onOverflow?.(1);
|
|
154
372
|
return true;
|
|
155
373
|
}
|
|
156
|
-
|
|
374
|
+
move(rowCaret(caretRow + 1), event.shift);
|
|
157
375
|
return true;
|
|
158
376
|
}
|
|
159
377
|
case 'backspace':
|
|
378
|
+
// A selection is what gets deleted when there is one - the character
|
|
379
|
+
// before the caret is only the fallback.
|
|
380
|
+
if (selected) {
|
|
381
|
+
insert('');
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
160
384
|
if (position > 0)
|
|
161
385
|
replace([...chars.slice(0, position - 1), ...chars.slice(position)].join(''), position - 1);
|
|
162
386
|
return true;
|
|
163
387
|
case 'delete':
|
|
388
|
+
if (selected) {
|
|
389
|
+
insert('');
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
164
392
|
if (position < chars.length)
|
|
165
393
|
replace([...chars.slice(0, position), ...chars.slice(position + 1)].join(''), position);
|
|
166
394
|
return true;
|
|
@@ -173,9 +401,17 @@ export const TextArea = defineComponent('TextArea', (props) => {
|
|
|
173
401
|
return true;
|
|
174
402
|
}
|
|
175
403
|
onSubmit(value);
|
|
404
|
+
setAnchor(null);
|
|
176
405
|
setCaret(0);
|
|
177
406
|
return true;
|
|
407
|
+
// `onCancel` is documented as escape *when there is nothing inside the
|
|
408
|
+
// field to cancel*, and a live selection is something inside the field
|
|
409
|
+
// to cancel.
|
|
178
410
|
case 'escape':
|
|
411
|
+
if (selected) {
|
|
412
|
+
setAnchor(null);
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
179
415
|
onCancel?.();
|
|
180
416
|
return true;
|
|
181
417
|
case 'paste':
|
|
@@ -206,11 +442,96 @@ export const TextArea = defineComponent('TextArea', (props) => {
|
|
|
206
442
|
// nothing else, because one row is what the field asked for and the border
|
|
207
443
|
// spent it. Nobody saw it because a field without a border looks the same
|
|
208
444
|
// either way, and `TextInput` fixes no height at all.
|
|
445
|
+
/**
|
|
446
|
+
* A click puts the caret where it landed; a drag selects to where it got to.
|
|
447
|
+
*
|
|
448
|
+
* `onMouse` rather than `onClick` because a field that is not focused has to
|
|
449
|
+
* take the keyboard first - otherwise the caret moves somewhere the next
|
|
450
|
+
* keystroke will not go - and because `onClick` is the button going down,
|
|
451
|
+
* which is a third of a drag.
|
|
452
|
+
*
|
|
453
|
+
* The drag arrives at all because the application holds the pointer for
|
|
454
|
+
* whoever took the button down.
|
|
455
|
+
*
|
|
456
|
+
* The row is deliberately not clamped to what is on screen. Dragging below
|
|
457
|
+
* the field asks for a row below the field, the caret follows it there, and
|
|
458
|
+
* the scroll offset is computed from the caret - so a selection dragged off
|
|
459
|
+
* the bottom scrolls the field, which is the whole reason to drag off the
|
|
460
|
+
* bottom. `offsetAt` clamps to the text, and that is the only clamp there is.
|
|
461
|
+
*/
|
|
462
|
+
const pointerOffset = (event) => {
|
|
463
|
+
const column = Math.max(0, event.x - rect.x);
|
|
464
|
+
return Math.max(0, Math.min(chars.length, offsetAt(column, first + (event.y - rect.y))));
|
|
465
|
+
};
|
|
466
|
+
const onMouse = (event) => {
|
|
467
|
+
if (disabled || event.button !== 'left' || rect.width <= 0)
|
|
468
|
+
return false;
|
|
469
|
+
switch (event.action) {
|
|
470
|
+
case 'down': {
|
|
471
|
+
// Only a press *inside* the field starts anything. Drags and releases
|
|
472
|
+
// are not tested that way: by then the pointer is wherever it has got
|
|
473
|
+
// to, and the gesture is this field's regardless.
|
|
474
|
+
if (event.x < rect.x || event.y < rect.y)
|
|
475
|
+
return false;
|
|
476
|
+
if (!focus.focused)
|
|
477
|
+
focus.focus();
|
|
478
|
+
const at = pointerOffset(event);
|
|
479
|
+
// One cell, one window, or it is a new gesture. Position matters as
|
|
480
|
+
// much as time: two clicks half a second apart on different words are
|
|
481
|
+
// two clicks, and a terminal that reports no timestamp gets `0` for
|
|
482
|
+
// both, which lands on "same instant" - so the cell is what saves it.
|
|
483
|
+
const stamp = event.at ?? 0;
|
|
484
|
+
const same = event.x === repeat.current.x && event.y === repeat.current.y;
|
|
485
|
+
const soon = stamp - repeat.current.at <= 450;
|
|
486
|
+
const count = same && soon ? (repeat.current.count % 3) + 1 : 1;
|
|
487
|
+
repeat.current = { at: stamp, x: event.x, y: event.y, count, dragged: false };
|
|
488
|
+
if (count === 2) {
|
|
489
|
+
const [from, to] = runAt(at);
|
|
490
|
+
select(from, to);
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
if (count === 3) {
|
|
494
|
+
const [from, to] = lineAt(at);
|
|
495
|
+
select(from, to);
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
// Anchored, but empty - so a click that never turns into a drag is a
|
|
499
|
+
// caret and nothing else.
|
|
500
|
+
setAnchor(at);
|
|
501
|
+
setCaret(at);
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
case 'drag':
|
|
505
|
+
repeat.current.dragged = true;
|
|
506
|
+
setCaret(pointerOffset(event));
|
|
507
|
+
return true;
|
|
508
|
+
case 'up': {
|
|
509
|
+
// A word or a line was decided when the button went down, and the
|
|
510
|
+
// release is the same press ending. Re-reading the pointer here would
|
|
511
|
+
// collapse the selection back to the cell it was made from - which is
|
|
512
|
+
// what "double click selects two letters" was.
|
|
513
|
+
if (repeat.current.count > 1 && !repeat.current.dragged)
|
|
514
|
+
return true;
|
|
515
|
+
const at = pointerOffset(event);
|
|
516
|
+
const from = anchorRef.current;
|
|
517
|
+
setCaret(at);
|
|
518
|
+
if (from === null || from === at) {
|
|
519
|
+
setAnchor(null);
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
copy(chars.slice(Math.min(from, at), Math.max(from, at)).join(''));
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
default:
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
};
|
|
209
529
|
return h('box', {
|
|
210
530
|
id: focus.id,
|
|
211
531
|
role: 'textbox',
|
|
212
532
|
label: placeholder,
|
|
213
533
|
direction: 'column',
|
|
534
|
+
onMouse,
|
|
214
535
|
...rest,
|
|
215
536
|
}, h('box', { direction: 'column', height: rows }, value === ''
|
|
216
537
|
// The placeholder stays while the field is focused and empty: it is the
|
|
@@ -232,19 +553,65 @@ export const TextArea = defineComponent('TextArea', (props) => {
|
|
|
232
553
|
})()
|
|
233
554
|
: shown.map((row, i) => {
|
|
234
555
|
const index = first + i;
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
return h('text', { key: index, content: text === '' ? ' ' : text, wrap: 'none', truncate: 'end' });
|
|
238
|
-
}
|
|
556
|
+
const base = lineStart(row.line) + row.start;
|
|
557
|
+
const end = base + row.cells.length;
|
|
239
558
|
// The caret's column inside this row. The row has already been broken
|
|
240
559
|
// to the width it will be drawn at, so the split lands where it looks.
|
|
241
|
-
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
560
|
+
const at = index === caretRow && focus.focused ? caretColumn - row.start : -1;
|
|
561
|
+
// One entry per drawn cell, carrying what is true of it. The caret is
|
|
562
|
+
// a cell the row already has rather than an extra one between two of
|
|
563
|
+
// them - that is what keeps a wrapped row as wide as its own text.
|
|
564
|
+
const cells = row.cells.map((char, column) => ({
|
|
565
|
+
char,
|
|
566
|
+
selected: selected && base + column >= selectionStart && base + column < selectionEnd,
|
|
567
|
+
caret: column === at,
|
|
568
|
+
}));
|
|
569
|
+
// The cell after the last character. It exists when the caret is past
|
|
570
|
+
// the end and has nothing to mark, when the newline this row ends on
|
|
571
|
+
// is inside the selection - a selected line break has to show, or
|
|
572
|
+
// three selected lines read as three unrelated runs - and when the row
|
|
573
|
+
// is empty and needs something to occupy it.
|
|
574
|
+
const breaks = visual[index + 1]?.line !== row.line;
|
|
575
|
+
const tail = {
|
|
576
|
+
char: ' ',
|
|
577
|
+
selected: selected && breaks && end >= selectionStart && end < selectionEnd,
|
|
578
|
+
caret: at === row.cells.length,
|
|
579
|
+
};
|
|
580
|
+
if (tail.caret || tail.selected || cells.length === 0)
|
|
581
|
+
cells.push(tail);
|
|
582
|
+
if (!cells.some((cell) => cell.selected || cell.caret)) {
|
|
583
|
+
return h('text', { key: index, content: cells.map((c) => c.char).join(''), wrap: 'none', truncate: 'end' });
|
|
584
|
+
}
|
|
585
|
+
// Neighbouring cells that look the same are drawn as one run, so a
|
|
586
|
+
// selected row is a handful of nodes rather than one per column.
|
|
587
|
+
const runs = [];
|
|
588
|
+
let previous = '';
|
|
589
|
+
for (const cell of cells) {
|
|
590
|
+
const lift = `${cell.selected ? 's' : ''}${cell.caret && lit ? 'c' : ''}`;
|
|
591
|
+
const last = runs[runs.length - 1];
|
|
592
|
+
if (last && lift === previous)
|
|
593
|
+
last.content += cell.char;
|
|
594
|
+
else {
|
|
595
|
+
runs.push({
|
|
596
|
+
content: cell.char,
|
|
597
|
+
style: {
|
|
598
|
+
...(cell.selected ? selectionStyle : {}),
|
|
599
|
+
...(cell.caret && lit ? mark : {}),
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
previous = lift;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return h('box', { key: index, direction: 'row' }, ...runs.map((run, r) => h('text', {
|
|
606
|
+
key: r,
|
|
607
|
+
content: run.content,
|
|
608
|
+
wrap: 'none',
|
|
609
|
+
truncate: 'end',
|
|
610
|
+
...run.style,
|
|
611
|
+
})),
|
|
612
|
+
// The rest of the row, unstyled: flexing the last run instead would
|
|
613
|
+
// drag a selection's background out to the edge of the field.
|
|
614
|
+
h('text', { key: 'rest', content: '', flex: 1, truncate: 'end' }));
|
|
248
615
|
})));
|
|
249
616
|
});
|
|
250
617
|
/**
|
|
@@ -297,3 +664,16 @@ function rowWidth(cells) {
|
|
|
297
664
|
width += stringWidth(cell);
|
|
298
665
|
return width;
|
|
299
666
|
}
|
|
667
|
+
/**
|
|
668
|
+
* What kind of character this is, for the purpose of "the run around it".
|
|
669
|
+
*
|
|
670
|
+
* Four classes, and a newline is its own: joining it to the whitespace either
|
|
671
|
+
* side would make a double click in the margin select across two lines.
|
|
672
|
+
*/
|
|
673
|
+
function classOf(cell) {
|
|
674
|
+
if (cell === '\n')
|
|
675
|
+
return 'break';
|
|
676
|
+
if (/\s/.test(cell))
|
|
677
|
+
return 'space';
|
|
678
|
+
return /[\p{L}\p{N}_]/u.test(cell) ? 'word' : 'other';
|
|
679
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"text-input.d.ts","sourceRoot":"","sources":["../../src/control/text-input.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD;;;;;;GAMG;AACH,eAAO,MAAM,SAAS,
|
|
1
|
+
{"version":3,"file":"text-input.d.ts","sourceRoot":"","sources":["../../src/control/text-input.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD;;;;;;GAMG;AACH,eAAO,MAAM,SAAS,gEAoKpB,CAAC"}
|
|
@@ -109,9 +109,49 @@ export const TextInput = defineComponent('TextInput', (props) => {
|
|
|
109
109
|
}
|
|
110
110
|
return false;
|
|
111
111
|
}, { focusId: focus.id });
|
|
112
|
+
/**
|
|
113
|
+
* A click takes the keyboard and puts the caret where it landed.
|
|
114
|
+
*
|
|
115
|
+
* Without it the field was the one control on the screen that did nothing
|
|
116
|
+
* when it was clicked - and an empty one has nothing in it to say it is a
|
|
117
|
+
* field, so what it looked like was a gap in the layout. `TextArea` has had
|
|
118
|
+
* this from the start; the two disagreeing is what made a form built out of
|
|
119
|
+
* the single-line one unusable with a mouse.
|
|
120
|
+
*
|
|
121
|
+
* `onMouse` rather than `onClick`, the same as there: a field that is not
|
|
122
|
+
* focused has to take the keyboard before the caret moves, or the caret is
|
|
123
|
+
* somewhere the next keystroke will not go.
|
|
124
|
+
*
|
|
125
|
+
* `measured` is the *content* rect - inside the border and the padding - so
|
|
126
|
+
* the value starts at `lead` cells into it, and `start` is what the field
|
|
127
|
+
* has scrolled past.
|
|
128
|
+
*/
|
|
129
|
+
const onMouse = (event) => {
|
|
130
|
+
if (disabled || event.button !== 'left' || event.action !== 'down')
|
|
131
|
+
return false;
|
|
132
|
+
if (measured.width <= 0)
|
|
133
|
+
return false;
|
|
134
|
+
if (event.x < measured.x || event.y < measured.y)
|
|
135
|
+
return false;
|
|
136
|
+
if (!focus.focused)
|
|
137
|
+
focus.focus();
|
|
138
|
+
// Cells to characters. A wide glyph is two columns, and a click on its
|
|
139
|
+
// second column means the same character as a click on its first.
|
|
140
|
+
const column = event.x - measured.x - lead + start;
|
|
141
|
+
let index = 0;
|
|
142
|
+
for (let width = 0; index < chars.length; index++) {
|
|
143
|
+
const next = width + stringWidth(chars[index]);
|
|
144
|
+
if (next > column)
|
|
145
|
+
break;
|
|
146
|
+
width = next;
|
|
147
|
+
}
|
|
148
|
+
move(index);
|
|
149
|
+
return true;
|
|
150
|
+
};
|
|
112
151
|
return h('box', {
|
|
113
152
|
id: focus.id,
|
|
114
153
|
role: search ? 'searchbox' : 'textbox',
|
|
154
|
+
onMouse,
|
|
115
155
|
label,
|
|
116
156
|
direction: 'row',
|
|
117
157
|
gap: 1,
|
package/dist/data/feed.d.ts
CHANGED
|
@@ -17,6 +17,20 @@ export interface FeedProps extends BoxProps {
|
|
|
17
17
|
onSelect?(index: number): void;
|
|
18
18
|
onActivate?(index: number): void;
|
|
19
19
|
scrollbar?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Who `pageup` and `pagedown` belong to.
|
|
22
|
+
*
|
|
23
|
+
* `focused` is the ordinary answer: the keys go to whatever has the
|
|
24
|
+
* keyboard. `always` claims them even while something else does - for the
|
|
25
|
+
* feed that *is* the screen, with a text field under it. Somebody typing a
|
|
26
|
+
* message who presses page up means the conversation above them; there is
|
|
27
|
+
* nothing else on that screen those keys could be for, and taking the
|
|
28
|
+
* keyboard away from the field to use them is the thing they are avoiding.
|
|
29
|
+
*
|
|
30
|
+
* Only those two keys, and only when the focused node has declined them
|
|
31
|
+
* first - so a field that pages its own content keeps them.
|
|
32
|
+
*/
|
|
33
|
+
pageKeys?: 'focused' | 'always';
|
|
20
34
|
focusable?: boolean;
|
|
21
35
|
autoFocus?: boolean;
|
|
22
36
|
/** So a command can send the reader here by name. */
|
package/dist/data/feed.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"feed.d.ts","sourceRoot":"","sources":["../../src/data/feed.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAiB7C,MAAM,WAAW,SAAU,SAAQ,QAAQ;IACzC,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,cAAc,CAAC,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC;IACvC;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,UAAU,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,qDAAqD;IACrD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,IAAI,
|
|
1
|
+
{"version":3,"file":"feed.d.ts","sourceRoot":"","sources":["../../src/data/feed.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAiB7C,MAAM,WAAW,SAAU,SAAQ,QAAQ;IACzC,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,cAAc,CAAC,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC;IACvC;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,UAAU,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;IAChC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,qDAAqD;IACrD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,IAAI,2DAsOf,CAAC"}
|