@neosh/api 0.4.0 → 0.4.2
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/package.json +1 -1
- package/src/generated/ApiCall.ts +98 -0
- package/src/generated/ApiOk.ts +2 -0
- package/src/generated/ChecksState.ts +14 -0
- package/src/generated/FloatConfig.ts +33 -0
- package/src/generated/ModelInfo.ts +16 -0
- package/src/generated/PluginEvent.ts +1 -0
- package/src/generated/PullRequest.ts +30 -0
- package/src/generated/PullState.ts +11 -0
- package/src/generated/ScrollAmount.ts +16 -0
- package/src/generated/TabInfo.ts +16 -0
- package/src/generated/UiEvent.ts +18 -1
- package/src/generated/UpdateStatus.ts +21 -1
- package/src/index.ts +314 -10
- package/src/ui.ts +276 -55
package/src/ui.ts
CHANGED
|
@@ -513,7 +513,11 @@ export async function picker<T>(
|
|
|
513
513
|
items: PickerItem<T>[],
|
|
514
514
|
opts: PickerOptions<T> = {},
|
|
515
515
|
): Promise<T | null> {
|
|
516
|
-
|
|
516
|
+
// What the list would like. What it *gets* is settled by `measure` below, once the frontend has
|
|
517
|
+
// said how much of the screen there was — a picker asking for twelve rows on a sixteen-row
|
|
518
|
+
// terminal is not a twelve-row picker, and every number this widget computes from `rows` was
|
|
519
|
+
// wrong by the difference until it asked.
|
|
520
|
+
let rows = Math.max(1, opts.height ?? 12);
|
|
517
521
|
const width = Math.max(20, opts.width ?? 64);
|
|
518
522
|
const filtering = opts.filter !== false;
|
|
519
523
|
|
|
@@ -530,13 +534,13 @@ export async function picker<T>(
|
|
|
530
534
|
anchor: opts.anchor ?? { kind: "screen" },
|
|
531
535
|
offset: opts.offset,
|
|
532
536
|
width: { kind: "fixed", n: width },
|
|
533
|
-
// Exactly the rows that get drawn: the list, plus a filter line when there is one
|
|
534
|
-
// when there is one
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
},
|
|
537
|
+
// Exactly the rows that get drawn: the list, plus a filter line when there is one and a title
|
|
538
|
+
// when there is one. The key strip is not among them any more — it is on the bottom border,
|
|
539
|
+
// where it costs no row and cannot be the row that does not fit. It was a row, and on a
|
|
540
|
+
// sixteen-row terminal it was reliably the one clipped: the strip that says how to get out.
|
|
541
|
+
height: { kind: "fixed", n: rows + (filtering ? 1 : 0) + (opts.title ? 1 : 0) },
|
|
539
542
|
border: "rounded",
|
|
543
|
+
footer: hints === "" ? undefined : ` ${hints} `,
|
|
540
544
|
focusable: true,
|
|
541
545
|
closeOnBlur: true,
|
|
542
546
|
// Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
|
|
@@ -625,7 +629,7 @@ export async function picker<T>(
|
|
|
625
629
|
if (closed) return;
|
|
626
630
|
// Keep the cursor on screen without recentring on every keystroke.
|
|
627
631
|
if (cursor < top) top = cursor;
|
|
628
|
-
if (cursor >= top +
|
|
632
|
+
if (cursor >= top + rows) top = cursor - rows + 1;
|
|
629
633
|
|
|
630
634
|
const lines: string[] = [];
|
|
631
635
|
if (opts.title) lines.push(opts.title);
|
|
@@ -636,7 +640,7 @@ export async function picker<T>(
|
|
|
636
640
|
// dock. Measuring against anything else puts the last visible character on both lines — the
|
|
637
641
|
// continuation says `/ finds` under a row that already ended in `/`.
|
|
638
642
|
const inner = Math.max(8, width);
|
|
639
|
-
let window = visible.slice(top, top +
|
|
643
|
+
let window = visible.slice(top, top + rows);
|
|
640
644
|
// One gutter for the whole list, or none at all. Giving it only to the rows that asked for an
|
|
641
645
|
// icon would step every other label one column left, which reads as a list that cannot decide
|
|
642
646
|
// where its left margin is.
|
|
@@ -672,7 +676,7 @@ export async function picker<T>(
|
|
|
672
676
|
// float's height: the window was sized for `height` lines and growing past it would push the
|
|
673
677
|
// key strip off the bottom, which is the row that says how to get out.
|
|
674
678
|
if (rest.length > 0) {
|
|
675
|
-
const room = Math.max(1,
|
|
679
|
+
const room = Math.max(1, rows - rest.length);
|
|
676
680
|
if (cursor < top) top = cursor;
|
|
677
681
|
if (cursor >= top + room) top = cursor - room + 1;
|
|
678
682
|
window = visible.slice(top, top + room);
|
|
@@ -691,23 +695,6 @@ export async function picker<T>(
|
|
|
691
695
|
if (!isCursor) continue;
|
|
692
696
|
for (const line of rest) lines.push(` ${line}`);
|
|
693
697
|
}
|
|
694
|
-
// Pushed onto the last row rather than floated: the float is sized for it, and a strip that
|
|
695
|
-
// moved up as the list shortened would be a strip you have to look for.
|
|
696
|
-
//
|
|
697
|
-
// The placeholder takes a row of the list's own space, so an empty list has used one of the
|
|
698
|
-
// `height` rows and not none. Counting it as none put the strip one row past the bottom of a
|
|
699
|
-
// float sized for exactly `height`, where it was silently clipped — leaving the empty state,
|
|
700
|
-
// the one state where you most want to be told what the keys do, as the only one with no keys
|
|
701
|
-
// on it.
|
|
702
|
-
// Lines, not rows: the row under the cursor is more than one of them when it has unfolded, and
|
|
703
|
-
// counting rows here would leave the strip that many lines low — off the bottom of a float
|
|
704
|
-
// sized for exactly `height`.
|
|
705
|
-
const listRows = Math.max(1, lines.length - firstListLine);
|
|
706
|
-
const hintLine = hints === "" ? -1 : lines.length + Math.max(0, height - listRows);
|
|
707
|
-
if (hintLine >= 0) {
|
|
708
|
-
while (lines.length < hintLine) lines.push("");
|
|
709
|
-
lines.push(` ${hints}`);
|
|
710
|
-
}
|
|
711
698
|
// Marks are collected against their row and handed over with the text, in one call. Set one at
|
|
712
699
|
// a time they were a sequence the frontend could draw the middle of: the moment the clear had
|
|
713
700
|
// landed and the marks had not, every row drew unmarked — in `Normal`, which is near-white.
|
|
@@ -716,9 +703,6 @@ export async function picker<T>(
|
|
|
716
703
|
drawn[line]?.marks!.push({ col, opts: o });
|
|
717
704
|
};
|
|
718
705
|
|
|
719
|
-
if (hintLine >= 0) {
|
|
720
|
-
mark(hintLine, 0, { hlGroup: "Sidebar.Dim", endCol: byteLength(lines[hintLine] ?? "") });
|
|
721
|
-
}
|
|
722
706
|
const listTop = (opts.title ? 1 : 0) + (filtering ? 1 : 0);
|
|
723
707
|
for (let i = 0; i < window.length; i++) {
|
|
724
708
|
const row = window[i]!;
|
|
@@ -869,10 +853,10 @@ export async function picker<T>(
|
|
|
869
853
|
cursor = Math.max(0, cursor - 1);
|
|
870
854
|
break;
|
|
871
855
|
case "page_down":
|
|
872
|
-
cursor = Math.min(last(), cursor +
|
|
856
|
+
cursor = Math.min(last(), cursor + rows);
|
|
873
857
|
break;
|
|
874
858
|
case "page_up":
|
|
875
|
-
cursor = Math.max(0, cursor -
|
|
859
|
+
cursor = Math.max(0, cursor - rows);
|
|
876
860
|
break;
|
|
877
861
|
case "first":
|
|
878
862
|
cursor = 0;
|
|
@@ -923,6 +907,33 @@ export async function picker<T>(
|
|
|
923
907
|
}, { desc: "picker key" }),
|
|
924
908
|
);
|
|
925
909
|
|
|
910
|
+
/**
|
|
911
|
+
* How many list rows there turned out to be room for.
|
|
912
|
+
*
|
|
913
|
+
* A float is *asked* for a height and given whatever the screen has, so `opts.height` is a wish
|
|
914
|
+
* and this is the answer. Everything above counts in `rows` — where the window starts, what a
|
|
915
|
+
* page step is, how far the cursor may go before the list scrolls — and while that number was the
|
|
916
|
+
* wish, a picker on a short terminal scrolled to keep the cursor on a row that was not drawn.
|
|
917
|
+
*
|
|
918
|
+
* Read once when the panel opens and again whenever the frontend says this window changed size,
|
|
919
|
+
* rather than on every keystroke: a round trip per character typed is latency on the one path in
|
|
920
|
+
* this widget that has to feel immediate.
|
|
921
|
+
*/
|
|
922
|
+
async function measure(): Promise<void> {
|
|
923
|
+
if (closed) return;
|
|
924
|
+
const v = await neosh.win.viewport(win).catch(() => null);
|
|
925
|
+
if (!v) return;
|
|
926
|
+
const next = Math.max(1, v.height - (filtering ? 1 : 0) - (opts.title ? 1 : 0));
|
|
927
|
+
if (next === rows) return;
|
|
928
|
+
rows = next;
|
|
929
|
+
await render();
|
|
930
|
+
}
|
|
931
|
+
disposers.push(
|
|
932
|
+
neosh.event.on("neosh.viewport", (e) => {
|
|
933
|
+
if ((e as { win?: WindowId }).win === win) void measure();
|
|
934
|
+
}),
|
|
935
|
+
);
|
|
936
|
+
|
|
926
937
|
await neosh.focus.push(win);
|
|
927
938
|
disposers.push(await neosh.keymap.capture(win, command));
|
|
928
939
|
// `<C-c>` is bound globally to `interrupt`, which would arm "press again to quit" while a picker
|
|
@@ -935,6 +946,7 @@ export async function picker<T>(
|
|
|
935
946
|
if (typeof opts.query === "function") query = opts.query();
|
|
936
947
|
await refetch();
|
|
937
948
|
await render();
|
|
949
|
+
await measure();
|
|
938
950
|
if (opts.onHighlight) {
|
|
939
951
|
const row = visible[cursor];
|
|
940
952
|
if (row) opts.onHighlight(row.item, row.index);
|
|
@@ -957,6 +969,145 @@ function dropSegment(text: string): string {
|
|
|
957
969
|
return at < 0 ? "" : trimmed.slice(0, at + 1);
|
|
958
970
|
}
|
|
959
971
|
|
|
972
|
+
// ---------------------------------------------------------------------------
|
|
973
|
+
// Reading something too long for the screen
|
|
974
|
+
// ---------------------------------------------------------------------------
|
|
975
|
+
|
|
976
|
+
export interface PagerOptions {
|
|
977
|
+
/** On the top border. */
|
|
978
|
+
title?: string;
|
|
979
|
+
/**
|
|
980
|
+
* On the bottom border, replacing the default legend.
|
|
981
|
+
*
|
|
982
|
+
* The default already names the scroll keys and the way out, read out of nothing — they are fixed
|
|
983
|
+
* defaults on `neosh.scroll` and `^Z` is where a rebinding shows up. Pass your own only to add a
|
|
984
|
+
* verb of your own to it.
|
|
985
|
+
*/
|
|
986
|
+
footer?: string;
|
|
987
|
+
/** Columns of content. The border is drawn outside it. */
|
|
988
|
+
width?: number;
|
|
989
|
+
/**
|
|
990
|
+
* The most rows of content to ask for. Fewer if the content is shorter, fewer still if the screen
|
|
991
|
+
* is — which is the case this whole widget is about, and is why it is a ceiling and not a size.
|
|
992
|
+
*/
|
|
993
|
+
height?: number;
|
|
994
|
+
/**
|
|
995
|
+
* The buffer kind, so a third party can bind keys in *this* panel and find it with `win.ofKind`.
|
|
996
|
+
*
|
|
997
|
+
* Defaults to {@link KIND_PAGER}, which every pager in the workspace shares. Pass your own when
|
|
998
|
+
* the panel is a thing in its own right — a diff, a status — and somebody might reasonably want
|
|
999
|
+
* keys on that and not on every pager there is.
|
|
1000
|
+
*/
|
|
1001
|
+
kind?: string;
|
|
1002
|
+
/** Marks to lay over the rows, in the same shape {@link Neosh.buf.render} takes. */
|
|
1003
|
+
marks?: DrawnRow["marks"][];
|
|
1004
|
+
z?: number;
|
|
1005
|
+
/**
|
|
1006
|
+
* Handed this panel's own close, as soon as it is on screen.
|
|
1007
|
+
*
|
|
1008
|
+
* For the caller that has to be able to put it away itself — a key that toggles, a panel that
|
|
1009
|
+
* closes when the thing it is about goes. Without it the only way out is a key, and a `^Z` that
|
|
1010
|
+
* opens a sheet and then opens a second one behind it is the bug this exists to prevent.
|
|
1011
|
+
*/
|
|
1012
|
+
onOpen?: (close: () => Promise<void>) => void;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** The kind a pager's buffer gets when the caller does not name one. */
|
|
1016
|
+
export const KIND_PAGER = "neosh.pager";
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Show rows you can read but not edit, in a float that scrolls when it does not fit.
|
|
1020
|
+
*
|
|
1021
|
+
* The shape almost every read-only panel in the workspace turned out to want, and the thing each of
|
|
1022
|
+
* them was missing: a float is given whatever height the screen has, so a status of sixty changed
|
|
1023
|
+
* files or a diff of four hundred lines was thirty rows on screen and the rest drawn nowhere, with
|
|
1024
|
+
* no key pointed at it and nothing to say it was there. Every one of them had independently written
|
|
1025
|
+
* "open a float, put lines in it" and independently stopped before the part where the terminal is
|
|
1026
|
+
* short.
|
|
1027
|
+
*
|
|
1028
|
+
* What you get: the reader's motions (`j`/`k`, `^D`/`^U`, `^F`/`^B`, `gg`/`G`, arrows, paging keys)
|
|
1029
|
+
* and the wheel, because the float sets `scroll` and those are ordinary bindings on
|
|
1030
|
+
* `neosh.scroll` — so `^Z` lists them and `init.ts` moves them. A bar down the right border while
|
|
1031
|
+
* anything is hidden. A legend on the bottom border, which cannot scroll away and is not the first
|
|
1032
|
+
* row clipped. `<Esc>`, `q`, `<CR>` and `^C` close it.
|
|
1033
|
+
*
|
|
1034
|
+
* Resolves when it closes.
|
|
1035
|
+
*/
|
|
1036
|
+
export async function pager(
|
|
1037
|
+
neosh: Neosh,
|
|
1038
|
+
lines: string[],
|
|
1039
|
+
opts: PagerOptions = {},
|
|
1040
|
+
): Promise<void> {
|
|
1041
|
+
const buf = await neosh.buf.create({
|
|
1042
|
+
name: `[${opts.title?.trim() || "pager"}]`,
|
|
1043
|
+
scratch: true,
|
|
1044
|
+
kind: opts.kind ?? KIND_PAGER,
|
|
1045
|
+
});
|
|
1046
|
+
const ns = await neosh.ns.create("neosh.ui.pager");
|
|
1047
|
+
await neosh.buf.render(
|
|
1048
|
+
buf,
|
|
1049
|
+
ns,
|
|
1050
|
+
0,
|
|
1051
|
+
-1,
|
|
1052
|
+
lines.map((text, i) => ({ text, marks: opts.marks?.[i] ?? [] })),
|
|
1053
|
+
);
|
|
1054
|
+
|
|
1055
|
+
const win = await neosh.float.open(buf, {
|
|
1056
|
+
anchor: { kind: "screen" },
|
|
1057
|
+
width: opts.width === undefined ? { kind: "auto" } : { kind: "fixed", n: opts.width },
|
|
1058
|
+
// A ceiling, never a size. `fixed` here would ask for rows the screen does not have and get
|
|
1059
|
+
// clipped to them anyway — the difference being that `max` also shrinks to fit content that is
|
|
1060
|
+
// shorter, so a two-line status is a two-line panel rather than a mostly empty box.
|
|
1061
|
+
height: { kind: "max", n: opts.height ?? 30 },
|
|
1062
|
+
border: "rounded",
|
|
1063
|
+
title: opts.title,
|
|
1064
|
+
footer: opts.footer ?? " j k move ^D ^U half gg G ends esc close ",
|
|
1065
|
+
focusable: true,
|
|
1066
|
+
closeOnBlur: true,
|
|
1067
|
+
scroll: true,
|
|
1068
|
+
z: opts.z ?? 200,
|
|
1069
|
+
});
|
|
1070
|
+
await neosh.focus.push(win);
|
|
1071
|
+
|
|
1072
|
+
let settle: () => void = () => {};
|
|
1073
|
+
const done = new Promise<void>((resolve) => {
|
|
1074
|
+
settle = resolve;
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
const command = `neosh.ui.pager.key.${++pickerSeq}`;
|
|
1078
|
+
const disposers: Disposable[] = [];
|
|
1079
|
+
let closed = false;
|
|
1080
|
+
const close = async () => {
|
|
1081
|
+
if (closed) return;
|
|
1082
|
+
closed = true;
|
|
1083
|
+
for (const d of disposers) d.dispose();
|
|
1084
|
+
await neosh.focus.pop().catch(() => {});
|
|
1085
|
+
await neosh.win.close(win).catch(() => {});
|
|
1086
|
+
settle();
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
disposers.push(
|
|
1090
|
+
await neosh.cmd.register(command, (_args, key) => {
|
|
1091
|
+
// Only what nothing else claimed reaches here, and the scroll keys are claimed — they resolve
|
|
1092
|
+
// at `neosh.scroll`, which is nearer than this capture is late. So what is left is dismissal.
|
|
1093
|
+
// Which is *named*: a panel you scroll is a panel you spend time in, and "any key closes it"
|
|
1094
|
+
// turns a mistyped letter into losing your place.
|
|
1095
|
+
if (!key || closesPager(key)) void close();
|
|
1096
|
+
}, { desc: "close this panel" }),
|
|
1097
|
+
);
|
|
1098
|
+
disposers.push(await neosh.keymap.capture(win, command));
|
|
1099
|
+
opts.onOpen?.(close);
|
|
1100
|
+
return done;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** `<Esc>`, `q`, `<CR>` and `^C` — the four keys every panel in this workspace closes on. */
|
|
1104
|
+
function closesPager(key: KeyContext): boolean {
|
|
1105
|
+
const { code, mods } = key.key;
|
|
1106
|
+
if (code.kind === "esc" || code.kind === "enter") return true;
|
|
1107
|
+
if (code.kind !== "char") return false;
|
|
1108
|
+
return mods.ctrl ? code.c.toLowerCase() === "c" : code.c.toLowerCase() === "q";
|
|
1109
|
+
}
|
|
1110
|
+
|
|
960
1111
|
// ---------------------------------------------------------------------------
|
|
961
1112
|
// Derived widgets
|
|
962
1113
|
// ---------------------------------------------------------------------------
|
|
@@ -1013,6 +1164,18 @@ export async function confirm(
|
|
|
1013
1164
|
`y ${yes.toLowerCase()} n ${no.toLowerCase()} ↵ choose esc cancel`,
|
|
1014
1165
|
width - 2,
|
|
1015
1166
|
);
|
|
1167
|
+
/**
|
|
1168
|
+
* How many of the detail lines there is room for.
|
|
1169
|
+
*
|
|
1170
|
+
* Settled once the frontend has said how tall this dialog turned out to be — a dialog is asked
|
|
1171
|
+
* for a height and given what the screen has, and the rows it loses are the ones at the bottom.
|
|
1172
|
+
* Which for a question with the answers under the detail meant the answers, so a long `detail` on
|
|
1173
|
+
* a short terminal was a dialog you could not see either option in.
|
|
1174
|
+
*
|
|
1175
|
+
* The detail is what gives way, because the detail is the part you can do without: the question
|
|
1176
|
+
* and the two answers are the dialog. What went is said rather than silently dropped.
|
|
1177
|
+
*/
|
|
1178
|
+
let room = detail.length;
|
|
1016
1179
|
|
|
1017
1180
|
// On the answer that changes nothing, when the other one cannot be taken back.
|
|
1018
1181
|
let cursor = opts.dangerous ? 1 : 0;
|
|
@@ -1025,14 +1188,17 @@ export async function confirm(
|
|
|
1025
1188
|
|
|
1026
1189
|
/** Where each part of the dialog starts, so the marks do not have to count rows twice. */
|
|
1027
1190
|
const detailAt = asked.length + 1;
|
|
1028
|
-
|
|
1029
|
-
const
|
|
1191
|
+
/** Every row but the detail: the question, the blanks around it, and the two answers. */
|
|
1192
|
+
const fixedRows = asked.length + 1 + answers.length;
|
|
1030
1193
|
|
|
1031
1194
|
const win = await neosh.float.open(buf, {
|
|
1032
1195
|
anchor: { kind: "screen" },
|
|
1033
1196
|
width: { kind: "fixed", n: width },
|
|
1034
|
-
height: { kind: "fixed", n:
|
|
1197
|
+
height: { kind: "fixed", n: fixedRows + (detail.length === 0 ? 0 : detail.length + 1) },
|
|
1035
1198
|
border: "rounded",
|
|
1199
|
+
// The key strip, on the edge. It was the last row of the buffer, which made it both the row a
|
|
1200
|
+
// long `detail` pushed off the bottom and a row of height every dialog had to pay for.
|
|
1201
|
+
footer: ` ${strip} `,
|
|
1036
1202
|
focusable: true,
|
|
1037
1203
|
closeOnBlur: true,
|
|
1038
1204
|
// Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
|
|
@@ -1048,15 +1214,20 @@ export async function confirm(
|
|
|
1048
1214
|
});
|
|
1049
1215
|
|
|
1050
1216
|
const render = async () => {
|
|
1217
|
+
const shownDetail = room >= detail.length
|
|
1218
|
+
? detail
|
|
1219
|
+
: // One row of the budget goes to saying what is not being shown, so a dialog that has had to
|
|
1220
|
+
// cut something never looks like a dialog that had nothing more to say.
|
|
1221
|
+
[...detail.slice(0, Math.max(0, room - 1)), `… and ${detail.length - Math.max(0, room - 1)} more lines`];
|
|
1222
|
+
const detailRows = shownDetail.length;
|
|
1223
|
+
const answersAt = detailAt + (detailRows === 0 ? 0 : detailRows + 1);
|
|
1051
1224
|
const lines: string[] = asked.map((l) => ` ${l}`);
|
|
1052
1225
|
lines.push("");
|
|
1053
|
-
if (
|
|
1054
|
-
lines.push(...
|
|
1226
|
+
if (detailRows > 0) {
|
|
1227
|
+
lines.push(...shownDetail.map((l) => ` ${l}`));
|
|
1055
1228
|
lines.push("");
|
|
1056
1229
|
}
|
|
1057
1230
|
answers.forEach((a, i) => lines.push(`${i === cursor ? `${CURSOR_MARKER}` : BLANK_MARKER}${a}`));
|
|
1058
|
-
lines.push("");
|
|
1059
|
-
lines.push(` ${strip}`);
|
|
1060
1231
|
// Text and marks together, in one call: this is redrawn on every keystroke, and a repaint the
|
|
1061
1232
|
// frontend can draw the middle of is one that flashes — unmarked rows draw in `Normal`. It also
|
|
1062
1233
|
// takes care of the older half of the same bug, that a mark whose line was replaced under it
|
|
@@ -1069,7 +1240,7 @@ export async function confirm(
|
|
|
1069
1240
|
for (let i = 0; i < asked.length; i++) {
|
|
1070
1241
|
mark(i, 0, { hlGroup: "Title", endCol: byteLength(lines[i] ?? "") });
|
|
1071
1242
|
}
|
|
1072
|
-
for (let i = 0; i <
|
|
1243
|
+
for (let i = 0; i < detailRows; i++) {
|
|
1073
1244
|
mark(detailAt + i, 0, {
|
|
1074
1245
|
hlGroup: "Comment",
|
|
1075
1246
|
endCol: byteLength(lines[detailAt + i] ?? ""),
|
|
@@ -1090,7 +1261,6 @@ export async function confirm(
|
|
|
1090
1261
|
});
|
|
1091
1262
|
}
|
|
1092
1263
|
}
|
|
1093
|
-
mark(stripAt, 0, { hlGroup: "Sidebar.Dim", endCol: byteLength(lines[stripAt] ?? "") });
|
|
1094
1264
|
await neosh.buf.render(buf, ns, 0, -1, drawn);
|
|
1095
1265
|
// The caret marks the answer, since there is nothing here to type into.
|
|
1096
1266
|
await neosh.win.setCursor(win, answersAt + cursor, 0);
|
|
@@ -1161,6 +1331,23 @@ export async function confirm(
|
|
|
1161
1331
|
disposers.push(await neosh.keymap.capture(win, command));
|
|
1162
1332
|
await bindWidgetKeys(neosh, win, command, keys);
|
|
1163
1333
|
await render();
|
|
1334
|
+
// And then how much of it there was room for. Asked after the first draw, because a window the
|
|
1335
|
+
// frontend has never laid out has no answer to give.
|
|
1336
|
+
const measure = async () => {
|
|
1337
|
+
if (closed) return;
|
|
1338
|
+
const v = await neosh.win.viewport(win).catch(() => null);
|
|
1339
|
+
if (!v) return;
|
|
1340
|
+
const next = Math.max(0, v.height - fixedRows - 1);
|
|
1341
|
+
if (next === room) return;
|
|
1342
|
+
room = next;
|
|
1343
|
+
await render();
|
|
1344
|
+
};
|
|
1345
|
+
disposers.push(
|
|
1346
|
+
neosh.event.on("neosh.viewport", (e) => {
|
|
1347
|
+
if ((e as { win?: WindowId }).win === win) void measure();
|
|
1348
|
+
}),
|
|
1349
|
+
);
|
|
1350
|
+
await measure();
|
|
1164
1351
|
return done;
|
|
1165
1352
|
}
|
|
1166
1353
|
|
|
@@ -1401,6 +1588,9 @@ export async function prompt(
|
|
|
1401
1588
|
width: { kind: "fixed", n: width },
|
|
1402
1589
|
height: { kind: "fixed", n: 2 },
|
|
1403
1590
|
border: "rounded",
|
|
1591
|
+
// A field with no legend on it is a field you guess at. On the border, so it costs neither of
|
|
1592
|
+
// the two rows this has.
|
|
1593
|
+
footer: " ↵ accept ^W a word ^U all esc cancel ",
|
|
1404
1594
|
focusable: true,
|
|
1405
1595
|
closeOnBlur: true,
|
|
1406
1596
|
// Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
|
|
@@ -2224,9 +2414,11 @@ export async function railPicker<G, T>(
|
|
|
2224
2414
|
const win = await neosh.float.open(buf, {
|
|
2225
2415
|
anchor: { kind: "screen" },
|
|
2226
2416
|
width: { kind: "fixed", n: total },
|
|
2227
|
-
// title, filter, rule, body, rule,
|
|
2228
|
-
|
|
2417
|
+
// title, filter, rule, body, rule. The key strip is not among them: it is on the bottom border,
|
|
2418
|
+
// where it costs no row of body and cannot be the row a short terminal clips.
|
|
2419
|
+
height: { kind: "fixed", n: height + 4 },
|
|
2229
2420
|
border: "rounded",
|
|
2421
|
+
footer: ` ${opts.hints ?? "↵ use ⇥ panes ^N/^P move esc close"} `,
|
|
2230
2422
|
focusable: true,
|
|
2231
2423
|
closeOnBlur: true,
|
|
2232
2424
|
// Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
|
|
@@ -2336,8 +2528,21 @@ export async function railPicker<G, T>(
|
|
|
2336
2528
|
all = got;
|
|
2337
2529
|
rebuild();
|
|
2338
2530
|
cursor = Math.max(0, Math.min(opts.itemAt?.(all) ?? 0, Math.max(0, rows.length - 1)));
|
|
2339
|
-
// Land on something
|
|
2340
|
-
|
|
2531
|
+
// Land on something you can actually press `↵` on: not a section header, and not a row the
|
|
2532
|
+
// caller disabled. `movePane` has always stepped over disabled rows, so the only way to be
|
|
2533
|
+
// standing on one was to open here — and the first row of a list is where a picker opens by
|
|
2534
|
+
// default. A pane whose top row is "this needs a newer CLI" then greeted every `↵` with
|
|
2535
|
+
// nothing happening, which reads as the picker being broken rather than the row being refused.
|
|
2536
|
+
const landable = (at: number) => {
|
|
2537
|
+
const row = rows[at];
|
|
2538
|
+
return row?.kind === "item" && !row.item.disabled;
|
|
2539
|
+
};
|
|
2540
|
+
if (!landable(cursor)) {
|
|
2541
|
+
const next = rows.findIndex((_, at) => landable(at));
|
|
2542
|
+
// Every row refused is still a list worth opening — you came to read why. Falling back to
|
|
2543
|
+
// the first item rather than to nothing keeps the detail line of *something* on screen.
|
|
2544
|
+
cursor = next >= 0 ? next : rows.findIndex((r) => r.kind === "item");
|
|
2545
|
+
}
|
|
2341
2546
|
if (cursor < 0) cursor = 0;
|
|
2342
2547
|
paneTop = 0;
|
|
2343
2548
|
};
|
|
@@ -2415,7 +2620,11 @@ export async function railPicker<G, T>(
|
|
|
2415
2620
|
? takeWords(detail, Math.max(0, room))
|
|
2416
2621
|
: [clipToWidth(detail, Math.max(0, room)), ""];
|
|
2417
2622
|
const label = padToWidth(labelHead, labelWidth);
|
|
2418
|
-
|
|
2623
|
+
// One column narrower than the cell it sits in, so there is always a space before the detail.
|
|
2624
|
+
// Clipped to the full width, a badge that happens to fill it runs straight into the sentence
|
|
2625
|
+
// after it and the two read as one word.
|
|
2626
|
+
const badgeCell =
|
|
2627
|
+
badgeWidth === 0 ? "" : padToWidth(clipToWidth(badge, badgeWidth - 1), badgeWidth);
|
|
2419
2628
|
const text = `${head}${label}${badgeCell}${detailHead}`;
|
|
2420
2629
|
|
|
2421
2630
|
const labelAt = byteLength(head);
|
|
@@ -2543,12 +2752,6 @@ export async function railPicker<G, T>(
|
|
|
2543
2752
|
line: lines.length - 1,
|
|
2544
2753
|
mark: { from: 0, to: byteLength(lines[lines.length - 1]!), hl: "Separator" },
|
|
2545
2754
|
});
|
|
2546
|
-
const hints = opts.hints ?? "↵ use ⇥ panes ^N/^P move esc close";
|
|
2547
|
-
lines.push(` ${hints}`);
|
|
2548
|
-
marks.push({
|
|
2549
|
-
line: lines.length - 1,
|
|
2550
|
-
mark: { from: 0, to: byteLength(lines[lines.length - 1]!), hl: "Sidebar.Dim" },
|
|
2551
|
-
});
|
|
2552
2755
|
|
|
2553
2756
|
// The marks were already collected against their rows; they now travel with them. One call
|
|
2554
2757
|
// rather than one per mark, so there is no frame in which the text has arrived and the colour
|
|
@@ -2766,7 +2969,16 @@ export interface ActionItem {
|
|
|
2766
2969
|
/** For the hint strip and `^Z`. */
|
|
2767
2970
|
label: string;
|
|
2768
2971
|
command: string;
|
|
2769
|
-
/**
|
|
2972
|
+
/**
|
|
2973
|
+
* Which rows it applies to — a row kind the panel names, `custom` for contributed rows, or `any`.
|
|
2974
|
+
*
|
|
2975
|
+
* `custom:<section id>` narrows it to the rows of **one** section: `custom:git` is a verb about
|
|
2976
|
+
* the git block and nothing else. Bare `custom` means every contributed row in the panel, which
|
|
2977
|
+
* is right for a verb about contributions in general and wrong for the usual case — two plugins
|
|
2978
|
+
* each with a block in the column and each wanting `<Tab>` on their own rows were, without this,
|
|
2979
|
+
* one key that one of them won and the other silently lost. A panel binds the key once and sends
|
|
2980
|
+
* it to whichever action matches the row the cursor is actually on.
|
|
2981
|
+
*/
|
|
2770
2982
|
on?: string;
|
|
2771
2983
|
}
|
|
2772
2984
|
|
|
@@ -3012,7 +3224,16 @@ export function placeSections<S extends string>(
|
|
|
3012
3224
|
*/
|
|
3013
3225
|
export function sectionRows<T>(
|
|
3014
3226
|
c: Contribution & { item: SectionItem },
|
|
3015
|
-
opts: {
|
|
3227
|
+
opts: {
|
|
3228
|
+
width: number;
|
|
3229
|
+
/**
|
|
3230
|
+
* What a landable contributed row *is*, in the panel's own vocabulary.
|
|
3231
|
+
*
|
|
3232
|
+
* The section's id comes third so a panel can tell one contributor's rows from another's —
|
|
3233
|
+
* which is what {@link ActionItem.on}'s `custom:<id>` form is matched against.
|
|
3234
|
+
*/
|
|
3235
|
+
custom: (command: string, args: string[], section: string) => T;
|
|
3236
|
+
},
|
|
3016
3237
|
): ListRow<T>[] {
|
|
3017
3238
|
const rows: ListRow<T>[] = [];
|
|
3018
3239
|
const contributed = Array.isArray(c.item?.rows) ? c.item.rows : [];
|
|
@@ -3052,7 +3273,7 @@ export function sectionRows<T>(
|
|
|
3052
3273
|
spans: spans.length > 0 ? spans : undefined,
|
|
3053
3274
|
right: typeof r.right?.text === "string" ? { text: `${r.right.text} `, hl: r.right.hl } : undefined,
|
|
3054
3275
|
inert: typeof r.command !== "string",
|
|
3055
|
-
value: typeof r.command === "string" ? opts.custom(r.command, args) : undefined,
|
|
3276
|
+
value: typeof r.command === "string" ? opts.custom(r.command, args, c.id) : undefined,
|
|
3056
3277
|
});
|
|
3057
3278
|
}
|
|
3058
3279
|
return rows;
|