@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.
Files changed (55) hide show
  1. package/README.md +17 -15
  2. package/dist/control/checkbox.d.ts +2 -0
  3. package/dist/control/checkbox.d.ts.map +1 -1
  4. package/dist/control/checkbox.js +2 -2
  5. package/dist/control/radio-group.d.ts +2 -0
  6. package/dist/control/radio-group.d.ts.map +1 -1
  7. package/dist/control/radio-group.js +2 -2
  8. package/dist/control/text-area.d.ts +15 -2
  9. package/dist/control/text-area.d.ts.map +1 -1
  10. package/dist/control/text-area.js +402 -22
  11. package/dist/control/text-input.d.ts.map +1 -1
  12. package/dist/control/text-input.js +40 -0
  13. package/dist/data/feed.d.ts +14 -0
  14. package/dist/data/feed.d.ts.map +1 -1
  15. package/dist/data/feed.js +28 -2
  16. package/dist/data/list.d.ts +60 -7
  17. package/dist/data/list.d.ts.map +1 -1
  18. package/dist/data/list.js +66 -10
  19. package/dist/data/markdown-view.d.ts.map +1 -1
  20. package/dist/data/markdown-view.js +49 -0
  21. package/dist/display/color-text.d.ts +158 -0
  22. package/dist/display/color-text.d.ts.map +1 -0
  23. package/dist/display/color-text.js +246 -0
  24. package/dist/display/index.d.ts +1 -0
  25. package/dist/display/index.d.ts.map +1 -1
  26. package/dist/display/index.js +3 -0
  27. package/dist/layout/divider.d.ts +6 -1
  28. package/dist/layout/divider.d.ts.map +1 -1
  29. package/dist/layout/divider.js +5 -5
  30. package/dist/layout/scroll-view.d.ts +10 -0
  31. package/dist/layout/scroll-view.d.ts.map +1 -1
  32. package/dist/layout/scroll-view.js +2 -2
  33. package/dist/navigation/menu.d.ts +24 -0
  34. package/dist/navigation/menu.d.ts.map +1 -1
  35. package/dist/navigation/menu.js +58 -15
  36. package/dist/overlay/command-palette.d.ts +32 -1
  37. package/dist/overlay/command-palette.d.ts.map +1 -1
  38. package/dist/overlay/command-palette.js +84 -10
  39. package/dist/shells/workbench-shell.d.ts.map +1 -1
  40. package/dist/shells/workbench-shell.js +16 -2
  41. package/package.json +8 -8
  42. package/src/control/checkbox.ts +4 -2
  43. package/src/control/radio-group.ts +4 -2
  44. package/src/control/text-area.ts +394 -31
  45. package/src/control/text-input.ts +38 -1
  46. package/src/data/feed.ts +44 -1
  47. package/src/data/list.ts +125 -18
  48. package/src/data/markdown-view.ts +56 -0
  49. package/src/display/color-text.ts +386 -0
  50. package/src/display/index.ts +3 -0
  51. package/src/layout/divider.ts +15 -7
  52. package/src/layout/scroll-view.ts +12 -2
  53. package/src/navigation/menu.ts +89 -14
  54. package/src/overlay/command-palette.ts +125 -11
  55. package/src/shells/workbench-shell.ts +16 -3
@@ -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. An underline under the character it is on by
64
- * default; `block` fills the cell instead.
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 = 'underline', ...rest
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
- const mark = caretStyle === 'block'
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
- const width = useMeasure().width;
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([...chars.slice(0, position), ...inserted, ...chars.slice(position)].join(''), position + inserted.length);
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
- setCaret(position - 1);
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 (position === chars.length) { onEdge?.('end'); return true; }
223
- setCaret(position + 1);
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
- setCaret(rowCaret(caretRow - 1));
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
- setCaret(rowCaret(caretRow + 1));
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
- case 'escape': onCancel?.(); return true;
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 text = row.cells.join('');
320
- if (index !== caretRow || !focus.focused) {
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
- // Three parts, and the middle one is the cell the caret is *on* - not
327
- // an extra cell between them. Past the last character there is nothing
328
- // to mark, so a space stands in; that is the one case where the caret
329
- // adds a column, and it adds it at the end where nothing moves.
330
- const at = caretColumn - row.start;
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
- h('text', { content: row.cells.slice(0, at).join('') }),
333
- h('text', { content: row.cells[at] ?? ' ', ...(lit ? mark : {}) }),
334
- h('text', { content: row.cells.slice(at + 1).join(''), flex: 1, truncate: 'end' }));
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,