@textui/widgets 0.3.0 → 0.5.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.
@@ -1 +1 @@
1
- {"version":3,"file":"select.d.ts","sourceRoot":"","sources":["../../src/control/select.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAiB,YAAY,EAAE,MAAM,cAAc,CAAC;AAK1E,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAY,SAAQ,QAAQ;IAC3C,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,oCAAoC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,IAAI,CAAC,EAAE,QAAQ,GAAG,UAAU,GAAG,OAAO,CAAC;CACxC;AAED,eAAO,MAAM,MAAM,sCAuLjB,CAAC"}
1
+ {"version":3,"file":"select.d.ts","sourceRoot":"","sources":["../../src/control/select.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAiB,YAAY,EAAE,MAAM,cAAc,CAAC;AAK1E,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAY,SAAQ,QAAQ;IAC3C,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,oCAAoC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,IAAI,CAAC,EAAE,QAAQ,GAAG,UAAU,GAAG,OAAO,CAAC;CACxC;AAED,eAAO,MAAM,MAAM,sCAiMjB,CAAC"}
@@ -146,6 +146,16 @@ export const Select = defineComponent('Select', (props) => {
146
146
  direction: 'column',
147
147
  border: { style: theme.border, color: focus.focused ? 'focus' : 'border' },
148
148
  padding: [0, 1],
149
+ /*
150
+ * The terminal cursor, parked at the start of the value.
151
+ *
152
+ * There is nothing to type here, so this is not a caret - it is the
153
+ * strongest "you are here" a terminal has, and a control that gives it up
154
+ * on focus is one whose only remaining signal is the colour of its border.
155
+ * Tabbing from a text field used to look like the focus had stayed behind
156
+ * in the field, because the cursor had.
157
+ */
158
+ cursor: focus.focused ? 0 : undefined,
149
159
  ...rest,
150
160
  }, h('box', {
151
161
  direction: 'row',
@@ -1 +1 @@
1
- {"version":3,"file":"text-area.d.ts","sourceRoot":"","sources":["../../src/control/text-area.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAwB,eAAe,EAAS,MAAM,cAAc,CAAC;AAiB3F,MAAM,WAAW,aAAc,SAAQ,QAAQ;IAC7C,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,gEAAgE;IAChE,QAAQ,CAAC,IAAI,IAAI,CAAC;IAClB,gEAAgE;IAChE,UAAU,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;IACrC;;;;;;;OAOG;IACH,MAAM,CAAC,CAAC,IAAI,EAAE,OAAO,GAAG,KAAK,GAAG,IAAI,CAAC;IACrC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B;;;;;;;;;;;;;OAaG;IACH,UAAU,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC;IACnC;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,QAAQ,+DA4jBnB,CAAC"}
1
+ {"version":3,"file":"text-area.d.ts","sourceRoot":"","sources":["../../src/control/text-area.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAwB,eAAe,EAAS,MAAM,cAAc,CAAC;AA0B3F,MAAM,WAAW,aAAc,SAAQ,QAAQ;IAC7C,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,gEAAgE;IAChE,QAAQ,CAAC,IAAI,IAAI,CAAC;IAClB,gEAAgE;IAChE,UAAU,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;IACrC;;;;;;;OAOG;IACH,MAAM,CAAC,CAAC,IAAI,EAAE,OAAO,GAAG,KAAK,GAAG,IAAI,CAAC;IACrC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B;;;;;;;;;;;;;OAaG;IACH,UAAU,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC;IACnC;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,QAAQ,+DA+rBnB,CAAC"}
@@ -1,4 +1,6 @@
1
1
  import { defineComponent, graphemes, h, stringWidth, useClipboard, useEffect, useFocus, useInput, useMeasure, useRef, useState, useTheme, useTicker, } from '@textui/core';
2
+ /** How many edits back a field remembers. */
3
+ const MEMORY = 200;
2
4
  /**
3
5
  * A field that is a paragraph.
4
6
  *
@@ -27,6 +29,22 @@ export const TextArea = defineComponent('TextArea', (props) => {
27
29
  });
28
30
  const [caret, setCaret] = useState(value.length);
29
31
  const [lit, setLit] = useState(true);
32
+ /**
33
+ * What the field looked like before each edit, so one can be taken back.
34
+ *
35
+ * A ref rather than state: nothing here is drawn, and a render for a stack
36
+ * nobody can see is a render for nothing. `value` is the caller's, so an
37
+ * undo is an `onChange` back to a value this field held earlier - the same
38
+ * call any other edit makes.
39
+ *
40
+ * A run of typing is one step. Pushing a snapshot per character would make
41
+ * undo a backspace with extra ceremony, so the run is opened by the first
42
+ * character and closed by anything that is not another one: a delete, a
43
+ * newline, a caret move, a paste.
44
+ */
45
+ const edits = useRef({
46
+ past: [], future: [], typing: false,
47
+ });
30
48
  /**
31
49
  * Where the selection was started from; `null` when there is none.
32
50
  *
@@ -182,16 +200,58 @@ export const TextArea = defineComponent('TextArea', (props) => {
182
200
  }
183
201
  return lineStart(row.line) + row.start + column;
184
202
  };
185
- const replace = (next, at) => {
203
+ /**
204
+ * Keep what is about to be overwritten.
205
+ *
206
+ * Three kinds, because undo is only useful at the granularity a person
207
+ * thinks in. `typing` opens a run and every character after joins it;
208
+ * `break` is the space or newline that finishes a word, which joins the run
209
+ * it ends rather than starting one of its own - so "one two" undone once
210
+ * leaves "one " and not "one". `edit` is everything else, and is always its
211
+ * own step: a kill somebody wants back is not part of the word before it.
212
+ */
213
+ const remember = (kind) => {
214
+ const state = edits.current;
215
+ // A run is open and this joins it, so there is nothing new to keep.
216
+ if (kind !== 'edit' && state.typing) {
217
+ state.typing = kind === 'typing';
218
+ state.future.length = 0;
219
+ return;
220
+ }
221
+ state.past.push({ value, caret: position });
222
+ // Bounded, because a field somebody has been typing in all afternoon
223
+ // should not be holding every version of itself.
224
+ if (state.past.length > MEMORY)
225
+ state.past.shift();
226
+ state.future.length = 0;
227
+ state.typing = kind === 'typing';
228
+ };
229
+ const replace = (next, at, kind = 'edit') => {
186
230
  const capped = maxLength !== undefined ? next.slice(0, maxLength) : next;
231
+ remember(kind);
187
232
  setAnchor(null);
188
233
  onChange(capped);
189
234
  setCaret(Math.max(0, Math.min(graphemes(capped).length, at)));
190
235
  };
236
+ /** Back one step, or forward again. `false` when there is nowhere to go. */
237
+ const step = (back) => {
238
+ const state = edits.current;
239
+ const from = back ? state.past : state.future;
240
+ const to = back ? state.future : state.past;
241
+ const found = from.pop();
242
+ if (!found)
243
+ return false;
244
+ to.push({ value, caret: position });
245
+ state.typing = false;
246
+ setAnchor(null);
247
+ onChange(found.value);
248
+ setCaret(Math.max(0, Math.min(graphemes(found.value).length, found.caret)));
249
+ return true;
250
+ };
191
251
  /** Typing over a selection replaces it - which is what deletes it, too. */
192
- const insert = (text) => {
252
+ const insert = (text, kind = 'edit') => {
193
253
  const inserted = graphemes(text);
194
- replace([...chars.slice(0, selectionStart), ...inserted, ...chars.slice(selectionEnd)].join(''), selectionStart + inserted.length);
254
+ replace([...chars.slice(0, selectionStart), ...inserted, ...chars.slice(selectionEnd)].join(''), selectionStart + inserted.length, kind);
195
255
  };
196
256
  /**
197
257
  * Move the caret, taking the selection with it when shift is held.
@@ -374,16 +434,25 @@ export const TextArea = defineComponent('TextArea', (props) => {
374
434
  move(rowCaret(caretRow + 1), event.shift);
375
435
  return true;
376
436
  }
377
- case 'backspace':
437
+ case 'backspace': {
378
438
  // A selection is what gets deleted when there is one - the character
379
439
  // before the caret is only the fallback.
380
440
  if (selected) {
381
441
  insert('');
382
442
  return true;
383
443
  }
444
+ // `alt+backspace` is the word, which is the one readline binding
445
+ // that is a modifier on a key this field already handles.
446
+ if (event.alt || event.ctrl) {
447
+ const from = wordStep(position, -1);
448
+ if (from !== position)
449
+ replace([...chars.slice(0, from), ...chars.slice(position)].join(''), from);
450
+ return true;
451
+ }
384
452
  if (position > 0)
385
453
  replace([...chars.slice(0, position - 1), ...chars.slice(position)].join(''), position - 1);
386
454
  return true;
455
+ }
387
456
  case 'delete':
388
457
  if (selected) {
389
458
  insert('');
@@ -419,6 +488,86 @@ export const TextArea = defineComponent('TextArea', (props) => {
419
488
  return true;
420
489
  default: break;
421
490
  }
491
+ /*
492
+ * The readline set, which is what a shell has taught everyone's hands.
493
+ *
494
+ * The line and not the row: `ctrl+a` in a shell goes to the start of
495
+ * what you typed, and a wrapped paragraph is still one line of that. The
496
+ * arrow keys are the ones that move by row, because on screen the row
497
+ * above is what is above.
498
+ *
499
+ * `ctrl+f` and `ctrl+b` are deliberately absent. They are character
500
+ * motion in readline and the arrows already are, and leaving them
501
+ * unclaimed lets an application above this field spend them - which is
502
+ * where a find command usually goes.
503
+ */
504
+ if (event.ctrl || event.alt) {
505
+ const home = lineStart(caretLine);
506
+ const away = home + graphemes(lines[caretLine] ?? '').length;
507
+ switch (event.name) {
508
+ case 'a':
509
+ if (event.ctrl) {
510
+ move(home, event.shift);
511
+ return true;
512
+ }
513
+ break;
514
+ case 'e':
515
+ if (event.ctrl) {
516
+ move(away, event.shift);
517
+ return true;
518
+ }
519
+ break;
520
+ case 'k':
521
+ if (event.ctrl) {
522
+ // At the end of a line the newline is what is killed, which is
523
+ // how `ctrl+k` joins two lines in every shell.
524
+ const to = position === away ? Math.min(chars.length, position + 1) : away;
525
+ if (to !== position)
526
+ replace([...chars.slice(0, position), ...chars.slice(to)].join(''), position);
527
+ return true;
528
+ }
529
+ break;
530
+ case 'u':
531
+ if (event.ctrl) {
532
+ if (position !== home)
533
+ replace([...chars.slice(0, home), ...chars.slice(position)].join(''), home);
534
+ return true;
535
+ }
536
+ break;
537
+ case 'w':
538
+ if (event.ctrl) {
539
+ const from = wordStep(position, -1);
540
+ if (from !== position)
541
+ replace([...chars.slice(0, from), ...chars.slice(position)].join(''), from);
542
+ return true;
543
+ }
544
+ break;
545
+ case 'd':
546
+ // `alt+d` only: `ctrl+d` is end-of-input to a terminal and an
547
+ // application above this field answers it.
548
+ if (event.alt) {
549
+ const to = wordStep(position, 1);
550
+ if (to !== position)
551
+ replace([...chars.slice(0, position), ...chars.slice(to)].join(''), position);
552
+ return true;
553
+ }
554
+ break;
555
+ /*
556
+ * Undo on `ctrl+z`, redo on `alt+z`.
557
+ *
558
+ * `ctrl+z` is the suspend key to a shell, but this field runs under
559
+ * a terminal in raw mode, where the byte arrives as itself and no
560
+ * signal is raised. Redo is an alt chord because the usual second
561
+ * key is `ctrl+shift+z`, and shift with a control character is not
562
+ * something most terminals can say.
563
+ */
564
+ case 'z':
565
+ if (event.ctrl || event.alt)
566
+ return step(event.ctrl);
567
+ break;
568
+ default: break;
569
+ }
570
+ }
422
571
  // ctrl+j, for the one case where it is a key of its own: with the kitty
423
572
  // protocol on it arrives as `CSI 106;5u` and is named `j`. Without it
424
573
  // ctrl+j is 0x0a, the same byte as ctrl+enter, and the case above has
@@ -428,7 +577,9 @@ export const TextArea = defineComponent('TextArea', (props) => {
428
577
  return true;
429
578
  }
430
579
  if (event.char && !event.ctrl && !event.alt && !event.meta) {
431
- insert(event.char);
580
+ // A run of ordinary characters is one undo step, and the space that
581
+ // ends a word belongs to the word rather than to the next one.
582
+ insert(event.char, /\s/.test(event.char) ? 'break' : 'typing');
432
583
  return true;
433
584
  }
434
585
  return false;
@@ -14,6 +14,17 @@ export interface FeedProps extends BoxProps {
14
14
  * wants.
15
15
  */
16
16
  selectedIndex?: number;
17
+ /**
18
+ * Keep the selection in view for as long as this is set.
19
+ *
20
+ * Revealing on a change of `selectedIndex` is not enough for a caller that
21
+ * lands on the entry the cursor is already on - a search whose first hit is
22
+ * the row the cursor happens to sit on moves nothing, and the feed stays
23
+ * where it was showing a selection somewhere else. While this is set the
24
+ * selection is brought back whenever it is off screen, whatever the index
25
+ * did, which is what "the cursor is the thing being read" means.
26
+ */
27
+ pinSelection?: boolean;
17
28
  onSelect?(index: number): void;
18
29
  onActivate?(index: number): void;
19
30
  scrollbar?: boolean;
@@ -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;;;;;;;;;;;;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"}
1
+ {"version":3,"file":"feed.d.ts","sourceRoot":"","sources":["../../src/data/feed.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAmB7C,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;;;;;;;;;OASG;IACH,YAAY,CAAC,EAAE,OAAO,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,2DAqTf,CAAC"}
package/dist/data/feed.js CHANGED
@@ -1,6 +1,7 @@
1
- import { chorded, defineComponent, h, useFocus, useInput, useMeasure, useRef, useScrollExtent, useState, } from '@textui/core';
1
+ import { chorded, defineComponent, h, useEffect, useFocus, useInput, useMeasure, useRef, useScrollExtent, useState, } from '@textui/core';
2
2
  import { sizedByLayout } from '../viewport.js';
3
- import { FeedEntry, FeedScrollbar } from './shared.js';
3
+ import { FeedEntry } from './shared.js';
4
+ import { ScrollThumb } from './scroll-thumb.js';
4
5
  /**
5
6
  * A viewport over entries of any height.
6
7
  *
@@ -19,7 +20,7 @@ import { FeedEntry, FeedScrollbar } from './shared.js';
19
20
  * list of things that are not one line tall".
20
21
  */
21
22
  export const Feed = defineComponent('Feed', (props) => {
22
- const { children, follow: followProp, onFollowChange, selectedIndex, onSelect, onActivate, pageKeys = 'focused', scrollbar = true, focusable = true, autoFocus, focusId, id, ...rest } = props;
23
+ const { children, follow: followProp, onFollowChange, selectedIndex, pinSelection, onSelect, onActivate, pageKeys = 'focused', scrollbar = true, focusable = true, autoFocus, focusId, id, ...rest } = props;
23
24
  const focus = useFocus({
24
25
  ...(focusId ? { id: focusId } : {}),
25
26
  disabled: !focusable,
@@ -102,6 +103,42 @@ export const Feed = defineComponent('Feed', (props) => {
102
103
  else
103
104
  scrollTo(top);
104
105
  };
106
+ /*
107
+ * A selection moved from outside is scrolled to as well.
108
+ *
109
+ * `reveal` was reachable only from this feed's own arrow keys, so a caller
110
+ * that drove `selectedIndex` - a search jumping to its next hit, a link to
111
+ * an entry, a cursor restored on the way back to a screen - moved a
112
+ * highlight the viewport never followed. The row was selected and off
113
+ * screen, which reads as the key having done nothing.
114
+ *
115
+ * The first value is skipped rather than revealed. A feed opens at the tail
116
+ * and its caller usually passes an index of zero on the way there, so
117
+ * honouring the initial value would scroll every transcript to the top of
118
+ * the conversation instead of the end of it - and on that first frame there
119
+ * are no measured heights to scroll by anyway.
120
+ */
121
+ const revealed = useRef(null);
122
+ useEffect(() => {
123
+ if (selectedIndex === undefined)
124
+ return;
125
+ const first = revealed.current === null;
126
+ revealed.current = index;
127
+ if (!first)
128
+ reveal(index);
129
+ }, [selectedIndex]);
130
+ // And every frame while the selection is pinned, but only when it has
131
+ // actually gone off screen - `reveal` on an entry already in view is a
132
+ // scroll to where the feed already is, reported as a follow change once a
133
+ // frame for as long as the pin lasts.
134
+ useEffect(() => {
135
+ if (!pinSelection || selectedIndex === undefined)
136
+ return;
137
+ const start = startOf(index);
138
+ const height = heights.current[index] ?? 1;
139
+ if (start < top || start + height > top + Math.max(1, measured.height))
140
+ reveal(index);
141
+ });
105
142
  const move = (delta) => {
106
143
  if (count === 0)
107
144
  return;
@@ -225,12 +262,46 @@ export const Feed = defineComponent('Feed', (props) => {
225
262
  y += height;
226
263
  }
227
264
  }
228
- const drawn = entries.map((entry, i) => h(FeedEntry, {
229
- key: i,
230
- index: i,
231
- onHeight: report.current,
232
- ...(skipped[i] ? { placeholder: true, height: heights.current[i] } : {}),
233
- }, skipped[i] ? null : entry));
265
+ /*
266
+ * A run of skipped entries is one box, not one box each.
267
+ *
268
+ * Skipping the *contents* of an entry took the wrapping away, which was the
269
+ * expensive part, and left everything else: an entry standing in for itself
270
+ * is still a component to reconcile and still a box to measure, four times a
271
+ * frame, at every level of the tree above it. Six hundred entries with
272
+ * thirty on screen was five hundred and seventy of those, and they cost the
273
+ * same whether or not anything in them had changed.
274
+ *
275
+ * The window is contiguous, so there are at most two runs - everything above
276
+ * it and everything below - and each collapses to a single box of exactly
277
+ * the height its entries had. The entries that *are* drawn keep their own
278
+ * index as their key, so scrolling moves the boundary without disturbing the
279
+ * identity of anything still on screen.
280
+ */
281
+ const drawn = [];
282
+ let gap = 0;
283
+ let gapFrom = 0;
284
+ const closeGap = () => {
285
+ if (gap <= 0)
286
+ return;
287
+ drawn.push(h('box', { key: `gap:${String(gapFrom)}`, height: gap }));
288
+ gap = 0;
289
+ };
290
+ for (let i = 0; i < count; i++) {
291
+ if (skipped[i]) {
292
+ if (gap === 0)
293
+ gapFrom = i;
294
+ gap += heights.current[i];
295
+ continue;
296
+ }
297
+ closeGap();
298
+ drawn.push(h(FeedEntry, {
299
+ key: i,
300
+ index: i,
301
+ onHeight: report.current,
302
+ }, entries[i]));
303
+ }
304
+ closeGap();
234
305
  // Content-sized: draw everything and let the box grow. Clamping to a
235
306
  // measurement here would clamp to how tall this happened to be last frame,
236
307
  // which for a box sized *by* its own content is nothing at all - the entries
@@ -256,7 +327,20 @@ export const Feed = defineComponent('Feed', (props) => {
256
327
  return true;
257
328
  },
258
329
  }, h('box', { flex: 1, direction: 'column', scrollTop: top, overflow: 'scroll' }, ...drawn),
330
+ // `ScrollThumb`, the one every other viewport in the library draws. This
331
+ // one had its own: a column filled with the border glyph, taking no offset
332
+ // and saying nothing about where in the conversation you were or how much
333
+ // of it there is. Against the panel it sits inside, it read as a second
334
+ // border - and a bar that cannot be told from a frame is not a bar.
335
+ //
259
336
  // Only when there is somewhere to scroll. A track down the side of a feed
260
337
  // that fits is chrome that states something untrue.
261
- scrollbar && limit > 0 ? h(FeedScrollbar, {}) : null);
338
+ scrollbar && limit > 0
339
+ ? h(ScrollThumb, {
340
+ total: total || measured.height,
341
+ rows: measured.height,
342
+ offset: top,
343
+ focused: focus.focused,
344
+ })
345
+ : null);
262
346
  });
@@ -13,6 +13,14 @@ export interface MarkdownViewProps extends BoxProps {
13
13
  maxLines?: number;
14
14
  /** Dim everything, for reasoning and other second-voice text. */
15
15
  quiet?: boolean;
16
+ /**
17
+ * Text to pick out wherever it appears in the document.
18
+ *
19
+ * Passed to every run, and the painter colours the cells that hold it. The
20
+ * runs are already split by emphasis, so a match that falls across a bold
21
+ * boundary is coloured in both halves.
22
+ */
23
+ match?: string;
16
24
  }
17
25
  /**
18
26
  * Markdown, drawn into the width it was given.
@@ -1 +1 @@
1
- {"version":3,"file":"markdown-view.d.ts","sourceRoot":"","sources":["../../src/data/markdown-view.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAA0C,MAAM,cAAc,CAAC;AAclG,MAAM,WAAW,iBAAkB,SAAQ,QAAQ;IACjD,mDAAmD;IACnD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,oEAAoE;IACpE,IAAI,CAAC,EAAE,WAAW,EAAE,CAAC;IACrB,+EAA+E;IAC/E,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1C,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iEAAiE;IACjE,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,YAAY,mEA8KvB,CAAC"}
1
+ {"version":3,"file":"markdown-view.d.ts","sourceRoot":"","sources":["../../src/data/markdown-view.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAA0C,MAAM,cAAc,CAAC;AAclG,MAAM,WAAW,iBAAkB,SAAQ,QAAQ;IACjD,mDAAmD;IACnD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,oEAAoE;IACpE,IAAI,CAAC,EAAE,WAAW,EAAE,CAAC;IACrB,+EAA+E;IAC/E,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1C,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iEAAiE;IACjE,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,YAAY,mEAkLvB,CAAC"}
@@ -14,7 +14,10 @@ import { CodeViewer } from './code-viewer.js';
14
14
  * person to read.
15
15
  */
16
16
  export const MarkdownView = defineComponent('MarkdownView', (props) => {
17
- const { content = '', rows: given, window: slice, maxLines, quiet, ...rest } = props;
17
+ const { content = '', rows: given, window: slice, maxLines, quiet, match, ...rest } = props;
18
+ // Spread into every run's style. Empty when nothing is being looked for, so
19
+ // a document with no search over it carries no extra prop per run.
20
+ const hit = match ? { match } : {};
18
21
  const theme = useTheme();
19
22
  const measured = useMeasure();
20
23
  const width = measured.width > 0 ? measured.width : 0;
@@ -133,7 +136,7 @@ export const MarkdownView = defineComponent('MarkdownView', (props) => {
133
136
  // anything: a row is its cells with a rule between each pair.
134
137
  out.push(h('box', { key, direction: 'row', overflow: 'hidden' }, ...row.cells.flatMap((cell, c) => [
135
138
  h('text', { key: `s${c}`, content: c === 0 ? `${chars.left} ` : ` ${chars.left} `, fg, wrap: 'none' }),
136
- h('box', { key: `c${c}`, direction: 'row', overflow: 'hidden' }, ...runNodes(cell, quiet ? { fg: 'muted' } : {})),
139
+ h('box', { key: `c${c}`, direction: 'row', overflow: 'hidden' }, ...runNodes(cell, { ...(quiet ? { fg: 'muted' } : {}), ...hit })),
137
140
  ]), h('text', { key: 'end', content: ` ${chars.right}`, fg, wrap: 'none' })));
138
141
  i++;
139
142
  continue;
@@ -148,11 +151,12 @@ export const MarkdownView = defineComponent('MarkdownView', (props) => {
148
151
  bold: row.level <= 2,
149
152
  underline: row.level === 1,
150
153
  fg: quiet ? 'muted' : row.level <= 2 ? 'text' : 'muted',
154
+ ...hit,
151
155
  })));
152
156
  i++;
153
157
  continue;
154
158
  }
155
- const style = { ...(quiet ? { fg: 'muted' } : row.fg ? { fg: row.fg } : {}) };
159
+ const style = { ...(quiet ? { fg: 'muted' } : row.fg ? { fg: row.fg } : {}), ...hit };
156
160
  if (row.prefix !== undefined) {
157
161
  out.push(h('box', { key, direction: 'row', gap: 1, overflow: 'hidden' }, h('text', { content: row.prefix, fg: quiet ? 'subtle' : row.prefixFg ?? 'accent' }), h('box', { direction: 'row', flex: 1, overflow: 'hidden' }, ...runNodes(row.runs, style))));
158
162
  i++;
@@ -13,17 +13,6 @@ import type { BoxProps } from '@textui/core';
13
13
  */
14
14
  export declare const FeedEntry: (props: BoxProps & {
15
15
  index: number;
16
- /**
17
- * Standing in for an entry that is not drawn, at the height it had.
18
- *
19
- * A stand-in must not answer the question it is an answer to. Its height is
20
- * one the feed already knows - it is where it came from - and reporting it
21
- * back is how a measurement gets to depend on itself: a round where a
22
- * stand-in came out any shorter would teach the feed that it *was* shorter,
23
- * and the next round would place the rest of them on top of each other.
24
- */
25
- placeholder?: boolean;
26
16
  onHeight(index: number, height: number): void;
27
17
  }) => import("@textui/core").RenderOutput;
28
- export declare const FeedScrollbar: (props: Record<string, never>) => import("@textui/core").RenderOutput;
29
18
  //# sourceMappingURL=shared.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/data/shared.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAG7C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,SAAS;WACb,MAAM;IACb;;;;;;;;OAQG;kBACW,OAAO;oBACL,MAAM,UAAU,MAAM,GAAG,IAAI;yCAY9C,CAAC;AAEF,eAAO,MAAM,aAAa,uEAGxB,CAAC"}
1
+ {"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/data/shared.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAG7C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,SAAS;WACb,MAAM;oBACG,MAAM,UAAU,MAAM,GAAG,IAAI;yCAW9C,CAAC"}
@@ -1,4 +1,4 @@
1
- import { defineComponent, h, useEffect, useMeasure, useTheme } from '@textui/core';
1
+ import { defineComponent, h, useEffect, useMeasure } from '@textui/core';
2
2
  /**
3
3
  * An entry that reports how tall it turned out to be.
4
4
  *
@@ -12,16 +12,10 @@ import { defineComponent, h, useEffect, useMeasure, useTheme } from '@textui/cor
12
12
  * along with it, however little any of them had changed.
13
13
  */
14
14
  export const FeedEntry = defineComponent('FeedEntry', (props) => {
15
- const { index, placeholder, onHeight, children, ...rest } = props;
15
+ const { index, onHeight, children, ...rest } = props;
16
16
  const measured = useMeasure();
17
17
  useEffect(() => {
18
- if (placeholder)
19
- return;
20
18
  onHeight(index, measured.height);
21
- }, [measured.height, placeholder]);
19
+ }, [measured.height]);
22
20
  return h('box', { direction: 'column', ...rest }, children);
23
21
  });
24
- export const FeedScrollbar = defineComponent('FeedScrollbar', () => {
25
- const theme = useTheme();
26
- return h('box', { width: 1, fill: theme.borderChars().left, fg: 'borderSubtle' });
27
- });
@@ -1 +1 @@
1
- {"version":3,"file":"command-palette.d.ts","sourceRoot":"","sources":["../../src/overlay/command-palette.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAa,OAAO,EAAE,QAAQ,EAAE,iBAAiB,EAAa,MAAM,cAAc,CAAC;AAiB/F,MAAM,WAAW,mBAAoB,SAAQ,QAAQ;IACnD;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,iBAAiB,EAAE,GAAG,CAAC,MAAM,iBAAiB,EAAE,CAAC,CAAC;IAC7D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iEAAiE;IACjE,KAAK,CAAC,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACzD,OAAO,CAAC,IAAI,IAAI,CAAC;IACjB,uEAAuE;IACvE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAC;IAClC;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,cAAc,qEAsZzB,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,iBAAiB,EAC1B,SAAS,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACtC,OAAO,GAAG,SAAS,CAKrB;AAqBD,wBAAgB,cAAc,CAAC,QAAQ,EAAE,iBAAiB,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAehG"}
1
+ {"version":3,"file":"command-palette.d.ts","sourceRoot":"","sources":["../../src/overlay/command-palette.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAa,OAAO,EAAE,QAAQ,EAAE,iBAAiB,EAAa,MAAM,cAAc,CAAC;AAiB/F,MAAM,WAAW,mBAAoB,SAAQ,QAAQ;IACnD;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,iBAAiB,EAAE,GAAG,CAAC,MAAM,iBAAiB,EAAE,CAAC,CAAC;IAC7D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iEAAiE;IACjE,KAAK,CAAC,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACzD,OAAO,CAAC,IAAI,IAAI,CAAC;IACjB,uEAAuE;IACvE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAC;IAClC;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,cAAc,qEA2ZzB,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,iBAAiB,EAC1B,SAAS,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACtC,OAAO,GAAG,SAAS,CAKrB;AAqBD,wBAAgB,cAAc,CAAC,QAAQ,EAAE,iBAAiB,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAehG"}
@@ -80,6 +80,11 @@ export const CommandPalette = defineComponent('CommandPalette', (props) => {
80
80
  shortcut: command.shortcut ?? app?.keybindings.forCommand(command.id)[0],
81
81
  // A chevron, from `Menu`, for anything that will ask a question.
82
82
  children: argumentOf(command) ? [] : undefined,
83
+ // A switch's state, and `undefined` for everything that is not one -
84
+ // which is what keeps the check column off a menu of plain commands.
85
+ // The clause is evaluated per read, so a row opened twice reports
86
+ // what the switch is now rather than what it was when registered.
87
+ checked: app?.commands.isChecked(command.id),
83
88
  // The heading goes on the first row of each group, including the
84
89
  // first - a group with no name over it is the one the reader has to
85
90
  // work out from the rows in it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textui/widgets",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "The TextUI component catalog - layout, display, controls, data, overlays and charts",
5
5
  "keywords": [
6
6
  "terminal",
@@ -46,7 +46,7 @@
46
46
  "test": "vitest run"
47
47
  },
48
48
  "dependencies": {
49
- "@textui/core": "^0.3.0"
49
+ "@textui/core": "^0.5.0"
50
50
  },
51
51
  "publishConfig": {
52
52
  "access": "public"
@@ -202,6 +202,16 @@ export const Select = defineComponent<SelectProps>('Select', (props) => {
202
202
  direction: 'column',
203
203
  border: { style: theme.border, color: focus.focused ? 'focus' : 'border' },
204
204
  padding: [0, 1],
205
+ /*
206
+ * The terminal cursor, parked at the start of the value.
207
+ *
208
+ * There is nothing to type here, so this is not a caret - it is the
209
+ * strongest "you are here" a terminal has, and a control that gives it up
210
+ * on focus is one whose only remaining signal is the colour of its border.
211
+ * Tabbing from a text field used to look like the focus had stayed behind
212
+ * in the field, because the cursor had.
213
+ */
214
+ cursor: focus.focused ? 0 : undefined,
205
215
  ...rest,
206
216
  },
207
217
  h('box', {
@@ -15,6 +15,15 @@ import {
15
15
  useTicker,
16
16
  } from '@textui/core';
17
17
 
18
+ /** A version of the field, kept so an edit can be taken back. */
19
+ interface Snapshot {
20
+ value: string;
21
+ caret: number;
22
+ }
23
+
24
+ /** How many edits back a field remembers. */
25
+ const MEMORY = 200;
26
+
18
27
  export interface TextAreaProps extends BoxProps {
19
28
  value: string;
20
29
  onChange(value: string): void;
@@ -130,6 +139,23 @@ export const TextArea = defineComponent<TextAreaProps>('TextArea', (props) => {
130
139
  const [caret, setCaret] = useState(value.length);
131
140
  const [lit, setLit] = useState(true);
132
141
 
142
+ /**
143
+ * What the field looked like before each edit, so one can be taken back.
144
+ *
145
+ * A ref rather than state: nothing here is drawn, and a render for a stack
146
+ * nobody can see is a render for nothing. `value` is the caller's, so an
147
+ * undo is an `onChange` back to a value this field held earlier - the same
148
+ * call any other edit makes.
149
+ *
150
+ * A run of typing is one step. Pushing a snapshot per character would make
151
+ * undo a backspace with extra ceremony, so the run is opened by the first
152
+ * character and closed by anything that is not another one: a delete, a
153
+ * newline, a caret move, a paste.
154
+ */
155
+ const edits = useRef<{ past: Snapshot[]; future: Snapshot[]; typing: boolean }>({
156
+ past: [], future: [], typing: false,
157
+ });
158
+
133
159
  /**
134
160
  * Where the selection was started from; `null` when there is none.
135
161
  *
@@ -297,19 +323,58 @@ export const TextArea = defineComponent<TextAreaProps>('TextArea', (props) => {
297
323
  return lineStart(row.line) + row.start + column;
298
324
  };
299
325
 
300
- const replace = (next: string, at: number): void => {
326
+ /**
327
+ * Keep what is about to be overwritten.
328
+ *
329
+ * Three kinds, because undo is only useful at the granularity a person
330
+ * thinks in. `typing` opens a run and every character after joins it;
331
+ * `break` is the space or newline that finishes a word, which joins the run
332
+ * it ends rather than starting one of its own - so "one two" undone once
333
+ * leaves "one " and not "one". `edit` is everything else, and is always its
334
+ * own step: a kill somebody wants back is not part of the word before it.
335
+ */
336
+ const remember = (kind: 'typing' | 'break' | 'edit'): void => {
337
+ const state = edits.current;
338
+ // A run is open and this joins it, so there is nothing new to keep.
339
+ if (kind !== 'edit' && state.typing) { state.typing = kind === 'typing'; state.future.length = 0; return; }
340
+ state.past.push({ value, caret: position });
341
+ // Bounded, because a field somebody has been typing in all afternoon
342
+ // should not be holding every version of itself.
343
+ if (state.past.length > MEMORY) state.past.shift();
344
+ state.future.length = 0;
345
+ state.typing = kind === 'typing';
346
+ };
347
+
348
+ const replace = (next: string, at: number, kind: 'typing' | 'break' | 'edit' = 'edit'): void => {
301
349
  const capped = maxLength !== undefined ? next.slice(0, maxLength) : next;
350
+ remember(kind);
302
351
  setAnchor(null);
303
352
  onChange(capped);
304
353
  setCaret(Math.max(0, Math.min(graphemes(capped).length, at)));
305
354
  };
306
355
 
356
+ /** Back one step, or forward again. `false` when there is nowhere to go. */
357
+ const step = (back: boolean): boolean => {
358
+ const state = edits.current;
359
+ const from = back ? state.past : state.future;
360
+ const to = back ? state.future : state.past;
361
+ const found = from.pop();
362
+ if (!found) return false;
363
+ to.push({ value, caret: position });
364
+ state.typing = false;
365
+ setAnchor(null);
366
+ onChange(found.value);
367
+ setCaret(Math.max(0, Math.min(graphemes(found.value).length, found.caret)));
368
+ return true;
369
+ };
370
+
307
371
  /** Typing over a selection replaces it - which is what deletes it, too. */
308
- const insert = (text: string): void => {
372
+ const insert = (text: string, kind: 'typing' | 'break' | 'edit' = 'edit'): void => {
309
373
  const inserted = graphemes(text);
310
374
  replace(
311
375
  [...chars.slice(0, selectionStart), ...inserted, ...chars.slice(selectionEnd)].join(''),
312
376
  selectionStart + inserted.length,
377
+ kind,
313
378
  );
314
379
  };
315
380
 
@@ -457,12 +522,20 @@ export const TextArea = defineComponent<TextAreaProps>('TextArea', (props) => {
457
522
  return true;
458
523
  }
459
524
 
460
- case 'backspace':
525
+ case 'backspace': {
461
526
  // A selection is what gets deleted when there is one - the character
462
527
  // before the caret is only the fallback.
463
528
  if (selected) { insert(''); return true; }
529
+ // `alt+backspace` is the word, which is the one readline binding
530
+ // that is a modifier on a key this field already handles.
531
+ if (event.alt || event.ctrl) {
532
+ const from = wordStep(position, -1);
533
+ if (from !== position) replace([...chars.slice(0, from), ...chars.slice(position)].join(''), from);
534
+ return true;
535
+ }
464
536
  if (position > 0) replace([...chars.slice(0, position - 1), ...chars.slice(position)].join(''), position - 1);
465
537
  return true;
538
+ }
466
539
  case 'delete':
467
540
  if (selected) { insert(''); return true; }
468
541
  if (position < chars.length) replace([...chars.slice(0, position), ...chars.slice(position + 1)].join(''), position);
@@ -489,6 +562,71 @@ export const TextArea = defineComponent<TextAreaProps>('TextArea', (props) => {
489
562
  default: break;
490
563
  }
491
564
 
565
+ /*
566
+ * The readline set, which is what a shell has taught everyone's hands.
567
+ *
568
+ * The line and not the row: `ctrl+a` in a shell goes to the start of
569
+ * what you typed, and a wrapped paragraph is still one line of that. The
570
+ * arrow keys are the ones that move by row, because on screen the row
571
+ * above is what is above.
572
+ *
573
+ * `ctrl+f` and `ctrl+b` are deliberately absent. They are character
574
+ * motion in readline and the arrows already are, and leaving them
575
+ * unclaimed lets an application above this field spend them - which is
576
+ * where a find command usually goes.
577
+ */
578
+ if (event.ctrl || event.alt) {
579
+ const home = lineStart(caretLine);
580
+ const away = home + graphemes(lines[caretLine] ?? '').length;
581
+ switch (event.name) {
582
+ case 'a': if (event.ctrl) { move(home, event.shift); return true; } break;
583
+ case 'e': if (event.ctrl) { move(away, event.shift); return true; } break;
584
+ case 'k':
585
+ if (event.ctrl) {
586
+ // At the end of a line the newline is what is killed, which is
587
+ // how `ctrl+k` joins two lines in every shell.
588
+ const to = position === away ? Math.min(chars.length, position + 1) : away;
589
+ if (to !== position) replace([...chars.slice(0, position), ...chars.slice(to)].join(''), position);
590
+ return true;
591
+ }
592
+ break;
593
+ case 'u':
594
+ if (event.ctrl) {
595
+ if (position !== home) replace([...chars.slice(0, home), ...chars.slice(position)].join(''), home);
596
+ return true;
597
+ }
598
+ break;
599
+ case 'w':
600
+ if (event.ctrl) {
601
+ const from = wordStep(position, -1);
602
+ if (from !== position) replace([...chars.slice(0, from), ...chars.slice(position)].join(''), from);
603
+ return true;
604
+ }
605
+ break;
606
+ case 'd':
607
+ // `alt+d` only: `ctrl+d` is end-of-input to a terminal and an
608
+ // application above this field answers it.
609
+ if (event.alt) {
610
+ const to = wordStep(position, 1);
611
+ if (to !== position) replace([...chars.slice(0, position), ...chars.slice(to)].join(''), position);
612
+ return true;
613
+ }
614
+ break;
615
+ /*
616
+ * Undo on `ctrl+z`, redo on `alt+z`.
617
+ *
618
+ * `ctrl+z` is the suspend key to a shell, but this field runs under
619
+ * a terminal in raw mode, where the byte arrives as itself and no
620
+ * signal is raised. Redo is an alt chord because the usual second
621
+ * key is `ctrl+shift+z`, and shift with a control character is not
622
+ * something most terminals can say.
623
+ */
624
+ case 'z': if (event.ctrl || event.alt) return step(event.ctrl);
625
+ break;
626
+ default: break;
627
+ }
628
+ }
629
+
492
630
  // ctrl+j, for the one case where it is a key of its own: with the kitty
493
631
  // protocol on it arrives as `CSI 106;5u` and is named `j`. Without it
494
632
  // ctrl+j is 0x0a, the same byte as ctrl+enter, and the case above has
@@ -496,7 +634,9 @@ export const TextArea = defineComponent<TextAreaProps>('TextArea', (props) => {
496
634
  if (event.name === 'j' && event.ctrl) { insert('\n'); return true; }
497
635
 
498
636
  if (event.char && !event.ctrl && !event.alt && !event.meta) {
499
- insert(event.char);
637
+ // A run of ordinary characters is one undo step, and the space that
638
+ // ends a word belongs to the word rather than to the next one.
639
+ insert(event.char, /\s/.test(event.char) ? 'break' : 'typing');
500
640
  return true;
501
641
  }
502
642
  return false;
package/src/data/feed.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  chorded,
4
4
  defineComponent,
5
5
  h,
6
+ useEffect,
6
7
  useFocus,
7
8
  useInput,
8
9
  useMeasure,
@@ -11,7 +12,8 @@ import {
11
12
  useState,
12
13
  } from '@textui/core';
13
14
  import { sizedByLayout } from '../viewport.js';
14
- import { FeedEntry, FeedScrollbar } from './shared.js';
15
+ import { FeedEntry } from './shared.js';
16
+ import { ScrollThumb } from './scroll-thumb.js';
15
17
 
16
18
  // ---------------------------------------------------------------------- feed
17
19
 
@@ -30,6 +32,17 @@ export interface FeedProps extends BoxProps {
30
32
  * wants.
31
33
  */
32
34
  selectedIndex?: number;
35
+ /**
36
+ * Keep the selection in view for as long as this is set.
37
+ *
38
+ * Revealing on a change of `selectedIndex` is not enough for a caller that
39
+ * lands on the entry the cursor is already on - a search whose first hit is
40
+ * the row the cursor happens to sit on moves nothing, and the feed stays
41
+ * where it was showing a selection somewhere else. While this is set the
42
+ * selection is brought back whenever it is off screen, whatever the index
43
+ * did, which is what "the cursor is the thing being read" means.
44
+ */
45
+ pinSelection?: boolean;
33
46
  onSelect?(index: number): void;
34
47
  onActivate?(index: number): void;
35
48
  scrollbar?: boolean;
@@ -72,7 +85,7 @@ export interface FeedProps extends BoxProps {
72
85
  */
73
86
  export const Feed = defineComponent<FeedProps>('Feed', (props) => {
74
87
  const {
75
- children, follow: followProp, onFollowChange, selectedIndex, onSelect, onActivate,
88
+ children, follow: followProp, onFollowChange, selectedIndex, pinSelection, onSelect, onActivate,
76
89
  pageKeys = 'focused',
77
90
  scrollbar = true, focusable = true, autoFocus, focusId, id, ...rest
78
91
  } = props;
@@ -163,6 +176,40 @@ export const Feed = defineComponent<FeedProps>('Feed', (props) => {
163
176
  else scrollTo(top);
164
177
  };
165
178
 
179
+ /*
180
+ * A selection moved from outside is scrolled to as well.
181
+ *
182
+ * `reveal` was reachable only from this feed's own arrow keys, so a caller
183
+ * that drove `selectedIndex` - a search jumping to its next hit, a link to
184
+ * an entry, a cursor restored on the way back to a screen - moved a
185
+ * highlight the viewport never followed. The row was selected and off
186
+ * screen, which reads as the key having done nothing.
187
+ *
188
+ * The first value is skipped rather than revealed. A feed opens at the tail
189
+ * and its caller usually passes an index of zero on the way there, so
190
+ * honouring the initial value would scroll every transcript to the top of
191
+ * the conversation instead of the end of it - and on that first frame there
192
+ * are no measured heights to scroll by anyway.
193
+ */
194
+ const revealed = useRef<number | null>(null);
195
+ useEffect(() => {
196
+ if (selectedIndex === undefined) return;
197
+ const first = revealed.current === null;
198
+ revealed.current = index;
199
+ if (!first) reveal(index);
200
+ }, [selectedIndex]);
201
+
202
+ // And every frame while the selection is pinned, but only when it has
203
+ // actually gone off screen - `reveal` on an entry already in view is a
204
+ // scroll to where the feed already is, reported as a follow change once a
205
+ // frame for as long as the pin lasts.
206
+ useEffect(() => {
207
+ if (!pinSelection || selectedIndex === undefined) return;
208
+ const start = startOf(index);
209
+ const height = heights.current[index] ?? 1;
210
+ if (start < top || start + height > top + Math.max(1, measured.height)) reveal(index);
211
+ });
212
+
166
213
  const move = (delta: number): void => {
167
214
  if (count === 0) return;
168
215
  const next = Math.max(0, Math.min(count - 1, index + delta));
@@ -263,12 +310,44 @@ export const Feed = defineComponent<FeedProps>('Feed', (props) => {
263
310
  y += height;
264
311
  }
265
312
  }
266
- const drawn = entries.map((entry, i) => h(FeedEntry, {
267
- key: i,
268
- index: i,
269
- onHeight: report.current,
270
- ...(skipped[i] ? { placeholder: true, height: heights.current[i] as number } : {}),
271
- }, skipped[i] ? null : entry));
313
+ /*
314
+ * A run of skipped entries is one box, not one box each.
315
+ *
316
+ * Skipping the *contents* of an entry took the wrapping away, which was the
317
+ * expensive part, and left everything else: an entry standing in for itself
318
+ * is still a component to reconcile and still a box to measure, four times a
319
+ * frame, at every level of the tree above it. Six hundred entries with
320
+ * thirty on screen was five hundred and seventy of those, and they cost the
321
+ * same whether or not anything in them had changed.
322
+ *
323
+ * The window is contiguous, so there are at most two runs - everything above
324
+ * it and everything below - and each collapses to a single box of exactly
325
+ * the height its entries had. The entries that *are* drawn keep their own
326
+ * index as their key, so scrolling moves the boundary without disturbing the
327
+ * identity of anything still on screen.
328
+ */
329
+ const drawn: unknown[] = [];
330
+ let gap = 0;
331
+ let gapFrom = 0;
332
+ const closeGap = (): void => {
333
+ if (gap <= 0) return;
334
+ drawn.push(h('box', { key: `gap:${String(gapFrom)}`, height: gap }));
335
+ gap = 0;
336
+ };
337
+ for (let i = 0; i < count; i++) {
338
+ if (skipped[i]) {
339
+ if (gap === 0) gapFrom = i;
340
+ gap += heights.current[i] as number;
341
+ continue;
342
+ }
343
+ closeGap();
344
+ drawn.push(h(FeedEntry, {
345
+ key: i,
346
+ index: i,
347
+ onHeight: report.current,
348
+ }, entries[i]));
349
+ }
350
+ closeGap();
272
351
 
273
352
  // Content-sized: draw everything and let the box grow. Clamping to a
274
353
  // measurement here would clamp to how tall this happened to be last frame,
@@ -296,8 +375,21 @@ export const Feed = defineComponent<FeedProps>('Feed', (props) => {
296
375
  },
297
376
  },
298
377
  h('box', { flex: 1, direction: 'column', scrollTop: top, overflow: 'scroll' }, ...drawn),
378
+ // `ScrollThumb`, the one every other viewport in the library draws. This
379
+ // one had its own: a column filled with the border glyph, taking no offset
380
+ // and saying nothing about where in the conversation you were or how much
381
+ // of it there is. Against the panel it sits inside, it read as a second
382
+ // border - and a bar that cannot be told from a frame is not a bar.
383
+ //
299
384
  // Only when there is somewhere to scroll. A track down the side of a feed
300
385
  // that fits is chrome that states something untrue.
301
- scrollbar && limit > 0 ? h(FeedScrollbar, {}) : null,
386
+ scrollbar && limit > 0
387
+ ? h(ScrollThumb, {
388
+ total: total || measured.height,
389
+ rows: measured.height,
390
+ offset: top,
391
+ focused: focus.focused,
392
+ })
393
+ : null,
302
394
  );
303
395
  });
@@ -23,6 +23,14 @@ export interface MarkdownViewProps extends BoxProps {
23
23
  maxLines?: number;
24
24
  /** Dim everything, for reasoning and other second-voice text. */
25
25
  quiet?: boolean;
26
+ /**
27
+ * Text to pick out wherever it appears in the document.
28
+ *
29
+ * Passed to every run, and the painter colours the cells that hold it. The
30
+ * runs are already split by emphasis, so a match that falls across a bold
31
+ * boundary is coloured in both halves.
32
+ */
33
+ match?: string;
26
34
  }
27
35
 
28
36
  /**
@@ -39,7 +47,10 @@ export interface MarkdownViewProps extends BoxProps {
39
47
  * person to read.
40
48
  */
41
49
  export const MarkdownView = defineComponent<MarkdownViewProps>('MarkdownView', (props) => {
42
- const { content = '', rows: given, window: slice, maxLines, quiet, ...rest } = props;
50
+ const { content = '', rows: given, window: slice, maxLines, quiet, match, ...rest } = props;
51
+ // Spread into every run's style. Empty when nothing is being looked for, so
52
+ // a document with no search over it carries no extra prop per run.
53
+ const hit = match ? { match } : {};
43
54
  const theme = useTheme();
44
55
  const measured = useMeasure();
45
56
  const width = measured.width > 0 ? measured.width : 0;
@@ -169,7 +180,7 @@ export const MarkdownView = defineComponent<MarkdownViewProps>('MarkdownView', (
169
180
  ...row.cells.flatMap((cell, c) => [
170
181
  h('text', { key: `s${c}`, content: c === 0 ? `${chars.left} ` : ` ${chars.left} `, fg, wrap: 'none' }),
171
182
  h('box', { key: `c${c}`, direction: 'row', overflow: 'hidden' },
172
- ...runNodes(cell, quiet ? { fg: 'muted' as StyleColor } : {})),
183
+ ...runNodes(cell, { ...(quiet ? { fg: 'muted' as StyleColor } : {}), ...hit })),
173
184
  ]),
174
185
  h('text', { key: 'end', content: ` ${chars.right}`, fg, wrap: 'none' })));
175
186
  i++;
@@ -188,12 +199,13 @@ export const MarkdownView = defineComponent<MarkdownViewProps>('MarkdownView', (
188
199
  bold: row.level <= 2,
189
200
  underline: row.level === 1,
190
201
  fg: quiet ? 'muted' : row.level <= 2 ? 'text' : 'muted',
202
+ ...hit,
191
203
  })));
192
204
  i++;
193
205
  continue;
194
206
  }
195
207
 
196
- const style = { ...(quiet ? { fg: 'muted' as StyleColor } : row.fg ? { fg: row.fg } : {}) };
208
+ const style = { ...(quiet ? { fg: 'muted' as StyleColor } : row.fg ? { fg: row.fg } : {}), ...hit };
197
209
  if (row.prefix !== undefined) {
198
210
  out.push(h('box', { key, direction: 'row', gap: 1, overflow: 'hidden' },
199
211
  h('text', { content: row.prefix, fg: quiet ? 'subtle' : row.prefixFg ?? 'accent' }),
@@ -1,5 +1,5 @@
1
1
  import type { BoxProps } from '@textui/core';
2
- import { defineComponent, h, useEffect, useMeasure, useTheme } from '@textui/core';
2
+ import { defineComponent, h, useEffect, useMeasure } from '@textui/core';
3
3
 
4
4
  /**
5
5
  * An entry that reports how tall it turned out to be.
@@ -15,31 +15,15 @@ import { defineComponent, h, useEffect, useMeasure, useTheme } from '@textui/cor
15
15
  */
16
16
  export const FeedEntry = defineComponent<BoxProps & {
17
17
  index: number;
18
- /**
19
- * Standing in for an entry that is not drawn, at the height it had.
20
- *
21
- * A stand-in must not answer the question it is an answer to. Its height is
22
- * one the feed already knows - it is where it came from - and reporting it
23
- * back is how a measurement gets to depend on itself: a round where a
24
- * stand-in came out any shorter would teach the feed that it *was* shorter,
25
- * and the next round would place the rest of them on top of each other.
26
- */
27
- placeholder?: boolean;
28
18
  onHeight(index: number, height: number): void;
29
19
  }>(
30
20
  'FeedEntry',
31
21
  (props) => {
32
- const { index, placeholder, onHeight, children, ...rest } = props;
22
+ const { index, onHeight, children, ...rest } = props;
33
23
  const measured = useMeasure();
34
24
  useEffect(() => {
35
- if (placeholder) return;
36
25
  onHeight(index, measured.height);
37
- }, [measured.height, placeholder]);
26
+ }, [measured.height]);
38
27
  return h('box', { direction: 'column', ...rest }, children);
39
28
  },
40
29
  );
41
-
42
- export const FeedScrollbar = defineComponent<Record<string, never>>('FeedScrollbar', () => {
43
- const theme = useTheme();
44
- return h('box', { width: 1, fill: theme.borderChars().left, fg: 'borderSubtle' });
45
- });
@@ -166,6 +166,11 @@ export const CommandPalette = defineComponent<CommandPaletteProps>('CommandPalet
166
166
  shortcut: command.shortcut ?? app?.keybindings.forCommand(command.id)[0],
167
167
  // A chevron, from `Menu`, for anything that will ask a question.
168
168
  children: argumentOf(command) ? [] : undefined,
169
+ // A switch's state, and `undefined` for everything that is not one -
170
+ // which is what keeps the check column off a menu of plain commands.
171
+ // The clause is evaluated per read, so a row opened twice reports
172
+ // what the switch is now rather than what it was when registered.
173
+ checked: app?.commands.isChecked(command.id),
169
174
  // The heading goes on the first row of each group, including the
170
175
  // first - a group with no name over it is the one the reader has to
171
176
  // work out from the rows in it.