@textui/widgets 0.4.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/control/text-area.d.ts.map +1 -1
- package/dist/control/text-area.js +156 -5
- package/dist/data/feed.d.ts +11 -0
- package/dist/data/feed.d.ts.map +1 -1
- package/dist/data/feed.js +38 -2
- package/dist/data/markdown-view.d.ts +8 -0
- package/dist/data/markdown-view.d.ts.map +1 -1
- package/dist/data/markdown-view.js +7 -3
- package/dist/overlay/command-palette.d.ts +0 -14
- package/dist/overlay/command-palette.d.ts.map +1 -1
- package/dist/overlay/command-palette.js +78 -24
- package/package.json +2 -2
- package/src/control/text-area.ts +144 -4
- package/src/data/feed.ts +47 -1
- package/src/data/markdown-view.ts +15 -3
- package/src/overlay/command-palette.ts +92 -16
|
@@ -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;
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/data/feed.d.ts
CHANGED
|
@@ -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;
|
package/dist/data/feed.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"feed.d.ts","sourceRoot":"","sources":["../../src/data/feed.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;
|
|
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,4 +1,4 @@
|
|
|
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
3
|
import { FeedEntry } from './shared.js';
|
|
4
4
|
import { ScrollThumb } from './scroll-thumb.js';
|
|
@@ -20,7 +20,7 @@ import { ScrollThumb } from './scroll-thumb.js';
|
|
|
20
20
|
* list of things that are not one line tall".
|
|
21
21
|
*/
|
|
22
22
|
export const Feed = defineComponent('Feed', (props) => {
|
|
23
|
-
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;
|
|
24
24
|
const focus = useFocus({
|
|
25
25
|
...(focusId ? { id: focusId } : {}),
|
|
26
26
|
disabled: !focusable,
|
|
@@ -103,6 +103,42 @@ export const Feed = defineComponent('Feed', (props) => {
|
|
|
103
103
|
else
|
|
104
104
|
scrollTo(top);
|
|
105
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
|
+
});
|
|
106
142
|
const move = (delta) => {
|
|
107
143
|
if (count === 0)
|
|
108
144
|
return;
|
|
@@ -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;
|
|
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++;
|
|
@@ -58,20 +58,6 @@ export interface CommandPaletteProps extends BoxProps {
|
|
|
58
58
|
*/
|
|
59
59
|
openAt?: string;
|
|
60
60
|
}
|
|
61
|
-
/**
|
|
62
|
-
* The command palette.
|
|
63
|
-
*
|
|
64
|
-
* It searches the command registry rather than a list someone maintained, so
|
|
65
|
-
* a command registered anywhere is reachable here the moment it exists - which
|
|
66
|
-
* is the payoff for insisting actions are commands. It also *runs* what it
|
|
67
|
-
* finds, so choosing "Open dialog" here and pressing the button that opens a
|
|
68
|
-
* dialog are the same act, reaching the same code.
|
|
69
|
-
*
|
|
70
|
-
* A command that declares an argument with `choices` gets a second level:
|
|
71
|
-
* choosing it lists the choices, and picking one runs the command with it.
|
|
72
|
-
* That is where sub-items come from - the command says what it needs and the
|
|
73
|
-
* palette asks, rather than every caller inventing its own submenu.
|
|
74
|
-
*/
|
|
75
61
|
export declare const CommandPalette: (props: CommandPaletteProps) => import("@textui/core").RenderOutput;
|
|
76
62
|
/**
|
|
77
63
|
* The argument the palette should ask about, if there is one.
|
|
@@ -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;
|
|
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;AAyBD,eAAO,MAAM,cAAc,qEAqczB,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,iBAAiB,EAC1B,SAAS,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACtC,OAAO,GAAG,SAAS,CAKrB;AAyCD,wBAAgB,cAAc,CAAC,QAAQ,EAAE,iBAAiB,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAehG"}
|
|
@@ -2,20 +2,6 @@ import { defineComponent, h, stringWidth, useEffect, useFocusScope, useInput, us
|
|
|
2
2
|
import { TextInput } from '../control/index.js';
|
|
3
3
|
import { Menu } from '../navigation/index.js';
|
|
4
4
|
import { hint } from './shared.js';
|
|
5
|
-
/**
|
|
6
|
-
* The command palette.
|
|
7
|
-
*
|
|
8
|
-
* It searches the command registry rather than a list someone maintained, so
|
|
9
|
-
* a command registered anywhere is reachable here the moment it exists - which
|
|
10
|
-
* is the payoff for insisting actions are commands. It also *runs* what it
|
|
11
|
-
* finds, so choosing "Open dialog" here and pressing the button that opens a
|
|
12
|
-
* dialog are the same act, reaching the same code.
|
|
13
|
-
*
|
|
14
|
-
* A command that declares an argument with `choices` gets a second level:
|
|
15
|
-
* choosing it lists the choices, and picking one runs the command with it.
|
|
16
|
-
* That is where sub-items come from - the command says what it needs and the
|
|
17
|
-
* palette asks, rather than every caller inventing its own submenu.
|
|
18
|
-
*/
|
|
19
5
|
export const CommandPalette = defineComponent('CommandPalette', (props) => {
|
|
20
6
|
const theme = useTheme();
|
|
21
7
|
const runtime = useRuntime();
|
|
@@ -80,6 +66,11 @@ export const CommandPalette = defineComponent('CommandPalette', (props) => {
|
|
|
80
66
|
shortcut: command.shortcut ?? app?.keybindings.forCommand(command.id)[0],
|
|
81
67
|
// A chevron, from `Menu`, for anything that will ask a question.
|
|
82
68
|
children: argumentOf(command) ? [] : undefined,
|
|
69
|
+
// A switch's state, and `undefined` for everything that is not one -
|
|
70
|
+
// which is what keeps the check column off a menu of plain commands.
|
|
71
|
+
// The clause is evaluated per read, so a row opened twice reports
|
|
72
|
+
// what the switch is now rather than what it was when registered.
|
|
73
|
+
checked: app?.commands.isChecked(command.id),
|
|
83
74
|
// The heading goes on the first row of each group, including the
|
|
84
75
|
// first - a group with no name over it is the one the reader has to
|
|
85
76
|
// work out from the rows in it.
|
|
@@ -94,10 +85,29 @@ export const CommandPalette = defineComponent('CommandPalette', (props) => {
|
|
|
94
85
|
? { sectionBefore: command.category }
|
|
95
86
|
: {}),
|
|
96
87
|
}));
|
|
88
|
+
/** The argument answered last, when this question came after one. */
|
|
89
|
+
const previousOf = (asked) => {
|
|
90
|
+
const last = Object.keys(asked.collected).at(-1);
|
|
91
|
+
return last === undefined ? undefined : (asked.command.args ?? []).find((arg) => arg.name === last);
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* One question back.
|
|
95
|
+
*
|
|
96
|
+
* After a first question the second is asked again from the start with
|
|
97
|
+
* the earlier answer withdrawn: a wrong provider is corrected by choosing
|
|
98
|
+
* another, not by closing the whole picker and opening it again. At the
|
|
99
|
+
* first question, the command list.
|
|
100
|
+
*/
|
|
97
101
|
const back = () => {
|
|
98
102
|
// `null` is "never mind": whatever the preview did gets undone by whoever
|
|
99
103
|
// did it, because only the command knows what it changed.
|
|
100
104
|
pending?.arg.preview?.(null);
|
|
105
|
+
const previous = pending ? previousOf(pending) : undefined;
|
|
106
|
+
if (pending && previous) {
|
|
107
|
+
const { [previous.name]: _withdrawn, ...rest } = pending.collected;
|
|
108
|
+
drillInto(pending.command, previous, rest);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
101
111
|
setPending(null);
|
|
102
112
|
setChoices([]);
|
|
103
113
|
setQuery('');
|
|
@@ -139,9 +149,16 @@ export const CommandPalette = defineComponent('CommandPalette', (props) => {
|
|
|
139
149
|
setRefresh((n) => n + 1);
|
|
140
150
|
onRun?.(id, args);
|
|
141
151
|
};
|
|
142
|
-
/**
|
|
152
|
+
/**
|
|
153
|
+
* Ask about one command. `choices` may be a function, and may be async.
|
|
154
|
+
*
|
|
155
|
+
* A resolver is handed what was answered before this argument - and an
|
|
156
|
+
* argument that was not asked because its `default` stood was answered by
|
|
157
|
+
* that default. Left out, "the models of the chosen provider" would see no
|
|
158
|
+
* provider exactly when there was only one to choose from.
|
|
159
|
+
*/
|
|
143
160
|
const drillInto = (command, arg, collected = {}) => {
|
|
144
|
-
const resolved = typeof arg.choices === 'function' ? arg.choices() : arg.choices ?? [];
|
|
161
|
+
const resolved = typeof arg.choices === 'function' ? arg.choices(answeredBefore(command, arg, collected)) : arg.choices ?? [];
|
|
145
162
|
setPending({ command, arg, collected });
|
|
146
163
|
setQuery('');
|
|
147
164
|
/*
|
|
@@ -248,8 +265,10 @@ export const CommandPalette = defineComponent('CommandPalette', (props) => {
|
|
|
248
265
|
// anybody chose to be at: the caller drilled in on their behalf, and
|
|
249
266
|
// the level underneath is a list of one. Backing out to it reads as
|
|
250
267
|
// "escape did nothing", and the second escape - the one that would
|
|
251
|
-
// close it - is spent leaving a screen nobody asked to see.
|
|
252
|
-
|
|
268
|
+
// close it - is spent leaving a screen nobody asked to see. A question
|
|
269
|
+
// that came after one is different: there the level underneath is the
|
|
270
|
+
// earlier question, which somebody did choose to be at.
|
|
271
|
+
if (pending && (!openAt || previousOf(pending) !== undefined)) {
|
|
253
272
|
back();
|
|
254
273
|
return true;
|
|
255
274
|
}
|
|
@@ -262,7 +281,7 @@ export const CommandPalette = defineComponent('CommandPalette', (props) => {
|
|
|
262
281
|
onClose?.();
|
|
263
282
|
return true;
|
|
264
283
|
}
|
|
265
|
-
if (event.name === 'left' && pending && query === '' && !openAt) {
|
|
284
|
+
if (event.name === 'left' && pending && query === '' && (!openAt || previousOf(pending) !== undefined)) {
|
|
266
285
|
back();
|
|
267
286
|
return true;
|
|
268
287
|
}
|
|
@@ -292,6 +311,8 @@ export const CommandPalette = defineComponent('CommandPalette', (props) => {
|
|
|
292
311
|
// there - and a sentence about what a mode does is exactly the thing a
|
|
293
312
|
// person needs whole. So the line under the list follows the highlight: the
|
|
294
313
|
// choice's description while there is one, the question's otherwise.
|
|
314
|
+
// It is the one line in the panel that wraps rather than truncates,
|
|
315
|
+
// because it is the one place a sentence can be read whole.
|
|
295
316
|
const chosen = pending ? offered[index] : undefined;
|
|
296
317
|
const detail = pending
|
|
297
318
|
? chosen?.description
|
|
@@ -310,7 +331,14 @@ export const CommandPalette = defineComponent('CommandPalette', (props) => {
|
|
|
310
331
|
* the things that decide it: a question with two short answers still needs
|
|
311
332
|
* somewhere to type and a line saying what the keys do.
|
|
312
333
|
*/
|
|
313
|
-
const content = Math.max(...items.map((item) => rowWidth(item, descriptions)), ...(pending ? [stringWidth(pending.command.title) + 12] : [stringWidth(placeholder ?? '') + 4])
|
|
334
|
+
const content = Math.max(...items.map((item) => rowWidth(item, descriptions)), ...(pending ? [stringWidth(pending.command.title) + 12] : [stringWidth(placeholder ?? '') + 4]),
|
|
335
|
+
// The sentences the line under the list may show, so a question with
|
|
336
|
+
// three one-word answers is not a panel 28 wide wrapping a sentence
|
|
337
|
+
// three times under them. Capped like everything else by `maxWidth`.
|
|
338
|
+
...(pending
|
|
339
|
+
? [pending.arg.description ?? '', ...offered.map((choice) => choice.description ?? '')]
|
|
340
|
+
.map((sentence) => (sentence === '' ? 0 : stringWidth(sentence) + 6))
|
|
341
|
+
: []));
|
|
314
342
|
return h('box', {
|
|
315
343
|
role: 'dialog',
|
|
316
344
|
label: 'Commands',
|
|
@@ -356,10 +384,19 @@ export const CommandPalette = defineComponent('CommandPalette', (props) => {
|
|
|
356
384
|
if (command && argumentOf(command))
|
|
357
385
|
choose();
|
|
358
386
|
},
|
|
387
|
+
// A typed answer's field is the answer, so its placeholder is the
|
|
388
|
+
// question. A list's field is a filter, and the question goes where
|
|
389
|
+
// there is room to say it once: here, while the line under the list
|
|
390
|
+
// is busy saying what each answer means; under the list, whole and
|
|
391
|
+
// wrapped, when the answers have nothing to say for themselves and
|
|
392
|
+
// that line would otherwise repeat this one, cut to whatever width
|
|
393
|
+
// three short answers gave the panel.
|
|
359
394
|
placeholder: pending
|
|
360
|
-
? (pending.arg.
|
|
361
|
-
? `${pending.command.title}${theme.glyphs.ellipsis}`
|
|
362
|
-
:
|
|
395
|
+
? (pending.arg.choices === undefined
|
|
396
|
+
? pending.arg.description ?? `${pending.command.title}${theme.glyphs.ellipsis}`
|
|
397
|
+
: offered.some((choice) => choice.description) && pending.arg.description
|
|
398
|
+
? pending.arg.description
|
|
399
|
+
: `Choose ${pending.command.title.toLowerCase()}${theme.glyphs.ellipsis}`)
|
|
363
400
|
: (placeholder ?? `Type a command${theme.glyphs.ellipsis}`),
|
|
364
401
|
search: true,
|
|
365
402
|
autoFocus: true,
|
|
@@ -381,7 +418,7 @@ export const CommandPalette = defineComponent('CommandPalette', (props) => {
|
|
|
381
418
|
}), h('box', { height: 1, fill: theme.borderChars().top, fg: 'borderSubtle' }),
|
|
382
419
|
// What the highlighted row actually is, and how to move around. A palette
|
|
383
420
|
// that shows only titles makes you run something to find out what it does.
|
|
384
|
-
h('box', { direction: 'row', gap: 1 }, h('text', { content: detail, fg: 'muted', flex: 1,
|
|
421
|
+
h('box', { direction: 'row', gap: 1, align: 'start' }, h('text', { content: detail, fg: 'muted', flex: 1, wrap: 'word' }), h('text', { content: `${rows.length}`, fg: 'subtle' })), h('box', { direction: 'row', gap: 1 }, h('text', {
|
|
385
422
|
content: pending
|
|
386
423
|
? (pending.arg.choices === undefined
|
|
387
424
|
? hint(theme, ['type it', 'enter confirm', 'esc back'])
|
|
@@ -413,6 +450,23 @@ export function argumentOf(command, collected = {}) {
|
|
|
413
450
|
return (command.args ?? []).find((arg) => collected[arg.name] === undefined &&
|
|
414
451
|
(arg.choices !== undefined || (arg.required === true && arg.default === undefined)));
|
|
415
452
|
}
|
|
453
|
+
/**
|
|
454
|
+
* What a `choices` resolver is told: the answers given, under the defaults
|
|
455
|
+
* of the arguments before this one that stood in for a question.
|
|
456
|
+
*
|
|
457
|
+
* Only the ones ahead of it in the declaration. An argument after this one
|
|
458
|
+
* has not been reached, and its default is not yet an answer to anything.
|
|
459
|
+
*/
|
|
460
|
+
function answeredBefore(command, arg, collected) {
|
|
461
|
+
const answered = {};
|
|
462
|
+
for (const spec of command.args ?? []) {
|
|
463
|
+
if (spec === arg)
|
|
464
|
+
break;
|
|
465
|
+
if (spec.default !== undefined)
|
|
466
|
+
answered[spec.name] = spec.default;
|
|
467
|
+
}
|
|
468
|
+
return { ...answered, ...collected };
|
|
469
|
+
}
|
|
416
470
|
/** The short form and the long one, as one shape. */
|
|
417
471
|
function asChoice(choice) {
|
|
418
472
|
return typeof choice === 'string' ? { value: choice } : choice;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@textui/widgets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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.
|
|
49
|
+
"@textui/core": "^0.6.1"
|
|
50
50
|
},
|
|
51
51
|
"publishConfig": {
|
|
52
52
|
"access": "public"
|
package/src/control/text-area.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -31,6 +32,17 @@ export interface FeedProps extends BoxProps {
|
|
|
31
32
|
* wants.
|
|
32
33
|
*/
|
|
33
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;
|
|
34
46
|
onSelect?(index: number): void;
|
|
35
47
|
onActivate?(index: number): void;
|
|
36
48
|
scrollbar?: boolean;
|
|
@@ -73,7 +85,7 @@ export interface FeedProps extends BoxProps {
|
|
|
73
85
|
*/
|
|
74
86
|
export const Feed = defineComponent<FeedProps>('Feed', (props) => {
|
|
75
87
|
const {
|
|
76
|
-
children, follow: followProp, onFollowChange, selectedIndex, onSelect, onActivate,
|
|
88
|
+
children, follow: followProp, onFollowChange, selectedIndex, pinSelection, onSelect, onActivate,
|
|
77
89
|
pageKeys = 'focused',
|
|
78
90
|
scrollbar = true, focusable = true, autoFocus, focusId, id, ...rest
|
|
79
91
|
} = props;
|
|
@@ -164,6 +176,40 @@ export const Feed = defineComponent<FeedProps>('Feed', (props) => {
|
|
|
164
176
|
else scrollTo(top);
|
|
165
177
|
};
|
|
166
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
|
+
|
|
167
213
|
const move = (delta: number): void => {
|
|
168
214
|
if (count === 0) return;
|
|
169
215
|
const next = Math.max(0, Math.min(count - 1, index + delta));
|
|
@@ -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' }),
|
|
@@ -89,6 +89,15 @@ export interface CommandPaletteProps extends BoxProps {
|
|
|
89
89
|
* That is where sub-items come from - the command says what it needs and the
|
|
90
90
|
* palette asks, rather than every caller inventing its own submenu.
|
|
91
91
|
*/
|
|
92
|
+
|
|
93
|
+
/** The question being asked: the command, the argument, and the answers given before it. */
|
|
94
|
+
interface Pending {
|
|
95
|
+
command: CommandDefinition;
|
|
96
|
+
arg: ArgSpec;
|
|
97
|
+
/** Answers given so far, for a command that asks for more than one. */
|
|
98
|
+
collected: Record<string, unknown>;
|
|
99
|
+
}
|
|
100
|
+
|
|
92
101
|
export const CommandPalette = defineComponent<CommandPaletteProps>('CommandPalette', (props) => {
|
|
93
102
|
const theme = useTheme();
|
|
94
103
|
const runtime = useRuntime();
|
|
@@ -101,12 +110,7 @@ export const CommandPalette = defineComponent<CommandPaletteProps>('CommandPalet
|
|
|
101
110
|
const [query, setQuery] = useState('');
|
|
102
111
|
const [highlight, setHighlight] = useState(0);
|
|
103
112
|
/** The command being asked about, when the palette has drilled in. */
|
|
104
|
-
const [pending, setPending] = useState<
|
|
105
|
-
command: CommandDefinition;
|
|
106
|
-
arg: ArgSpec;
|
|
107
|
-
/** Answers given so far, for a command that asks for more than one. */
|
|
108
|
-
collected: Record<string, unknown>;
|
|
109
|
-
} | null>(null);
|
|
113
|
+
const [pending, setPending] = useState<Pending | null>(null);
|
|
110
114
|
const [choices, setChoices] = useState<ArgChoice[]>([]);
|
|
111
115
|
/**
|
|
112
116
|
* Whether the answer to "what may I choose" is still on its way.
|
|
@@ -166,6 +170,11 @@ export const CommandPalette = defineComponent<CommandPaletteProps>('CommandPalet
|
|
|
166
170
|
shortcut: command.shortcut ?? app?.keybindings.forCommand(command.id)[0],
|
|
167
171
|
// A chevron, from `Menu`, for anything that will ask a question.
|
|
168
172
|
children: argumentOf(command) ? [] : undefined,
|
|
173
|
+
// A switch's state, and `undefined` for everything that is not one -
|
|
174
|
+
// which is what keeps the check column off a menu of plain commands.
|
|
175
|
+
// The clause is evaluated per read, so a row opened twice reports
|
|
176
|
+
// what the switch is now rather than what it was when registered.
|
|
177
|
+
checked: app?.commands.isChecked(command.id),
|
|
169
178
|
// The heading goes on the first row of each group, including the
|
|
170
179
|
// first - a group with no name over it is the one the reader has to
|
|
171
180
|
// work out from the rows in it.
|
|
@@ -181,10 +190,30 @@ export const CommandPalette = defineComponent<CommandPaletteProps>('CommandPalet
|
|
|
181
190
|
: {}),
|
|
182
191
|
}));
|
|
183
192
|
|
|
193
|
+
/** The argument answered last, when this question came after one. */
|
|
194
|
+
const previousOf = (asked: Pending): ArgSpec | undefined => {
|
|
195
|
+
const last = Object.keys(asked.collected).at(-1);
|
|
196
|
+
return last === undefined ? undefined : (asked.command.args ?? []).find((arg) => arg.name === last);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* One question back.
|
|
201
|
+
*
|
|
202
|
+
* After a first question the second is asked again from the start with
|
|
203
|
+
* the earlier answer withdrawn: a wrong provider is corrected by choosing
|
|
204
|
+
* another, not by closing the whole picker and opening it again. At the
|
|
205
|
+
* first question, the command list.
|
|
206
|
+
*/
|
|
184
207
|
const back = (): void => {
|
|
185
208
|
// `null` is "never mind": whatever the preview did gets undone by whoever
|
|
186
209
|
// did it, because only the command knows what it changed.
|
|
187
210
|
pending?.arg.preview?.(null);
|
|
211
|
+
const previous = pending ? previousOf(pending) : undefined;
|
|
212
|
+
if (pending && previous) {
|
|
213
|
+
const { [previous.name]: _withdrawn, ...rest } = pending.collected;
|
|
214
|
+
drillInto(pending.command, previous, rest);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
188
217
|
setPending(null);
|
|
189
218
|
setChoices([]);
|
|
190
219
|
setQuery('');
|
|
@@ -228,13 +257,20 @@ export const CommandPalette = defineComponent<CommandPaletteProps>('CommandPalet
|
|
|
228
257
|
onRun?.(id, args);
|
|
229
258
|
};
|
|
230
259
|
|
|
231
|
-
/**
|
|
260
|
+
/**
|
|
261
|
+
* Ask about one command. `choices` may be a function, and may be async.
|
|
262
|
+
*
|
|
263
|
+
* A resolver is handed what was answered before this argument - and an
|
|
264
|
+
* argument that was not asked because its `default` stood was answered by
|
|
265
|
+
* that default. Left out, "the models of the chosen provider" would see no
|
|
266
|
+
* provider exactly when there was only one to choose from.
|
|
267
|
+
*/
|
|
232
268
|
const drillInto = (
|
|
233
269
|
command: CommandDefinition,
|
|
234
270
|
arg: ArgSpec,
|
|
235
271
|
collected: Record<string, unknown> = {},
|
|
236
272
|
): void => {
|
|
237
|
-
const resolved = typeof arg.choices === 'function' ? arg.choices() : arg.choices ?? [];
|
|
273
|
+
const resolved = typeof arg.choices === 'function' ? arg.choices(answeredBefore(command, arg, collected)) : arg.choices ?? [];
|
|
238
274
|
setPending({ command, arg, collected });
|
|
239
275
|
setQuery('');
|
|
240
276
|
|
|
@@ -345,8 +381,10 @@ export const CommandPalette = defineComponent<CommandPaletteProps>('CommandPalet
|
|
|
345
381
|
// anybody chose to be at: the caller drilled in on their behalf, and
|
|
346
382
|
// the level underneath is a list of one. Backing out to it reads as
|
|
347
383
|
// "escape did nothing", and the second escape - the one that would
|
|
348
|
-
// close it - is spent leaving a screen nobody asked to see.
|
|
349
|
-
|
|
384
|
+
// close it - is spent leaving a screen nobody asked to see. A question
|
|
385
|
+
// that came after one is different: there the level underneath is the
|
|
386
|
+
// earlier question, which somebody did choose to be at.
|
|
387
|
+
if (pending && (!openAt || previousOf(pending) !== undefined)) { back(); return true; }
|
|
350
388
|
// Whatever a highlighted choice previewed has to be put back on the
|
|
351
389
|
// way out, and `null` is how the command is told to undo it - only it
|
|
352
390
|
// knows what it changed. `back()` did that, so closing instead of
|
|
@@ -356,7 +394,7 @@ export const CommandPalette = defineComponent<CommandPaletteProps>('CommandPalet
|
|
|
356
394
|
onClose?.();
|
|
357
395
|
return true;
|
|
358
396
|
}
|
|
359
|
-
if (event.name === 'left' && pending && query === '' && !openAt) { back(); return true; }
|
|
397
|
+
if (event.name === 'left' && pending && query === '' && (!openAt || previousOf(pending) !== undefined)) { back(); return true; }
|
|
360
398
|
// Wrapping, both ways. A list you can walk off the end of makes you
|
|
361
399
|
// check where you are before every press; one that comes round means the
|
|
362
400
|
// last item is one key from the first.
|
|
@@ -384,6 +422,8 @@ export const CommandPalette = defineComponent<CommandPaletteProps>('CommandPalet
|
|
|
384
422
|
// there - and a sentence about what a mode does is exactly the thing a
|
|
385
423
|
// person needs whole. So the line under the list follows the highlight: the
|
|
386
424
|
// choice's description while there is one, the question's otherwise.
|
|
425
|
+
// It is the one line in the panel that wraps rather than truncates,
|
|
426
|
+
// because it is the one place a sentence can be read whole.
|
|
387
427
|
const chosen = pending ? offered[index] : undefined;
|
|
388
428
|
const detail = pending
|
|
389
429
|
? chosen?.description
|
|
@@ -406,6 +446,13 @@ export const CommandPalette = defineComponent<CommandPaletteProps>('CommandPalet
|
|
|
406
446
|
const content = Math.max(
|
|
407
447
|
...items.map((item) => rowWidth(item, descriptions)),
|
|
408
448
|
...(pending ? [stringWidth(pending.command.title) + 12] : [stringWidth(placeholder ?? '') + 4]),
|
|
449
|
+
// The sentences the line under the list may show, so a question with
|
|
450
|
+
// three one-word answers is not a panel 28 wide wrapping a sentence
|
|
451
|
+
// three times under them. Capped like everything else by `maxWidth`.
|
|
452
|
+
...(pending
|
|
453
|
+
? [pending.arg.description ?? '', ...offered.map((choice) => choice.description ?? '')]
|
|
454
|
+
.map((sentence) => (sentence === '' ? 0 : stringWidth(sentence) + 6))
|
|
455
|
+
: []),
|
|
409
456
|
);
|
|
410
457
|
|
|
411
458
|
return h('box', {
|
|
@@ -452,10 +499,19 @@ export const CommandPalette = defineComponent<CommandPaletteProps>('CommandPalet
|
|
|
452
499
|
const command = matches[index];
|
|
453
500
|
if (command && argumentOf(command)) choose();
|
|
454
501
|
},
|
|
502
|
+
// A typed answer's field is the answer, so its placeholder is the
|
|
503
|
+
// question. A list's field is a filter, and the question goes where
|
|
504
|
+
// there is room to say it once: here, while the line under the list
|
|
505
|
+
// is busy saying what each answer means; under the list, whole and
|
|
506
|
+
// wrapped, when the answers have nothing to say for themselves and
|
|
507
|
+
// that line would otherwise repeat this one, cut to whatever width
|
|
508
|
+
// three short answers gave the panel.
|
|
455
509
|
placeholder: pending
|
|
456
|
-
? (pending.arg.
|
|
457
|
-
? `${pending.command.title}${theme.glyphs.ellipsis}`
|
|
458
|
-
:
|
|
510
|
+
? (pending.arg.choices === undefined
|
|
511
|
+
? pending.arg.description ?? `${pending.command.title}${theme.glyphs.ellipsis}`
|
|
512
|
+
: offered.some((choice) => choice.description) && pending.arg.description
|
|
513
|
+
? pending.arg.description
|
|
514
|
+
: `Choose ${pending.command.title.toLowerCase()}${theme.glyphs.ellipsis}`)
|
|
459
515
|
: (placeholder ?? `Type a command${theme.glyphs.ellipsis}`),
|
|
460
516
|
search: true,
|
|
461
517
|
autoFocus: true,
|
|
@@ -479,8 +535,8 @@ export const CommandPalette = defineComponent<CommandPaletteProps>('CommandPalet
|
|
|
479
535
|
h('box', { height: 1, fill: theme.borderChars().top, fg: 'borderSubtle' }),
|
|
480
536
|
// What the highlighted row actually is, and how to move around. A palette
|
|
481
537
|
// that shows only titles makes you run something to find out what it does.
|
|
482
|
-
h('box', { direction: 'row', gap: 1 },
|
|
483
|
-
h('text', { content: detail, fg: 'muted', flex: 1,
|
|
538
|
+
h('box', { direction: 'row', gap: 1, align: 'start' },
|
|
539
|
+
h('text', { content: detail, fg: 'muted', flex: 1, wrap: 'word' }),
|
|
484
540
|
h('text', { content: `${rows.length}`, fg: 'subtle' })),
|
|
485
541
|
h('box', { direction: 'row', gap: 1 },
|
|
486
542
|
h('text', {
|
|
@@ -523,6 +579,26 @@ export function argumentOf(
|
|
|
523
579
|
);
|
|
524
580
|
}
|
|
525
581
|
|
|
582
|
+
/**
|
|
583
|
+
* What a `choices` resolver is told: the answers given, under the defaults
|
|
584
|
+
* of the arguments before this one that stood in for a question.
|
|
585
|
+
*
|
|
586
|
+
* Only the ones ahead of it in the declaration. An argument after this one
|
|
587
|
+
* has not been reached, and its default is not yet an answer to anything.
|
|
588
|
+
*/
|
|
589
|
+
function answeredBefore(
|
|
590
|
+
command: CommandDefinition,
|
|
591
|
+
arg: ArgSpec,
|
|
592
|
+
collected: Record<string, unknown>,
|
|
593
|
+
): Record<string, unknown> {
|
|
594
|
+
const answered: Record<string, unknown> = {};
|
|
595
|
+
for (const spec of command.args ?? []) {
|
|
596
|
+
if (spec === arg) break;
|
|
597
|
+
if (spec.default !== undefined) answered[spec.name] = spec.default;
|
|
598
|
+
}
|
|
599
|
+
return { ...answered, ...collected };
|
|
600
|
+
}
|
|
601
|
+
|
|
526
602
|
/** The short form and the long one, as one shape. */
|
|
527
603
|
function asChoice(choice: string | ArgChoice): ArgChoice {
|
|
528
604
|
return typeof choice === 'string' ? { value: choice } : choice;
|