@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,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 = 'underline', ...rest } = props;
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
- const mark = caretStyle === 'block'
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
- const width = useMeasure().width;
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, position), ...inserted, ...chars.slice(position)].join(''), position + inserted.length);
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
- setCaret(position - 1);
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
- setCaret(position + 1);
344
+ move(position + 1, event.shift);
131
345
  return true;
132
346
  case 'home':
133
- setCaret(lineStart(caretLine));
347
+ move(lineStart(caretLine), event.shift);
134
348
  return true;
135
349
  case 'end':
136
- setCaret(lineStart(caretLine) + graphemes(lines[caretLine] ?? '').length);
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
- setCaret(rowCaret(caretRow - 1));
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
- setCaret(rowCaret(caretRow + 1));
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 text = row.cells.join('');
236
- if (index !== caretRow || !focus.focused) {
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
- // Three parts, and the middle one is the cell the caret is *on* - not
243
- // an extra cell between them. Past the last character there is nothing
244
- // to mark, so a space stands in; that is the one case where the caret
245
- // adds a column, and it adds it at the end where nothing moves.
246
- const at = caretColumn - row.start;
247
- return h('box', { key: index, direction: 'row' }, h('text', { content: row.cells.slice(0, at).join('') }), h('text', { content: row.cells[at] ?? ' ', ...(lit ? mark : {}) }), h('text', { content: row.cells.slice(at + 1).join(''), flex: 1, truncate: 'end' }));
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,gEA+HpB,CAAC"}
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,
@@ -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. */
@@ -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,2DAiJf,CAAC"}
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,2DA8Kf,CAAC"}