@neosh/api 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +31 -0
- package/src/generated/AccountKind.ts +9 -0
- package/src/generated/Activity.ts +85 -0
- package/src/generated/AgentCommand.ts +18 -0
- package/src/generated/AgentState.ts +6 -0
- package/src/generated/AgentSummary.ts +46 -0
- package/src/generated/Anchor.ts +12 -0
- package/src/generated/Animation.ts +40 -0
- package/src/generated/ApiCall.ts +520 -0
- package/src/generated/ApiError.ts +15 -0
- package/src/generated/ApiOk.ts +110 -0
- package/src/generated/ApiResponse.ts +8 -0
- package/src/generated/AscpMessage.ts +73 -0
- package/src/generated/AttachmentInfo.ts +25 -0
- package/src/generated/Attrs.ts +10 -0
- package/src/generated/AuthRef.ts +14 -0
- package/src/generated/BackgroundTask.ts +23 -0
- package/src/generated/BlockStartKind.ts +8 -0
- package/src/generated/BorderStyle.ts +3 -0
- package/src/generated/BranchInfo.ts +19 -0
- package/src/generated/Brand.ts +31 -0
- package/src/generated/BufferId.ts +6 -0
- package/src/generated/BuildId.ts +24 -0
- package/src/generated/Capability.ts +10 -0
- package/src/generated/ClientMessage.ts +67 -0
- package/src/generated/Color.ts +10 -0
- package/src/generated/CommandEntry.ts +13 -0
- package/src/generated/CommitInfo.ts +10 -0
- package/src/generated/ContentBlock.ts +39 -0
- package/src/generated/Contribution.ts +16 -0
- package/src/generated/CostBasis.ts +6 -0
- package/src/generated/CredentialInfo.ts +30 -0
- package/src/generated/CredentialSource.ts +18 -0
- package/src/generated/CursorMotion.ts +21 -0
- package/src/generated/CursorShape.ts +6 -0
- package/src/generated/DetachReason.ts +5 -0
- package/src/generated/DiffTarget.ts +10 -0
- package/src/generated/Dock.ts +9 -0
- package/src/generated/DriverCommand.ts +20 -0
- package/src/generated/DriverKind.ts +8 -0
- package/src/generated/Extent.ts +20 -0
- package/src/generated/ExtmarkId.ts +6 -0
- package/src/generated/ExtmarkInfo.ts +43 -0
- package/src/generated/ExtmarkOpts.ts +33 -0
- package/src/generated/ExtmarkRender.ts +44 -0
- package/src/generated/FileChange.ts +18 -0
- package/src/generated/FileState.ts +15 -0
- package/src/generated/FloatConfig.ts +63 -0
- package/src/generated/FrameSet.ts +9 -0
- package/src/generated/Gravity.ts +11 -0
- package/src/generated/HighlightDef.ts +13 -0
- package/src/generated/HighlightEntry.ts +14 -0
- package/src/generated/HighlightSpec.ts +19 -0
- package/src/generated/Hint.ts +18 -0
- package/src/generated/HlTarget.ts +10 -0
- package/src/generated/HookInvocation.ts +14 -0
- package/src/generated/HookName.ts +17 -0
- package/src/generated/HookOutcome.ts +14 -0
- package/src/generated/HookPayload.ts +95 -0
- package/src/generated/InputEvent.ts +31 -0
- package/src/generated/InstanceConfig.ts +26 -0
- package/src/generated/InstanceId.ts +6 -0
- package/src/generated/KeyCode.ts +20 -0
- package/src/generated/KeyContext.ts +24 -0
- package/src/generated/KeyMods.ts +8 -0
- package/src/generated/KeyPress.ts +5 -0
- package/src/generated/KeymapEntry.ts +11 -0
- package/src/generated/KeymapScope.ts +20 -0
- package/src/generated/LineDraw.ts +15 -0
- package/src/generated/LineRender.ts +9 -0
- package/src/generated/LinkState.ts +16 -0
- package/src/generated/MarkDraw.ts +40 -0
- package/src/generated/Message.ts +5 -0
- package/src/generated/MessageLevel.ts +3 -0
- package/src/generated/Mode.ts +6 -0
- package/src/generated/ModelCapabilities.ts +14 -0
- package/src/generated/ModelEntry.ts +13 -0
- package/src/generated/ModelId.ts +6 -0
- package/src/generated/ModelInfo.ts +30 -0
- package/src/generated/ModelSelection.ts +15 -0
- package/src/generated/ModelTier.ts +14 -0
- package/src/generated/NamespaceId.ts +6 -0
- package/src/generated/NodeCapabilities.ts +30 -0
- package/src/generated/NodeId.ts +11 -0
- package/src/generated/NodeInfo.ts +24 -0
- package/src/generated/NoticeKind.ts +11 -0
- package/src/generated/Offset.ts +7 -0
- package/src/generated/OnDelete.ts +7 -0
- package/src/generated/OptionChoice.ts +8 -0
- package/src/generated/OptionEntry.ts +23 -0
- package/src/generated/OptionSelection.ts +4 -0
- package/src/generated/OptionSpec.ts +17 -0
- package/src/generated/OptionType.ts +16 -0
- package/src/generated/OptionValue.ts +13 -0
- package/src/generated/PermissionDecision.ts +6 -0
- package/src/generated/PermissionMode.ts +3 -0
- package/src/generated/PermissionOption.ts +21 -0
- package/src/generated/PermissionOptionKind.ts +13 -0
- package/src/generated/PlanState.ts +6 -0
- package/src/generated/PlanStep.ts +7 -0
- package/src/generated/PluginActivation.ts +10 -0
- package/src/generated/PluginEvent.ts +77 -0
- package/src/generated/PluginId.ts +6 -0
- package/src/generated/PluginInbound.ts +10 -0
- package/src/generated/PluginInfo.ts +19 -0
- package/src/generated/PluginManifest.ts +54 -0
- package/src/generated/PluginOutbound.ts +11 -0
- package/src/generated/PluginPermission.ts +18 -0
- package/src/generated/PluginProvides.ts +19 -0
- package/src/generated/PluginRequest.ts +11 -0
- package/src/generated/PluginResponse.ts +10 -0
- package/src/generated/PointInfo.ts +16 -0
- package/src/generated/Pricing.ts +12 -0
- package/src/generated/ProjectKey.ts +21 -0
- package/src/generated/ProviderEmit.ts +11 -0
- package/src/generated/ProviderEvent.ts +32 -0
- package/src/generated/ProviderOptionDescriptor.ts +38 -0
- package/src/generated/ProviderOptionValue.ts +3 -0
- package/src/generated/QuestionAnswer.ts +24 -0
- package/src/generated/QuestionOption.ts +17 -0
- package/src/generated/QuotaCredits.ts +23 -0
- package/src/generated/QuotaSample.ts +19 -0
- package/src/generated/QuotaSeverity.ts +11 -0
- package/src/generated/QuotaSnapshot.ts +34 -0
- package/src/generated/QuotaSource.ts +6 -0
- package/src/generated/QuotaWindow.ts +40 -0
- package/src/generated/Rect.ts +7 -0
- package/src/generated/Refusal.ts +10 -0
- package/src/generated/RemoteProject.ts +19 -0
- package/src/generated/RepoInfo.ts +23 -0
- package/src/generated/RepoStatus.ts +5 -0
- package/src/generated/RequestId.ts +6 -0
- package/src/generated/Role.ts +3 -0
- package/src/generated/RunningTurn.ts +12 -0
- package/src/generated/SelectShape.ts +6 -0
- package/src/generated/ServerMessage.ts +15 -0
- package/src/generated/SessionId.ts +6 -0
- package/src/generated/SessionInfo.ts +171 -0
- package/src/generated/StatusAlign.ts +3 -0
- package/src/generated/StatusSegment.ts +49 -0
- package/src/generated/StopReason.ts +16 -0
- package/src/generated/StreamEvent.ts +26 -0
- package/src/generated/StreamId.ts +6 -0
- package/src/generated/SurfaceCell.ts +22 -0
- package/src/generated/SurfaceId.ts +6 -0
- package/src/generated/SwarmAgent.ts +11 -0
- package/src/generated/SwarmNode.ts +24 -0
- package/src/generated/SwarmStranger.ts +21 -0
- package/src/generated/TaskId.ts +11 -0
- package/src/generated/TaskStatus.ts +11 -0
- package/src/generated/TextEdit.ts +19 -0
- package/src/generated/ToolCall.ts +13 -0
- package/src/generated/ToolCallId.ts +6 -0
- package/src/generated/ToolDef.ts +12 -0
- package/src/generated/ToolInvocation.ts +11 -0
- package/src/generated/ToolResult.ts +3 -0
- package/src/generated/ToolSource.ts +12 -0
- package/src/generated/TurnId.ts +6 -0
- package/src/generated/TurnRequest.ts +56 -0
- package/src/generated/UiEvent.ts +80 -0
- package/src/generated/Usage.ts +15 -0
- package/src/generated/UsageBucket.ts +32 -0
- package/src/generated/UsageHistory.ts +23 -0
- package/src/generated/UsageResolution.ts +6 -0
- package/src/generated/UsageScanSource.ts +24 -0
- package/src/generated/UsageScanStatus.ts +3 -0
- package/src/generated/UserQuestion.ts +34 -0
- package/src/generated/VarScope.ts +18 -0
- package/src/generated/ViewId.ts +16 -0
- package/src/generated/ViewInfo.ts +23 -0
- package/src/generated/Viewport.ts +18 -0
- package/src/generated/VirtChunk.ts +6 -0
- package/src/generated/VirtTextPos.ts +6 -0
- package/src/generated/WindowId.ts +6 -0
- package/src/generated/WindowInfo.ts +22 -0
- package/src/generated/WindowLayout.ts +28 -0
- package/src/generated/WorkspaceStatus.ts +28 -0
- package/src/generated/WorktreeInfo.ts +16 -0
- package/src/globals.d.ts +39 -0
- package/src/index.ts +2961 -0
- package/src/ui.ts +3279 -0
- package/tsconfig.json +17 -0
package/src/ui.ts
ADDED
|
@@ -0,0 +1,3279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@neosh/api/ui` — widgets built on the public API.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here is privileged. Every line uses the same surface a third-party plugin has, which is
|
|
5
|
+
* the point: if a picker could not be written this way, the API would be missing something. The
|
|
6
|
+
* model switcher, the branch picker and the command palette are all this file plus a list.
|
|
7
|
+
*
|
|
8
|
+
* Import as:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { picker, confirm, prompt } from "@neosh/api/ui";
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { byteLength, byteOffsets, clipToWidth, padToWidth, width } from "@neosh/api";
|
|
16
|
+
import type {
|
|
17
|
+
BufferId,
|
|
18
|
+
Contribution,
|
|
19
|
+
Disposable,
|
|
20
|
+
DrawnMark,
|
|
21
|
+
DrawnRow,
|
|
22
|
+
FileChange,
|
|
23
|
+
FileState,
|
|
24
|
+
FloatOptions,
|
|
25
|
+
KeyContext,
|
|
26
|
+
MarkOptions,
|
|
27
|
+
Neosh,
|
|
28
|
+
WindowId,
|
|
29
|
+
} from "@neosh/api";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The buffer kinds the shared widgets publish.
|
|
33
|
+
*
|
|
34
|
+
* A picker used to have no kind, which by the surface rule made it the one panel in the
|
|
35
|
+
* workspace you could only replace and never extend. With one, `keymap.set` at `buf_kind` scope
|
|
36
|
+
* binds inside every picker at once, `win.ofKind` finds the open one, and `win.setHighlights`
|
|
37
|
+
* restyles them all.
|
|
38
|
+
*/
|
|
39
|
+
export const KIND_PICKER = "neosh.picker";
|
|
40
|
+
export const KIND_CONFIRM = "neosh.confirm";
|
|
41
|
+
export const KIND_PROMPT = "neosh.prompt";
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Fuzzy matching
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/** Where a query matched, so the caller can highlight it. */
|
|
48
|
+
export interface Match {
|
|
49
|
+
score: number;
|
|
50
|
+
/**
|
|
51
|
+
* Indices of matched **code points**, ascending — not byte offsets and not UTF-16 indices.
|
|
52
|
+
*
|
|
53
|
+
* Code points because that is the unit a user perceives as "a character"; convert with
|
|
54
|
+
* `byteOffsets` before handing them to `ns.mark`, which speaks bytes.
|
|
55
|
+
*/
|
|
56
|
+
positions: number[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Subsequence match with a bias toward starts of words and runs of adjacent characters.
|
|
61
|
+
*
|
|
62
|
+
* Deliberately simple and deliberately *stable*: a picker that reorders under your fingers as you
|
|
63
|
+
* type one more character is worse than one that ranks imperfectly.
|
|
64
|
+
*/
|
|
65
|
+
export function fuzzy(candidate: string, query: string): Match | null {
|
|
66
|
+
if (query === "") return { score: 0, positions: [] };
|
|
67
|
+
|
|
68
|
+
// Code points, not UTF-16 units: indexing a string directly splits an emoji in half and reports
|
|
69
|
+
// a position no later stage can use.
|
|
70
|
+
const hay = Array.from(candidate.toLowerCase());
|
|
71
|
+
const needle = Array.from(query.toLowerCase());
|
|
72
|
+
const positions: number[] = [];
|
|
73
|
+
let score = 0;
|
|
74
|
+
let from = 0;
|
|
75
|
+
let previous = -2;
|
|
76
|
+
|
|
77
|
+
for (const ch of needle) {
|
|
78
|
+
if (ch === " ") continue; // spaces separate terms rather than needing to match
|
|
79
|
+
const at = hay.indexOf(ch, from);
|
|
80
|
+
if (at < 0) return null;
|
|
81
|
+
positions.push(at);
|
|
82
|
+
// Adjacent characters are worth much more than scattered ones, and a match at a word boundary
|
|
83
|
+
// more still — "gs" should find "git status" ahead of "goals".
|
|
84
|
+
if (at === previous + 1) score += 8;
|
|
85
|
+
if (at === 0 || /[\s/_.\-]/.test(hay[at - 1] ?? "")) score += 6;
|
|
86
|
+
score += 1;
|
|
87
|
+
previous = at;
|
|
88
|
+
from = at + 1;
|
|
89
|
+
}
|
|
90
|
+
// Shorter candidates win ties: an exact short name should beat a long one that contains it.
|
|
91
|
+
score -= Math.floor(hay.length / 32);
|
|
92
|
+
return { score, positions };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Picker
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Widget keys
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What a key means to a widget.
|
|
105
|
+
*
|
|
106
|
+
* Named actions rather than keys, so a binding is a setting: `ui.keys.next` is a space-separated
|
|
107
|
+
* list in Neovim notation, and adding `<C-j>` to it is one line of config rather than a fork of
|
|
108
|
+
* this file.
|
|
109
|
+
*/
|
|
110
|
+
export type WidgetAction =
|
|
111
|
+
| "next"
|
|
112
|
+
| "prev"
|
|
113
|
+
| "page_down"
|
|
114
|
+
| "page_up"
|
|
115
|
+
| "first"
|
|
116
|
+
| "last"
|
|
117
|
+
| "accept"
|
|
118
|
+
| "dismiss"
|
|
119
|
+
| "complete"
|
|
120
|
+
| "clear"
|
|
121
|
+
| "delete_word"
|
|
122
|
+
/** Move to the next pane of a two-pane widget — the provider rail and the list beside it. */
|
|
123
|
+
| "pane_next"
|
|
124
|
+
| "pane_prev";
|
|
125
|
+
|
|
126
|
+
const ACTIONS: WidgetAction[] = [
|
|
127
|
+
"next", "prev", "page_down", "page_up", "first", "last",
|
|
128
|
+
"accept", "dismiss", "complete", "clear", "delete_word",
|
|
129
|
+
"pane_next", "pane_prev",
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
/** One parsed key: a code kind, the character if it is one, and the modifiers that must match. */
|
|
133
|
+
interface KeySpec {
|
|
134
|
+
kind: string;
|
|
135
|
+
c?: string;
|
|
136
|
+
ctrl: boolean;
|
|
137
|
+
alt: boolean;
|
|
138
|
+
shift: boolean;
|
|
139
|
+
/** The notation it came from, so it can be bound window-scoped as well as matched. */
|
|
140
|
+
lhs: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Parse one key in Neovim notation.
|
|
145
|
+
*
|
|
146
|
+
* The same notation `keymap.set` takes, so what a user writes in `ui.keys.next` looks like every
|
|
147
|
+
* other binding they have written. Unparseable entries are dropped rather than thrown: a typo in a
|
|
148
|
+
* setting should cost you that key, not the picker.
|
|
149
|
+
*/
|
|
150
|
+
function parseKey(spec: string): KeySpec | null {
|
|
151
|
+
const trimmed = spec.trim();
|
|
152
|
+
if (trimmed === "") return null;
|
|
153
|
+
if (!trimmed.startsWith("<") || !trimmed.endsWith(">")) {
|
|
154
|
+
return trimmed.length === 1
|
|
155
|
+
? { kind: "char", c: trimmed, ctrl: false, alt: false, shift: false, lhs: trimmed }
|
|
156
|
+
: null;
|
|
157
|
+
}
|
|
158
|
+
let inner = trimmed.slice(1, -1);
|
|
159
|
+
const out: KeySpec = { kind: "", ctrl: false, alt: false, shift: false, lhs: trimmed };
|
|
160
|
+
for (;;) {
|
|
161
|
+
const m = /^([CSAMD])-/i.exec(inner);
|
|
162
|
+
if (!m) break;
|
|
163
|
+
const which = m[1]!.toUpperCase();
|
|
164
|
+
if (which === "C") out.ctrl = true;
|
|
165
|
+
else if (which === "S") out.shift = true;
|
|
166
|
+
else if (which === "A" || which === "M") out.alt = true;
|
|
167
|
+
inner = inner.slice(2);
|
|
168
|
+
}
|
|
169
|
+
const named: Record<string, string> = {
|
|
170
|
+
cr: "enter", enter: "enter", tab: "tab", bs: "backspace", del: "delete", esc: "esc",
|
|
171
|
+
up: "up", down: "down", left: "left", right: "right", home: "home", end: "end",
|
|
172
|
+
pageup: "page_up", pagedown: "page_down", space: "char", lt: "char", insert: "insert",
|
|
173
|
+
};
|
|
174
|
+
const lower = inner.toLowerCase();
|
|
175
|
+
if (lower in named) {
|
|
176
|
+
out.kind = named[lower]!;
|
|
177
|
+
if (lower === "space") out.c = " ";
|
|
178
|
+
if (lower === "lt") out.c = "<";
|
|
179
|
+
// `<S-Tab>` is not tab with a modifier — a terminal sends a wholly different sequence, which
|
|
180
|
+
// arrives as its own code. Written the way everyone writes it, matched the way it arrives.
|
|
181
|
+
if (out.kind === "tab" && out.shift) {
|
|
182
|
+
out.kind = "back_tab";
|
|
183
|
+
out.shift = false;
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
if (inner.length === 1) {
|
|
188
|
+
out.kind = "char";
|
|
189
|
+
out.c = inner;
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function matches(spec: KeySpec, key: KeyContext["key"]): boolean {
|
|
196
|
+
if (key.code.kind !== spec.kind) return false;
|
|
197
|
+
if (spec.kind === "char") {
|
|
198
|
+
const c = key.code.kind === "char" ? key.code.c : "";
|
|
199
|
+
// Case-insensitively for control chords, because a terminal reports `<C-N>` and `<C-n>`
|
|
200
|
+
// differently depending on whether shift happened to be down.
|
|
201
|
+
if (spec.ctrl ? c.toLowerCase() !== (spec.c ?? "").toLowerCase() : c !== spec.c) return false;
|
|
202
|
+
}
|
|
203
|
+
if (key.mods.ctrl !== spec.ctrl || key.mods.alt !== spec.alt) return false;
|
|
204
|
+
// Shift is only required when asked for: a terminal sets it for capital letters and for nothing
|
|
205
|
+
// else consistently, so demanding its absence would break `<C-N>`.
|
|
206
|
+
return !spec.shift || key.mods.shift;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let bindings: Map<WidgetAction, KeySpec[]> | null = null;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Read the key settings once per session, and again whenever one changes.
|
|
213
|
+
*
|
|
214
|
+
* Cached because a picker asks this on every keystroke and an option read is a round trip to the
|
|
215
|
+
* host. Invalidated by the change event rather than by a timer, so a rebinding takes effect on the
|
|
216
|
+
* next key rather than the next minute.
|
|
217
|
+
*/
|
|
218
|
+
async function widgetKeys(neosh: Neosh): Promise<Map<WidgetAction, KeySpec[]>> {
|
|
219
|
+
if (bindings) return bindings;
|
|
220
|
+
const built = new Map<WidgetAction, KeySpec[]>();
|
|
221
|
+
await Promise.all(
|
|
222
|
+
ACTIONS.map(async (action) => {
|
|
223
|
+
const raw = await neosh.opt.get<string>(`ui.keys.${action}`).catch(() => "");
|
|
224
|
+
const specs = (raw ?? "")
|
|
225
|
+
.split(/\s+/)
|
|
226
|
+
.map(parseKey)
|
|
227
|
+
.filter((k): k is KeySpec => k !== null);
|
|
228
|
+
built.set(action, specs);
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
bindings = built;
|
|
232
|
+
return built;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let watching = false;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Start following `ui.keys.*` changes. Idempotent; called by every widget before it opens.
|
|
239
|
+
*
|
|
240
|
+
* The listener is never disposed on purpose — it belongs to the module, not to the widget that
|
|
241
|
+
* happened to open first, and disposing it when that widget closed would leave the cache stale for
|
|
242
|
+
* everyone else.
|
|
243
|
+
*/
|
|
244
|
+
function watchKeys(neosh: Neosh): void {
|
|
245
|
+
if (watching) return;
|
|
246
|
+
watching = true;
|
|
247
|
+
neosh.opt.onChange((e) => {
|
|
248
|
+
if (e.name.startsWith("ui.keys.")) bindings = null;
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* How to write the key bound to an action, the way a person would press it.
|
|
254
|
+
*
|
|
255
|
+
* Read from the setting rather than hard-coded, so a rebinding shows up in the affordance too —
|
|
256
|
+
* the whole point of putting the key on screen is that it is the key that works.
|
|
257
|
+
*/
|
|
258
|
+
function keyLabel(keys: Map<WidgetAction, KeySpec[]>, action: WidgetAction): string {
|
|
259
|
+
const spec = keys.get(action)?.[0];
|
|
260
|
+
if (!spec) return "";
|
|
261
|
+
const pretty: Record<string, string> = {
|
|
262
|
+
"<Tab>": "⇥",
|
|
263
|
+
"<S-Tab>": "⇧⇥",
|
|
264
|
+
"<Left>": "←",
|
|
265
|
+
"<Right>": "→",
|
|
266
|
+
"<Up>": "↑",
|
|
267
|
+
"<Down>": "↓",
|
|
268
|
+
"<CR>": "↵",
|
|
269
|
+
"<Esc>": "esc",
|
|
270
|
+
};
|
|
271
|
+
// `^N`, not `^n`: nobody presses shift to send it, and the capital is how every terminal
|
|
272
|
+
// program has written a chord since curses. The first key of `ui.keys.*` is the one that
|
|
273
|
+
// reaches this — which is why the defaults lead with the chord and keep the arrow behind it.
|
|
274
|
+
return (
|
|
275
|
+
pretty[spec.lhs] ??
|
|
276
|
+
spec.lhs.replace(/^<C-(.)>$/, (_, c: string) => `^${c.toUpperCase()}`).replace(/^<|>$/g, "")
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Which action a key press means, or `null` if it is ordinary input.
|
|
282
|
+
*
|
|
283
|
+
* `only` is the set of actions the asking widget can actually carry out, and it is not an
|
|
284
|
+
* optimisation. Two actions share `<Tab>` on purpose — `complete` in a field with suggestions
|
|
285
|
+
* under it, `pane_next` in a widget with two panes — on the reasoning that no widget has both. It
|
|
286
|
+
* does not have both, but it does *resolve* both: without this filter the first one in `ACTIONS`
|
|
287
|
+
* wins for everybody, and `<Tab>` in the model picker resolved to a completion the picker has no
|
|
288
|
+
* case for, so it silently did nothing at all and the second pane had no way in.
|
|
289
|
+
*/
|
|
290
|
+
function actionFor(
|
|
291
|
+
keys: Map<WidgetAction, KeySpec[]>,
|
|
292
|
+
key: KeyContext["key"],
|
|
293
|
+
only?: readonly WidgetAction[],
|
|
294
|
+
): WidgetAction | null {
|
|
295
|
+
for (const action of only ?? ACTIONS) {
|
|
296
|
+
for (const spec of keys.get(action) ?? []) {
|
|
297
|
+
if (matches(spec, key)) return action;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The key strip a single-pane picker gets unless the caller writes its own.
|
|
305
|
+
*
|
|
306
|
+
* Built from the bindings rather than written out, because the point of putting a key on screen is
|
|
307
|
+
* that it is the key that works — a legend that goes stale the moment somebody rebinds `ui.keys.*`
|
|
308
|
+
* is worse than no legend, since it is believed.
|
|
309
|
+
*/
|
|
310
|
+
function defaultHints(keys: Map<WidgetAction, KeySpec[]>, filtering: boolean): string {
|
|
311
|
+
const parts = [
|
|
312
|
+
`${keyLabel(keys, "accept")} choose`,
|
|
313
|
+
`${keyLabel(keys, "prev")}/${keyLabel(keys, "next")} move`,
|
|
314
|
+
];
|
|
315
|
+
if (filtering) parts.push("type to filter");
|
|
316
|
+
parts.push(`${keyLabel(keys, "dismiss")} close`);
|
|
317
|
+
return parts.join(" ");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** What a two-pane widget answers to. Notably not `complete`, whose key it shares. */
|
|
321
|
+
const RAIL_ACTIONS: readonly WidgetAction[] = [
|
|
322
|
+
"dismiss", "accept", "pane_next", "pane_prev", "next", "prev",
|
|
323
|
+
"page_down", "page_up", "first", "last", "clear", "delete_word",
|
|
324
|
+
];
|
|
325
|
+
|
|
326
|
+
export interface PickerItem<T> {
|
|
327
|
+
/** What the user reads and what the filter matches against. */
|
|
328
|
+
label: string;
|
|
329
|
+
/** Dimmed, right of the label. A path, a description, a model id. */
|
|
330
|
+
detail?: string;
|
|
331
|
+
/** Extra text the filter should match but that is not shown. */
|
|
332
|
+
keywords?: string;
|
|
333
|
+
/**
|
|
334
|
+
* One column of glyph before the label, coloured by `hl`.
|
|
335
|
+
*
|
|
336
|
+
* What it is for is telling *kinds* of row apart at a glance — a branch from a machine from a
|
|
337
|
+
* directory — in a list where the labels alone read as one undifferentiated column. A picker
|
|
338
|
+
* where only some rows have one still aligns: the ones without get the same gutter, so the
|
|
339
|
+
* labels line up and the icons read as a column rather than as ragged punctuation.
|
|
340
|
+
*
|
|
341
|
+
* **One column.** The runtime cannot measure display width — only `neosh-tui` can — so a
|
|
342
|
+
* two-column emoji here shifts that row's label right by one. Highlighting stays correct
|
|
343
|
+
* regardless, because every offset is computed in bytes from the text actually written.
|
|
344
|
+
*/
|
|
345
|
+
icon?: string;
|
|
346
|
+
/**
|
|
347
|
+
* The highlight group for `icon`. A palette name — `Git.Branch`, `Diagnostic.Ok`,
|
|
348
|
+
* `Sidebar.Remote` — never a colour, so a row follows the theme like everything else.
|
|
349
|
+
*/
|
|
350
|
+
hl?: string;
|
|
351
|
+
value: T;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export interface PickerOptions<T> {
|
|
355
|
+
title?: string;
|
|
356
|
+
/** Shown when the list is empty or nothing matches. */
|
|
357
|
+
placeholder?: string;
|
|
358
|
+
/** Start with the cursor on this index. */
|
|
359
|
+
selected?: number;
|
|
360
|
+
/** Rows of list to show, before the title and filter lines. */
|
|
361
|
+
height?: number;
|
|
362
|
+
width?: number;
|
|
363
|
+
/**
|
|
364
|
+
* Called whenever the highlighted row changes. Use for a live preview — an "intelligence" picker
|
|
365
|
+
* that describes each effort level, a branch picker that shows the tip commit.
|
|
366
|
+
*/
|
|
367
|
+
onHighlight?(item: PickerItem<T>, index: number): void;
|
|
368
|
+
/**
|
|
369
|
+
* Where the rows come from, when they depend on what has been typed.
|
|
370
|
+
*
|
|
371
|
+
* With a source, the list is *replaced* on every keystroke instead of fuzzy-filtered — which is
|
|
372
|
+
* what you want when the candidates cannot all be fetched up front: a directory listing, a
|
|
373
|
+
* search, anything that asks something else a question. Without one, `items` is filtered locally.
|
|
374
|
+
*
|
|
375
|
+
* Calls are debounced and the answer is dropped if you have typed again since, so a slow source
|
|
376
|
+
* cannot make the list flicker backwards.
|
|
377
|
+
*/
|
|
378
|
+
source?(query: string): Promise<PickerItem<T>[]>;
|
|
379
|
+
/**
|
|
380
|
+
* What the filter starts with.
|
|
381
|
+
*
|
|
382
|
+
* A function is asked at the last possible moment — once the picker is on screen and holds the
|
|
383
|
+
* keyboard — rather than when it was called. That matters for anything completing a field that
|
|
384
|
+
* is still being typed into: opening this takes several round-trips, every keystroke in that
|
|
385
|
+
* window goes to the field, and a picker seeded with what was there *before* them shows an
|
|
386
|
+
* unfiltered list with the wrong row under the accept key. Typing `/compact` quickly and
|
|
387
|
+
* pressing `↵` ran the first command in the list, which was not `compact`.
|
|
388
|
+
*/
|
|
389
|
+
query?: string | (() => string);
|
|
390
|
+
/**
|
|
391
|
+
* Whether there is a filter line at all. On by default.
|
|
392
|
+
*
|
|
393
|
+
* Off for a list you pick from rather than search — a yes/no question does not have a text field
|
|
394
|
+
* in it, and drawing one invites the reader to type into something that will not answer.
|
|
395
|
+
*/
|
|
396
|
+
filter?: boolean;
|
|
397
|
+
/**
|
|
398
|
+
* What `accept` resolves to when nothing is highlighted, given what was typed.
|
|
399
|
+
*
|
|
400
|
+
* Lets a picker double as a field: a path picker offers completions but must still accept a path
|
|
401
|
+
* you typed in full, which is not one of them.
|
|
402
|
+
*/
|
|
403
|
+
freeform?(query: string): T | null;
|
|
404
|
+
/**
|
|
405
|
+
* The key strip at the foot. One line.
|
|
406
|
+
*
|
|
407
|
+
* Written out by default, from the keys actually bound, so a rebinding shows up here rather than
|
|
408
|
+
* making the row a lie. Pass `""` for a picker with nothing worth saying — a two-row yes/no
|
|
409
|
+
* question is not helped by being told that `↵` chooses.
|
|
410
|
+
*/
|
|
411
|
+
hints?: string;
|
|
412
|
+
/**
|
|
413
|
+
* Where the float goes. The middle of the screen unless you say otherwise.
|
|
414
|
+
*
|
|
415
|
+
* `{ kind: "dock", dock: "bottom" }` with a negative row offset is how a *completion* is placed:
|
|
416
|
+
* against the message field, above it, rather than over the transcript it has nothing to do with.
|
|
417
|
+
*/
|
|
418
|
+
anchor?: FloatOptions["anchor"];
|
|
419
|
+
/** Shifted from the anchor. Negative rows go up. */
|
|
420
|
+
offset?: { row: number; col: number };
|
|
421
|
+
/**
|
|
422
|
+
* How the filter matches.
|
|
423
|
+
*
|
|
424
|
+
* `"fuzzy"` — the default — scores a subsequence match over the label *and* `keywords`, which is
|
|
425
|
+
* what you want in a search box: you are looking for something and half-remember a word from its
|
|
426
|
+
* description.
|
|
427
|
+
*
|
|
428
|
+
* `"name"` matches the start of the label first, then anywhere in it, and never looks at
|
|
429
|
+
* `keywords` at all. That is what a **command menu** needs, and the difference is not cosmetic.
|
|
430
|
+
* Typing `compact` into a fuzzy list of every command matches half of them — `c`…`o`…`m`…`p`…
|
|
431
|
+
* are letters that occur in that order in almost any English sentence, and descriptions are
|
|
432
|
+
* sentences — so the top row is something unrelated and `↵` runs it. A menu whose accept key
|
|
433
|
+
* does an arbitrary thing is worse than one with no matching at all.
|
|
434
|
+
*/
|
|
435
|
+
match?: "fuzzy" | "name";
|
|
436
|
+
/**
|
|
437
|
+
* The filter changed.
|
|
438
|
+
*
|
|
439
|
+
* The hook that lets a picker stay in step with something outside itself — a completion that
|
|
440
|
+
* mirrors what is typed back into the composer, so the field says what you typed and dismissing
|
|
441
|
+
* the list leaves it there.
|
|
442
|
+
*/
|
|
443
|
+
onQuery?(query: string): void;
|
|
444
|
+
/**
|
|
445
|
+
* Keys `onKey` wants that a *binding elsewhere* would otherwise take.
|
|
446
|
+
*
|
|
447
|
+
* The raw capture only receives what no keymap claimed, so a caller reaching for `<C-x>` gets
|
|
448
|
+
* nothing — that key belongs to the composer. Listing it here binds it to this widget for as long
|
|
449
|
+
* as the widget is open, and drops it again with the window.
|
|
450
|
+
*
|
|
451
|
+
* A picker with a filter should only ever ask for chords. A bare letter taken here is a letter the
|
|
452
|
+
* filter can never contain, which is how a list of conversations ends up unable to search for one
|
|
453
|
+
* with an `x` in its name.
|
|
454
|
+
*/
|
|
455
|
+
ownKeys?: readonly string[];
|
|
456
|
+
/**
|
|
457
|
+
* A key the caller wants for itself, checked before the widget's own.
|
|
458
|
+
*
|
|
459
|
+
* Return `"close"` to dismiss, `"reload"` to re-read the rows, `"handled"` to redraw, and nothing
|
|
460
|
+
* at all to let the widget have the key. This is what puts a verb on a row — unarchive, delete,
|
|
461
|
+
* rename — without a second modal in front of the list you were reading.
|
|
462
|
+
*
|
|
463
|
+
* `"reload"` re-runs `source` when there is one, and otherwise re-ranks `items`: the very array
|
|
464
|
+
* you passed in, so a caller that mutates it in place gets a list that has caught up with what it
|
|
465
|
+
* just did. A row acted on is a row that has to leave, and closing the list and opening it again
|
|
466
|
+
* loses your place in it.
|
|
467
|
+
*/
|
|
468
|
+
onKey?(
|
|
469
|
+
key: KeyContext,
|
|
470
|
+
ctx: { item: T | undefined; query: string },
|
|
471
|
+
): Promise<"handled" | "reload" | "close" | undefined> | "handled" | "reload" | "close" | undefined;
|
|
472
|
+
/**
|
|
473
|
+
* Watch something outside the picker, and reload the rows when it moves.
|
|
474
|
+
*
|
|
475
|
+
* Called once as the picker opens; the `Disposable` it returns is disposed with it. Calling
|
|
476
|
+
* `reload` does what `onKey`'s `"reload"` does — re-run `source`, or re-rank `items`, the very
|
|
477
|
+
* array you passed in — for a change no key caused: a peer connecting while the list of
|
|
478
|
+
* computers is open is a row that should change under you, not on the next press.
|
|
479
|
+
*
|
|
480
|
+
* ```ts
|
|
481
|
+
* subscribe: (reload) => neosh.swarm.onChange(reload)
|
|
482
|
+
* ```
|
|
483
|
+
*/
|
|
484
|
+
subscribe?(reload: () => void): Disposable;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const NS = "neosh.ui.picker";
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* The row marker, and its width **in bytes**.
|
|
491
|
+
*
|
|
492
|
+
* `\u276f` is one column wide but three bytes in UTF-8, so the two markers are the same width on
|
|
493
|
+
* screen and different lengths in the buffer. Every mark offset past the marker is computed from
|
|
494
|
+
* the actual prefix rather than from its display width — the exact confusion this API's byte
|
|
495
|
+
* columns exist to prevent.
|
|
496
|
+
*/
|
|
497
|
+
const CURSOR_MARKER = "\u276f ";
|
|
498
|
+
const BLANK_MARKER = " ";
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* A filterable list in a float. Resolves to the chosen value, or `null` if dismissed.
|
|
502
|
+
*
|
|
503
|
+
* Keys: printable characters filter, `<BS>` deletes, `↑`/`↓` and `<C-p>`/`<C-n>` move, `<CR>`
|
|
504
|
+
* accepts, `<Esc>` dismisses. The float is **modal**, so nothing else reaches the workspace while
|
|
505
|
+
* it is up — `^N` over an open picker used to start a new conversation behind it — bar the keys in
|
|
506
|
+
* `ui.modal_escape_keys`, which is `<C-q>` and `<C-r>` unless you have said otherwise.
|
|
507
|
+
*
|
|
508
|
+
* Only one picker may be open per plugin at a time; opening a second dismisses the first, because
|
|
509
|
+
* two modal lists competing for the keyboard is never what was meant.
|
|
510
|
+
*/
|
|
511
|
+
export async function picker<T>(
|
|
512
|
+
neosh: Neosh,
|
|
513
|
+
items: PickerItem<T>[],
|
|
514
|
+
opts: PickerOptions<T> = {},
|
|
515
|
+
): Promise<T | null> {
|
|
516
|
+
const height = Math.max(1, opts.height ?? 12);
|
|
517
|
+
const width = Math.max(20, opts.width ?? 64);
|
|
518
|
+
const filtering = opts.filter !== false;
|
|
519
|
+
|
|
520
|
+
const buf = await neosh.buf.create({ name: `[${opts.title ?? "picker"}]`, scratch: true, kind: KIND_PICKER });
|
|
521
|
+
const ns = await neosh.ns.create(NS);
|
|
522
|
+
|
|
523
|
+
watchKeys(neosh);
|
|
524
|
+
const keys = await widgetKeys(neosh);
|
|
525
|
+
// A picker with no keys on it is a modal you have to guess your way out of. Every one of these
|
|
526
|
+
// is a list you arrived at by pressing something, and none of them said what to press next.
|
|
527
|
+
const hints = opts.hints ?? defaultHints(keys, filtering);
|
|
528
|
+
|
|
529
|
+
const win = await neosh.float.open(buf, {
|
|
530
|
+
anchor: opts.anchor ?? { kind: "screen" },
|
|
531
|
+
offset: opts.offset,
|
|
532
|
+
width: { kind: "fixed", n: width },
|
|
533
|
+
// Exactly the rows that get drawn: the list, plus a filter line when there is one, a title
|
|
534
|
+
// when there is one, and the key strip unless it was waived.
|
|
535
|
+
height: {
|
|
536
|
+
kind: "fixed",
|
|
537
|
+
n: height + (filtering ? 1 : 0) + (opts.title ? 1 : 0) + (hints === "" ? 0 : 1),
|
|
538
|
+
},
|
|
539
|
+
border: "rounded",
|
|
540
|
+
focusable: true,
|
|
541
|
+
closeOnBlur: true,
|
|
542
|
+
// Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
|
|
543
|
+
// is what `bindWidgetKeys` does and still does — only ever covered the keys it uses; `^T`,
|
|
544
|
+
// `^G`, `^L` and the rest fell straight through and opened a second panel behind this one,
|
|
545
|
+
// with focus somewhere neither of them expected. `^Q` and `^R` still work, so a widget that
|
|
546
|
+
// fails to bind a way out is never a terminal somebody has to kill: see
|
|
547
|
+
// `ui.modal_escape_keys`.
|
|
548
|
+
modal: true,
|
|
549
|
+
z: 200,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
let query = typeof opts.query === "string" ? opts.query : "";
|
|
553
|
+
let cursor = Math.min(Math.max(0, opts.selected ?? 0), Math.max(0, items.length - 1));
|
|
554
|
+
let visible = items.map((item, index) => ({ item, index, positions: [] as number[] }));
|
|
555
|
+
let top = 0;
|
|
556
|
+
|
|
557
|
+
const rank = (pool: PickerItem<T>[], q: string) => {
|
|
558
|
+
const scored = pool
|
|
559
|
+
.map((item, index) => {
|
|
560
|
+
if (opts.match === "name") return byName(item, index, q);
|
|
561
|
+
const hay = item.keywords ? `${item.label} ${item.keywords}` : item.label;
|
|
562
|
+
const m = fuzzy(hay, q);
|
|
563
|
+
return m ? { item, index, positions: m.positions, score: m.score } : null;
|
|
564
|
+
})
|
|
565
|
+
.filter((x): x is NonNullable<typeof x> => x !== null);
|
|
566
|
+
// Stable within equal scores: the original order carries meaning (recency, configured order),
|
|
567
|
+
// and re-sorting it away makes the list jump for no visible reason.
|
|
568
|
+
scored.sort((a, b) => b.score - a.score || a.index - b.index);
|
|
569
|
+
return scored;
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const refilter = () => {
|
|
573
|
+
visible = rank(items, query);
|
|
574
|
+
cursor = Math.min(cursor, Math.max(0, visible.length - 1));
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* A row matched by the start of its name, then by any part of it.
|
|
579
|
+
*
|
|
580
|
+
* Two tiers and nothing else, because the whole point of this mode is that it is *predictable*:
|
|
581
|
+
* you can tell from what you have typed which row you are about to accept. Longer names score
|
|
582
|
+
* lower within a tier so an exact `git.diff` outranks `git.diff.staged`, and the highlighted
|
|
583
|
+
* positions are the run that matched, which is where the eye is already looking.
|
|
584
|
+
*/
|
|
585
|
+
function byName(item: PickerItem<T>, index: number, q: string) {
|
|
586
|
+
if (!q) return { item, index, positions: [] as number[], score: 0 };
|
|
587
|
+
const label = item.label.toLowerCase();
|
|
588
|
+
const needle = q.toLowerCase();
|
|
589
|
+
const at = label.indexOf(needle);
|
|
590
|
+
if (at < 0) return null;
|
|
591
|
+
const positions = Array.from({ length: needle.length }, (_, k) => at + k);
|
|
592
|
+
return { item, index, positions, score: (at === 0 ? 1000 : 500) - label.length };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Which fetch is current. A slow source answering after you have typed again would replace a
|
|
596
|
+
// newer list with an older one, and the row under your finger would change out from under it.
|
|
597
|
+
let generation = 0;
|
|
598
|
+
// The fetch in flight, so accepting can wait for it. Typing faster than the source answers is
|
|
599
|
+
// ordinary — anyone pasting a path does it — and taking the row that was under the cursor two
|
|
600
|
+
// keystrokes ago is not a near miss, it is a different directory.
|
|
601
|
+
let inflight: Promise<void> = Promise.resolve();
|
|
602
|
+
const refetch = () => {
|
|
603
|
+
if (!opts.source) {
|
|
604
|
+
refilter();
|
|
605
|
+
return inflight;
|
|
606
|
+
}
|
|
607
|
+
const mine = ++generation;
|
|
608
|
+
inflight = opts
|
|
609
|
+
.source(query)
|
|
610
|
+
.catch(() => [] as PickerItem<T>[])
|
|
611
|
+
.then((fetched) => {
|
|
612
|
+
if (mine !== generation) return;
|
|
613
|
+
// Already the answer to this query, so ranked against nothing rather than filtered twice.
|
|
614
|
+
visible = fetched.map((item, index) => ({ item, index, positions: [] as number[] }));
|
|
615
|
+
cursor = Math.min(cursor, Math.max(0, visible.length - 1));
|
|
616
|
+
});
|
|
617
|
+
return inflight;
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const render = async () => {
|
|
621
|
+
// A burst of keys — anyone typing at speed, or a paste — arrives as several invocations of the
|
|
622
|
+
// key command at once, and any of them may be the one that accepts and closes. Whichever runs
|
|
623
|
+
// next would then be drawing into a window that is gone, which is not an error worth reporting
|
|
624
|
+
// to the user: it is this widget racing itself.
|
|
625
|
+
if (closed) return;
|
|
626
|
+
// Keep the cursor on screen without recentring on every keystroke.
|
|
627
|
+
if (cursor < top) top = cursor;
|
|
628
|
+
if (cursor >= top + height) top = cursor - height + 1;
|
|
629
|
+
|
|
630
|
+
const lines: string[] = [];
|
|
631
|
+
if (opts.title) lines.push(opts.title);
|
|
632
|
+
if (filtering) lines.push(`> ${query}`);
|
|
633
|
+
|
|
634
|
+
// The width the float was asked for *is* the width of its text: a border is drawn outside it,
|
|
635
|
+
// which is why `columnWidth` subtracts one from each side before opening a panel as wide as the
|
|
636
|
+
// dock. Measuring against anything else puts the last visible character on both lines — the
|
|
637
|
+
// continuation says `/ finds` under a row that already ended in `/`.
|
|
638
|
+
const inner = Math.max(8, width);
|
|
639
|
+
let window = visible.slice(top, top + height);
|
|
640
|
+
// One gutter for the whole list, or none at all. Giving it only to the rows that asked for an
|
|
641
|
+
// icon would step every other label one column left, which reads as a list that cannot decide
|
|
642
|
+
// where its left margin is.
|
|
643
|
+
const gutter = window.some((r) => r.item.icon) ? 2 : 0;
|
|
644
|
+
const iconFor = (item: PickerItem<T>) =>
|
|
645
|
+
gutter === 0 ? "" : item.icon ? `${item.icon} ` : " ";
|
|
646
|
+
const textOf = (row: typeof window[number], on: boolean) =>
|
|
647
|
+
`${on ? "❯ " : " "}${iconFor(row.item)}${row.item.label}${row.item.detail ? ` ${row.item.detail}` : ""}`;
|
|
648
|
+
|
|
649
|
+
// The row under the cursor says all of itself. A picker is a list of things you are choosing
|
|
650
|
+
// between, and two rows whose difference is past the right edge are two rows you cannot choose
|
|
651
|
+
// between — which is exactly what a long model id, a path, or a description does here. The
|
|
652
|
+
// rest goes underneath, only while the cursor is on it, so the list is still a list.
|
|
653
|
+
const on = visible[cursor];
|
|
654
|
+
/** The cursor row's first line, which is re-broken at a word when the row has to continue. */
|
|
655
|
+
let firstLine = on ? textOf(on, true) : "";
|
|
656
|
+
let rest: string[] = [];
|
|
657
|
+
if (on && columnsOf(firstLine) > inner) {
|
|
658
|
+
const head = `❯ ${iconFor(on.item)}${on.item.label}`;
|
|
659
|
+
const detail = on.item.detail ? ` ${on.item.detail}` : "";
|
|
660
|
+
// Only the detail is re-broken. The label is what the filter matched and what the match
|
|
661
|
+
// highlight is measured against, so it stays exactly where it was written; a row whose label
|
|
662
|
+
// alone overflows falls back to continuing from wherever the edge cut it.
|
|
663
|
+
if (detail !== "" && columnsOf(head) + 8 < inner) {
|
|
664
|
+
const [take, remainder] = takeWords(detail, inner - columnsOf(head));
|
|
665
|
+
firstLine = `${head}${take}`;
|
|
666
|
+
rest = remainder.trim() === "" ? [] : wrapToWidth(remainder.trim(), inner - 4);
|
|
667
|
+
} else {
|
|
668
|
+
rest = overflowOf(clipToWidth(firstLine, inner), firstLine, inner - 4);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
// The unfolded row pays for its own continuation out of the list's rows rather than out of the
|
|
672
|
+
// float's height: the window was sized for `height` lines and growing past it would push the
|
|
673
|
+
// key strip off the bottom, which is the row that says how to get out.
|
|
674
|
+
if (rest.length > 0) {
|
|
675
|
+
const room = Math.max(1, height - rest.length);
|
|
676
|
+
if (cursor < top) top = cursor;
|
|
677
|
+
if (cursor >= top + room) top = cursor - room + 1;
|
|
678
|
+
window = visible.slice(top, top + room);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (window.length === 0) {
|
|
682
|
+
lines.push(` ${opts.placeholder ?? "no matches"}`);
|
|
683
|
+
}
|
|
684
|
+
/** Which of `lines` each visible row starts on, relative to the first list row. */
|
|
685
|
+
const lineFor: number[] = [];
|
|
686
|
+
const firstListLine = lines.length;
|
|
687
|
+
for (const row of window) {
|
|
688
|
+
const isCursor = row.index === visible[cursor]?.index;
|
|
689
|
+
lineFor.push(lines.length - firstListLine);
|
|
690
|
+
lines.push(isCursor ? firstLine : textOf(row, false));
|
|
691
|
+
if (!isCursor) continue;
|
|
692
|
+
for (const line of rest) lines.push(` ${line}`);
|
|
693
|
+
}
|
|
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
|
+
// Marks are collected against their row and handed over with the text, in one call. Set one at
|
|
712
|
+
// a time they were a sequence the frontend could draw the middle of: the moment the clear had
|
|
713
|
+
// landed and the marks had not, every row drew unmarked — in `Normal`, which is near-white.
|
|
714
|
+
const drawn: DrawnRow[] = lines.map((text) => ({ text, marks: [] }));
|
|
715
|
+
const mark = (line: number, col: number, o: MarkOptions) => {
|
|
716
|
+
drawn[line]?.marks!.push({ col, opts: o });
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
if (hintLine >= 0) {
|
|
720
|
+
mark(hintLine, 0, { hlGroup: "Sidebar.Dim", endCol: byteLength(lines[hintLine] ?? "") });
|
|
721
|
+
}
|
|
722
|
+
const listTop = (opts.title ? 1 : 0) + (filtering ? 1 : 0);
|
|
723
|
+
for (let i = 0; i < window.length; i++) {
|
|
724
|
+
const row = window[i]!;
|
|
725
|
+
const line = listTop + (lineFor[i] ?? i);
|
|
726
|
+
const isCursor = row.index === visible[cursor]?.index;
|
|
727
|
+
const eol = byteLength(lines[line] ?? "");
|
|
728
|
+
if (isCursor) {
|
|
729
|
+
mark(line, 0, { hlGroup: "Picker.Selected", endCol: eol });
|
|
730
|
+
// The band covers the continuation too, so an unfolded row reads as one row that is
|
|
731
|
+
// several lines tall rather than as a selected row with loose text under it.
|
|
732
|
+
for (let k = 1; k <= rest.length; k++) {
|
|
733
|
+
mark(line + k, 0, { hlGroup: "Picker.Selected", endCol: byteLength(lines[line + k] ?? "") });
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const icon = iconFor(row.item);
|
|
737
|
+
const marker = byteLength(isCursor ? CURSOR_MARKER : BLANK_MARKER);
|
|
738
|
+
// The icon is painted before the match runs, and at no priority: a match highlight over the
|
|
739
|
+
// label is the thing the eye is looking for, and nothing here should be able to outrank it.
|
|
740
|
+
if (row.item.icon && row.item.hl) {
|
|
741
|
+
mark(line, marker, {
|
|
742
|
+
hlGroup: row.item.hl,
|
|
743
|
+
endCol: marker + byteLength(row.item.icon),
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
const prefix = marker + byteLength(icon);
|
|
747
|
+
const offsets = byteOffsets(row.item.label);
|
|
748
|
+
for (const at of row.positions) {
|
|
749
|
+
const start = offsets[at];
|
|
750
|
+
const end = offsets[at + 1];
|
|
751
|
+
// A match position past the label came from `keywords`, which is filtered on but not
|
|
752
|
+
// shown; there is nothing on screen to highlight.
|
|
753
|
+
if (start === undefined || end === undefined) continue;
|
|
754
|
+
mark(line, prefix + start, {
|
|
755
|
+
hlGroup: "Picker.Match",
|
|
756
|
+
endCol: prefix + end,
|
|
757
|
+
priority: 200,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
if (row.item.detail) {
|
|
761
|
+
mark(line, prefix + byteLength(row.item.label), {
|
|
762
|
+
hlGroup: "Picker.Detail",
|
|
763
|
+
endCol: eol,
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (opts.title) {
|
|
768
|
+
mark(0, 0, { hlGroup: "Float.Title", endCol: byteLength(opts.title) });
|
|
769
|
+
}
|
|
770
|
+
await neosh.buf.render(buf, ns, 0, -1, drawn);
|
|
771
|
+
|
|
772
|
+
// The caret goes where you are typing. Without this the terminal cursor stays parked at the
|
|
773
|
+
// top-left of the float and the field reads as inert — you type and nothing appears to be
|
|
774
|
+
// listening, even though the text is right there. With no filter there is nowhere to type, so
|
|
775
|
+
// it marks the row instead.
|
|
776
|
+
await neosh.win.setCursor(
|
|
777
|
+
win,
|
|
778
|
+
filtering ? (opts.title ? 1 : 0) : listTop + (lineFor[cursor - top] ?? cursor - top),
|
|
779
|
+
filtering ? byteLength(`> ${query}`) : 0,
|
|
780
|
+
);
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
let settle: (v: T | null) => void = () => {};
|
|
784
|
+
const done = new Promise<T | null>((resolve) => {
|
|
785
|
+
settle = resolve;
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
const command = `${NS}.key.${++pickerSeq}`;
|
|
789
|
+
const disposers: Disposable[] = [];
|
|
790
|
+
let closed = false;
|
|
791
|
+
const close = async (value: T | null) => {
|
|
792
|
+
if (closed) return;
|
|
793
|
+
closed = true;
|
|
794
|
+
for (const d of disposers) d.dispose();
|
|
795
|
+
await neosh.win.close(win).catch(() => {});
|
|
796
|
+
settle(value);
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
const last = () => Math.max(0, visible.length - 1);
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Change the filter, and tell whoever is watching.
|
|
803
|
+
*
|
|
804
|
+
* One place, because four separate cases used to do it themselves and each one had to remember
|
|
805
|
+
* the refetch. A completion also has to say so outwards, so the composer it is completing keeps
|
|
806
|
+
* showing what has been typed.
|
|
807
|
+
*/
|
|
808
|
+
const retype = async (next: string) => {
|
|
809
|
+
query = next;
|
|
810
|
+
await refetch();
|
|
811
|
+
opts.onQuery?.(query);
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
if (opts.subscribe) {
|
|
815
|
+
disposers.push(
|
|
816
|
+
opts.subscribe(() => {
|
|
817
|
+
if (closed) return;
|
|
818
|
+
void (async () => {
|
|
819
|
+
await refetch();
|
|
820
|
+
if (!closed) await render();
|
|
821
|
+
})().catch(() => {});
|
|
822
|
+
}),
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
disposers.push(
|
|
827
|
+
await neosh.cmd.register(command, async (_args, key) => {
|
|
828
|
+
if (!key || closed) return;
|
|
829
|
+
const before = cursor;
|
|
830
|
+
// The caller first: a verb it put on a key is a verb about the row under the cursor, and the
|
|
831
|
+
// widget's own handling of that key would be about the filter.
|
|
832
|
+
const outcome = await opts.onKey?.(key, { item: visible[cursor]?.item.value, query });
|
|
833
|
+
if (closed) return;
|
|
834
|
+
if (outcome === "close") {
|
|
835
|
+
await close(null);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (outcome === "reload") {
|
|
839
|
+
await refetch();
|
|
840
|
+
await render();
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (outcome === "handled") {
|
|
844
|
+
await render();
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
// Settings next, so a rebound key wins over what the widget would otherwise do with it.
|
|
848
|
+
const action = actionFor(keys, key.key);
|
|
849
|
+
switch (action) {
|
|
850
|
+
case "dismiss":
|
|
851
|
+
await close(null);
|
|
852
|
+
return;
|
|
853
|
+
case "accept": {
|
|
854
|
+
// Settle whatever is in flight first, so this takes the row for what is actually typed.
|
|
855
|
+
await inflight;
|
|
856
|
+
const chosen = visible[cursor];
|
|
857
|
+
if (chosen) {
|
|
858
|
+
await close(chosen.item.value);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
// Nothing highlighted: a picker that is also a field accepts what you typed.
|
|
862
|
+
await close(opts.freeform?.(query) ?? null);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
case "next":
|
|
866
|
+
cursor = Math.min(last(), cursor + 1);
|
|
867
|
+
break;
|
|
868
|
+
case "prev":
|
|
869
|
+
cursor = Math.max(0, cursor - 1);
|
|
870
|
+
break;
|
|
871
|
+
case "page_down":
|
|
872
|
+
cursor = Math.min(last(), cursor + height);
|
|
873
|
+
break;
|
|
874
|
+
case "page_up":
|
|
875
|
+
cursor = Math.max(0, cursor - height);
|
|
876
|
+
break;
|
|
877
|
+
case "first":
|
|
878
|
+
cursor = 0;
|
|
879
|
+
break;
|
|
880
|
+
case "last":
|
|
881
|
+
cursor = last();
|
|
882
|
+
break;
|
|
883
|
+
case "clear":
|
|
884
|
+
await retype("");
|
|
885
|
+
break;
|
|
886
|
+
case "delete_word":
|
|
887
|
+
await retype(dropSegment(query));
|
|
888
|
+
break;
|
|
889
|
+
case "complete": {
|
|
890
|
+
// Take the highlighted row into the field without accepting it — how you walk into a
|
|
891
|
+
// directory one segment at a time.
|
|
892
|
+
await inflight;
|
|
893
|
+
const chosen = visible[cursor];
|
|
894
|
+
if (!chosen || !opts.freeform) return;
|
|
895
|
+
cursor = 0;
|
|
896
|
+
await retype(chosen.item.label);
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
default: {
|
|
900
|
+
if (!filtering) return;
|
|
901
|
+
if (key.key.code.kind === "backspace") {
|
|
902
|
+
// Backspacing off the end of an empty filter dismisses, so a completion feels like
|
|
903
|
+
// part of the field rather than a window in front of it. Only where there is something
|
|
904
|
+
// outside to fall back to — a picker with nowhere to put the keystroke keeps it.
|
|
905
|
+
if (!query && opts.onQuery) {
|
|
906
|
+
await close(null);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
await retype(query.slice(0, -1));
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
if (key.key.code.kind !== "char" || key.key.mods.ctrl || key.key.mods.alt) return;
|
|
913
|
+
cursor = 0;
|
|
914
|
+
await retype(query + key.key.code.c);
|
|
915
|
+
break;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
await render();
|
|
919
|
+
if (cursor !== before && opts.onHighlight) {
|
|
920
|
+
const row = visible[cursor];
|
|
921
|
+
if (row) opts.onHighlight(row.item, row.index);
|
|
922
|
+
}
|
|
923
|
+
}, { desc: "picker key" }),
|
|
924
|
+
);
|
|
925
|
+
|
|
926
|
+
await neosh.focus.push(win);
|
|
927
|
+
disposers.push(await neosh.keymap.capture(win, command));
|
|
928
|
+
// `<C-c>` is bound globally to `interrupt`, which would arm "press again to quit" while a picker
|
|
929
|
+
// is on screen — the one moment the user obviously meant "close this". A window-scoped binding
|
|
930
|
+
// outranks the global one and is dropped with the window.
|
|
931
|
+
await bindWidgetKeys(neosh, win, command, keys, opts.ownKeys ?? []);
|
|
932
|
+
// Now, and not before: everything typed while this was being built went somewhere, and if it
|
|
933
|
+
// went into the field this is completing then it is part of the query. Asked after the keyboard
|
|
934
|
+
// is ours, so there is no further keystroke to miss.
|
|
935
|
+
if (typeof opts.query === "function") query = opts.query();
|
|
936
|
+
await refetch();
|
|
937
|
+
await render();
|
|
938
|
+
if (opts.onHighlight) {
|
|
939
|
+
const row = visible[cursor];
|
|
940
|
+
if (row) opts.onHighlight(row.item, row.index);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return done;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
let pickerSeq = 0;
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Delete one word, treating a path separator as a boundary.
|
|
950
|
+
*
|
|
951
|
+
* `/home/me/src/` becomes `/home/me/`, not `/home/me/src`. Deleting the separator and stopping
|
|
952
|
+
* would mean pressing the key twice per directory, which is not what anyone means by "back one".
|
|
953
|
+
*/
|
|
954
|
+
function dropSegment(text: string): string {
|
|
955
|
+
const trimmed = text.replace(/[\s/]+$/, "");
|
|
956
|
+
const at = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf(" "));
|
|
957
|
+
return at < 0 ? "" : trimmed.slice(0, at + 1);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// ---------------------------------------------------------------------------
|
|
961
|
+
// Derived widgets
|
|
962
|
+
// ---------------------------------------------------------------------------
|
|
963
|
+
|
|
964
|
+
export interface ConfirmOptions {
|
|
965
|
+
/** What the affirming answer says. A verb — `Delete`, `Remove`, `Sign out` — never `OK`. */
|
|
966
|
+
yes?: string;
|
|
967
|
+
/** What the answer that changes nothing says. */
|
|
968
|
+
no?: string;
|
|
969
|
+
/**
|
|
970
|
+
* Under the question, dimmed: what exactly is at stake, and what to do instead.
|
|
971
|
+
*
|
|
972
|
+
* The part that makes a dialog worth stopping for. "Are you sure?" is a speed bump you learn to
|
|
973
|
+
* clear without reading; "4 messages, in neosh · main" and "archiving keeps it" is a question you
|
|
974
|
+
* can answer without leaving the dialog to go and check.
|
|
975
|
+
*/
|
|
976
|
+
detail?: string[];
|
|
977
|
+
/**
|
|
978
|
+
* The affirming answer cannot be undone.
|
|
979
|
+
*
|
|
980
|
+
* Starts on the answer that changes nothing — `<CR>` is reflex by the second time you have seen a
|
|
981
|
+
* dialog, and a reflex must not delete anything — and draws the other one in the theme's error
|
|
982
|
+
* colour, so the row that destroys something does not look like the row beside it.
|
|
983
|
+
*/
|
|
984
|
+
dangerous?: boolean;
|
|
985
|
+
width?: number;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const CONFIRM_NS = "neosh.ui.confirm";
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Ask a yes/no question, and mean it. Resolves `false` when dismissed.
|
|
992
|
+
*
|
|
993
|
+
* A dialog rather than a two-row picker, because the picker's title is one clipped line and the
|
|
994
|
+
* question is the whole point: what is about to happen, to what, and what the alternative is. It
|
|
995
|
+
* wraps, it carries `detail`, and when the answer is destructive it says so in colour rather than
|
|
996
|
+
* relying on you to read a verb.
|
|
997
|
+
*
|
|
998
|
+
* `y` and `n` answer it outright, `↑`/`↓` and `j`/`k` move between the two, `<CR>` takes the one
|
|
999
|
+
* under the cursor, and `<Esc>` — like every other float in the workspace — means no.
|
|
1000
|
+
*/
|
|
1001
|
+
export async function confirm(
|
|
1002
|
+
neosh: Neosh,
|
|
1003
|
+
question: string,
|
|
1004
|
+
opts: ConfirmOptions = {},
|
|
1005
|
+
): Promise<boolean> {
|
|
1006
|
+
const yes = opts.yes ?? "Yes";
|
|
1007
|
+
const no = opts.no ?? "No";
|
|
1008
|
+
const width = Math.min(78, Math.max(36, opts.width ?? 58));
|
|
1009
|
+
const asked = wrapText(question, width - 2);
|
|
1010
|
+
const detail = (opts.detail ?? []).flatMap((d) => wrapText(d, width - 2));
|
|
1011
|
+
const answers = [yes, no];
|
|
1012
|
+
const strip = clipTo(
|
|
1013
|
+
`y ${yes.toLowerCase()} n ${no.toLowerCase()} ↵ choose esc cancel`,
|
|
1014
|
+
width - 2,
|
|
1015
|
+
);
|
|
1016
|
+
|
|
1017
|
+
// On the answer that changes nothing, when the other one cannot be taken back.
|
|
1018
|
+
let cursor = opts.dangerous ? 1 : 0;
|
|
1019
|
+
|
|
1020
|
+
const buf = await neosh.buf.create({ name: "[confirm]", scratch: true, kind: KIND_CONFIRM });
|
|
1021
|
+
const ns = await neosh.ns.create(CONFIRM_NS);
|
|
1022
|
+
|
|
1023
|
+
watchKeys(neosh);
|
|
1024
|
+
const keys = await widgetKeys(neosh);
|
|
1025
|
+
|
|
1026
|
+
/** Where each part of the dialog starts, so the marks do not have to count rows twice. */
|
|
1027
|
+
const detailAt = asked.length + 1;
|
|
1028
|
+
const answersAt = detailAt + (detail.length === 0 ? 0 : detail.length + 1);
|
|
1029
|
+
const stripAt = answersAt + answers.length + 1;
|
|
1030
|
+
|
|
1031
|
+
const win = await neosh.float.open(buf, {
|
|
1032
|
+
anchor: { kind: "screen" },
|
|
1033
|
+
width: { kind: "fixed", n: width },
|
|
1034
|
+
height: { kind: "fixed", n: stripAt + 1 },
|
|
1035
|
+
border: "rounded",
|
|
1036
|
+
focusable: true,
|
|
1037
|
+
closeOnBlur: true,
|
|
1038
|
+
// Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
|
|
1039
|
+
// is what `bindWidgetKeys` does and still does — only ever covered the keys it uses; `^T`,
|
|
1040
|
+
// `^G`, `^L` and the rest fell straight through and opened a second panel behind this one,
|
|
1041
|
+
// with focus somewhere neither of them expected. `^Q` and `^R` still work, so a widget that
|
|
1042
|
+
// fails to bind a way out is never a terminal somebody has to kill: see
|
|
1043
|
+
// `ui.modal_escape_keys`.
|
|
1044
|
+
modal: true,
|
|
1045
|
+
// Above a picker: this is asked *from* one often enough that being drawn under it would make
|
|
1046
|
+
// the workspace look wedged.
|
|
1047
|
+
z: 260,
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
const render = async () => {
|
|
1051
|
+
const lines: string[] = asked.map((l) => ` ${l}`);
|
|
1052
|
+
lines.push("");
|
|
1053
|
+
if (detail.length > 0) {
|
|
1054
|
+
lines.push(...detail.map((l) => ` ${l}`));
|
|
1055
|
+
lines.push("");
|
|
1056
|
+
}
|
|
1057
|
+
answers.forEach((a, i) => lines.push(`${i === cursor ? `${CURSOR_MARKER}` : BLANK_MARKER}${a}`));
|
|
1058
|
+
lines.push("");
|
|
1059
|
+
lines.push(` ${strip}`);
|
|
1060
|
+
// Text and marks together, in one call: this is redrawn on every keystroke, and a repaint the
|
|
1061
|
+
// frontend can draw the middle of is one that flashes — unmarked rows draw in `Normal`. It also
|
|
1062
|
+
// takes care of the older half of the same bug, that a mark whose line was replaced under it
|
|
1063
|
+
// clamps rather than dies, so rows would end up wearing the colours of the ones before.
|
|
1064
|
+
const drawn: DrawnRow[] = lines.map((text) => ({ text, marks: [] }));
|
|
1065
|
+
const mark = (line: number, col: number, o: MarkOptions) => {
|
|
1066
|
+
drawn[line]?.marks!.push({ col, opts: o });
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
for (let i = 0; i < asked.length; i++) {
|
|
1070
|
+
mark(i, 0, { hlGroup: "Title", endCol: byteLength(lines[i] ?? "") });
|
|
1071
|
+
}
|
|
1072
|
+
for (let i = 0; i < detail.length; i++) {
|
|
1073
|
+
mark(detailAt + i, 0, {
|
|
1074
|
+
hlGroup: "Comment",
|
|
1075
|
+
endCol: byteLength(lines[detailAt + i] ?? ""),
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
for (let i = 0; i < answers.length; i++) {
|
|
1079
|
+
const line = answersAt + i;
|
|
1080
|
+
// The band is the row's *background* rather than a group across its bytes, so the answer keeps
|
|
1081
|
+
// whatever colour said what it was. A ranged group here would leave the destructive row
|
|
1082
|
+
// looking exactly like the safe one for as long as the cursor is on it.
|
|
1083
|
+
if (i === cursor) {
|
|
1084
|
+
mark(line, 0, { lineHlGroup: "Picker.Selected" });
|
|
1085
|
+
}
|
|
1086
|
+
if (opts.dangerous && i === 0) {
|
|
1087
|
+
mark(line, byteLength(BLANK_MARKER), {
|
|
1088
|
+
hlGroup: "Diagnostic.Error",
|
|
1089
|
+
endCol: byteLength(lines[line] ?? ""),
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
mark(stripAt, 0, { hlGroup: "Sidebar.Dim", endCol: byteLength(lines[stripAt] ?? "") });
|
|
1094
|
+
await neosh.buf.render(buf, ns, 0, -1, drawn);
|
|
1095
|
+
// The caret marks the answer, since there is nothing here to type into.
|
|
1096
|
+
await neosh.win.setCursor(win, answersAt + cursor, 0);
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
let settle: (v: boolean) => void = () => {};
|
|
1100
|
+
const done = new Promise<boolean>((resolve) => {
|
|
1101
|
+
settle = resolve;
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
const command = `${CONFIRM_NS}.key.${++pickerSeq}`;
|
|
1105
|
+
const disposers: Disposable[] = [];
|
|
1106
|
+
let closed = false;
|
|
1107
|
+
const close = async (value: boolean) => {
|
|
1108
|
+
if (closed) return;
|
|
1109
|
+
closed = true;
|
|
1110
|
+
for (const d of disposers) d.dispose();
|
|
1111
|
+
await neosh.win.close(win).catch(() => {});
|
|
1112
|
+
settle(value);
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
disposers.push(
|
|
1116
|
+
await neosh.cmd.register(command, async (_args, key) => {
|
|
1117
|
+
if (!key || closed) return;
|
|
1118
|
+
switch (actionFor(keys, key.key)) {
|
|
1119
|
+
case "dismiss":
|
|
1120
|
+
await close(false);
|
|
1121
|
+
return;
|
|
1122
|
+
case "accept":
|
|
1123
|
+
await close(cursor === 0);
|
|
1124
|
+
return;
|
|
1125
|
+
case "next":
|
|
1126
|
+
case "last":
|
|
1127
|
+
cursor = answers.length - 1;
|
|
1128
|
+
break;
|
|
1129
|
+
case "prev":
|
|
1130
|
+
case "first":
|
|
1131
|
+
cursor = 0;
|
|
1132
|
+
break;
|
|
1133
|
+
default: {
|
|
1134
|
+
if (key.key.code.kind !== "char" || key.key.mods.ctrl || key.key.mods.alt) return;
|
|
1135
|
+
switch (key.key.code.c.toLowerCase()) {
|
|
1136
|
+
// Answering outright, which is what anyone who has read the question wants. Nothing here
|
|
1137
|
+
// filters, so the letters are free — and `y`/`n` are the two nobody has to be told.
|
|
1138
|
+
case "y":
|
|
1139
|
+
await close(true);
|
|
1140
|
+
return;
|
|
1141
|
+
case "n":
|
|
1142
|
+
case "q":
|
|
1143
|
+
await close(false);
|
|
1144
|
+
return;
|
|
1145
|
+
case "j":
|
|
1146
|
+
cursor = answers.length - 1;
|
|
1147
|
+
break;
|
|
1148
|
+
case "k":
|
|
1149
|
+
cursor = 0;
|
|
1150
|
+
break;
|
|
1151
|
+
default:
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
await render();
|
|
1157
|
+
}, { desc: "confirm key" }),
|
|
1158
|
+
);
|
|
1159
|
+
|
|
1160
|
+
await neosh.focus.push(win);
|
|
1161
|
+
disposers.push(await neosh.keymap.capture(win, command));
|
|
1162
|
+
await bindWidgetKeys(neosh, win, command, keys);
|
|
1163
|
+
await render();
|
|
1164
|
+
return done;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Ask before something that cannot be undone — unless the user has said not to.
|
|
1169
|
+
*
|
|
1170
|
+
* One place, so `ui.confirm_destructive` means the same thing everywhere and a plugin does not have
|
|
1171
|
+
* to know the option exists. The bar is *irreversible*, not merely significant: closing a panel,
|
|
1172
|
+
* switching a model or archiving a conversation asks nothing, because you can put those back. A
|
|
1173
|
+
* dialog charged for a reversible action is what teaches people to clear dialogs without reading
|
|
1174
|
+
* them, which is how the one that matters stops working.
|
|
1175
|
+
*/
|
|
1176
|
+
export async function confirmDestructive(
|
|
1177
|
+
neosh: Neosh,
|
|
1178
|
+
question: string,
|
|
1179
|
+
opts: Omit<ConfirmOptions, "dangerous"> = {},
|
|
1180
|
+
): Promise<boolean> {
|
|
1181
|
+
const ask = (await neosh.opt.get<boolean>("ui.confirm_destructive").catch(() => true)) ?? true;
|
|
1182
|
+
if (!ask) return true;
|
|
1183
|
+
return confirm(neosh, question, { ...opts, dangerous: true });
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Break text into lines of at most `width` characters.
|
|
1188
|
+
*
|
|
1189
|
+
* Counted in characters and not columns: measuring display width is the frontend's job, and this
|
|
1190
|
+
* feeds a float that is sized with a column to spare. A word longer than the line is left long and
|
|
1191
|
+
* clipped where it is drawn, by the one thing that knows where its own edge is.
|
|
1192
|
+
*/
|
|
1193
|
+
function wrapText(text: string, width: number): string[] {
|
|
1194
|
+
const out: string[] = [];
|
|
1195
|
+
for (const paragraph of text.split("\n")) {
|
|
1196
|
+
let line = "";
|
|
1197
|
+
for (const word of paragraph.split(/\s+/).filter((w) => w !== "")) {
|
|
1198
|
+
const next = line === "" ? word : `${line} ${word}`;
|
|
1199
|
+
if (line !== "" && Array.from(next).length > width) {
|
|
1200
|
+
out.push(line);
|
|
1201
|
+
line = word;
|
|
1202
|
+
} else {
|
|
1203
|
+
line = next;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
out.push(line);
|
|
1207
|
+
}
|
|
1208
|
+
return out;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function clipTo(text: string, width: number): string {
|
|
1212
|
+
const chars = Array.from(text);
|
|
1213
|
+
return chars.length <= width ? text : `${chars.slice(0, Math.max(1, width - 1)).join("")}…`;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// ---------------------------------------------------------------------------
|
|
1217
|
+
// Showing the rest of a row
|
|
1218
|
+
// ---------------------------------------------------------------------------
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Break `text` into lines of at most `columns` **display columns**, splitting words that are wider
|
|
1222
|
+
* than the column rather than letting them overflow it.
|
|
1223
|
+
*
|
|
1224
|
+
* The difference from {@link wrapText} is the whole reason both exist. That one counts code points
|
|
1225
|
+
* and leaves a long word long, because it feeds a float that is sized with a column to spare and
|
|
1226
|
+
* the frontend clips the overhang. This one feeds a *column of a row* — a description beside a
|
|
1227
|
+
* label, a continuation under a title — where an overhanging word does not get clipped, it pushes
|
|
1228
|
+
* whatever is to its right off the edge and takes the alignment of every row below it with it. A
|
|
1229
|
+
* URL, a path or a `--flag=value` is routinely wider than the column it lands in, so this is the
|
|
1230
|
+
* common case and not the exotic one.
|
|
1231
|
+
*
|
|
1232
|
+
* Measured with {@link width}, which is the op the renderer itself uses — so a CJK label and an
|
|
1233
|
+
* ASCII one wrap at the same visual place.
|
|
1234
|
+
*/
|
|
1235
|
+
export function wrapToWidth(text: string, columns: number): string[] {
|
|
1236
|
+
const limit = Math.max(1, Math.floor(columns));
|
|
1237
|
+
const out: string[] = [];
|
|
1238
|
+
for (const paragraph of text.split("\n")) {
|
|
1239
|
+
let line = "";
|
|
1240
|
+
for (const word of paragraph.split(/\s+/).filter((w) => w !== "")) {
|
|
1241
|
+
const next = line === "" ? word : `${line} ${word}`;
|
|
1242
|
+
if (width(next) <= limit) {
|
|
1243
|
+
line = next;
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
if (line !== "") {
|
|
1247
|
+
out.push(line);
|
|
1248
|
+
line = "";
|
|
1249
|
+
}
|
|
1250
|
+
// A word that cannot fit a line of its own is cut across as many as it needs. Cutting is
|
|
1251
|
+
// right here and wrong in prose: this is an identifier, not a sentence, and the alternative
|
|
1252
|
+
// is not a nicer break but a row that is silently wider than its column.
|
|
1253
|
+
let rest = word;
|
|
1254
|
+
while (width(rest) > limit) {
|
|
1255
|
+
const head = clipToWidth(rest, limit);
|
|
1256
|
+
if (head === "") break;
|
|
1257
|
+
out.push(head);
|
|
1258
|
+
rest = rest.slice(head.length);
|
|
1259
|
+
}
|
|
1260
|
+
line = rest;
|
|
1261
|
+
}
|
|
1262
|
+
out.push(line);
|
|
1263
|
+
}
|
|
1264
|
+
return out.length > 0 ? out : [""];
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* The part of `full` that `shown` did not get to say, as lines of `columns` columns.
|
|
1269
|
+
*
|
|
1270
|
+
* `shown` is the row as it was actually written — clipped, and ending in an ellipsis if it was.
|
|
1271
|
+
* The ellipsis is dropped before comparing, so what comes back starts exactly where the visible
|
|
1272
|
+
* text stopped, and the two read as one sentence broken across a line.
|
|
1273
|
+
*
|
|
1274
|
+
* Empty when nothing was lost, which is the common case and has to cost nothing: a list of short
|
|
1275
|
+
* rows must not grow a blank line under the cursor.
|
|
1276
|
+
*/
|
|
1277
|
+
export function overflowOf(shown: string, full: string, columns: number): string[] {
|
|
1278
|
+
if (full === "" || columns < 1) return [];
|
|
1279
|
+
// Everything before the ellipsis is what actually reached the screen. What comes *after* it is
|
|
1280
|
+
// decoration that survived the clip — an unread dot, a count, a state glyph pinned to the end of
|
|
1281
|
+
// the row — and it is not part of the text, so it is neither compared against `full` nor said
|
|
1282
|
+
// again underneath.
|
|
1283
|
+
const cut = shown.lastIndexOf("…");
|
|
1284
|
+
const visible = cut >= 0 ? shown.slice(0, cut) : shown;
|
|
1285
|
+
// Nothing was lost. The common case, and it has to cost nothing: a list of short rows must not
|
|
1286
|
+
// grow a blank line under the cursor.
|
|
1287
|
+
if (visible.startsWith(full)) return [];
|
|
1288
|
+
// A `full` that is not a continuation of what is on screen means the caller built the two
|
|
1289
|
+
// differently rather than by clipping one from the other. The whole of it is then the honest
|
|
1290
|
+
// thing to show, because there is no prefix to trust.
|
|
1291
|
+
const rest = full.startsWith(visible) ? full.slice(visible.length) : full;
|
|
1292
|
+
if (rest.trim() === "") return [];
|
|
1293
|
+
return wrapToWidth(rest.replace(/^\s+/, ""), columns);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* As much of `text` as fits `columns` **without breaking a word**, and the rest of it.
|
|
1298
|
+
*
|
|
1299
|
+
* What a row's first line needs. {@link overflowOf} continues from wherever the clip landed, which
|
|
1300
|
+
* is right when the clip is somebody else's — the row is already drawn and ends in an ellipsis that
|
|
1301
|
+
* says so. When the row is *ours* to lay out, breaking `work` into `wor` and `k` is a choice, and a
|
|
1302
|
+
* bad one: the eye stops at the ragged edge and the reader spends a beat rejoining a word instead of
|
|
1303
|
+
* reading the sentence.
|
|
1304
|
+
*
|
|
1305
|
+
* A single word wider than the column is cut anyway. There is nothing else to do with it, and the
|
|
1306
|
+
* alternative — a first line left empty — is worse.
|
|
1307
|
+
*/
|
|
1308
|
+
export function takeWords(text: string, columns: number): [string, string] {
|
|
1309
|
+
const limit = Math.max(1, Math.floor(columns));
|
|
1310
|
+
if (width(text) <= limit) return [text, ""];
|
|
1311
|
+
// Split keeping the separators, so what is left over is the exact remainder of the original
|
|
1312
|
+
// rather than a rejoin of it: two spaces after a full stop stay two spaces.
|
|
1313
|
+
const parts = text.split(/(\s+)/);
|
|
1314
|
+
let head = "";
|
|
1315
|
+
for (const part of parts) {
|
|
1316
|
+
const next = head + part;
|
|
1317
|
+
if (width(next.trimEnd()) > limit) break;
|
|
1318
|
+
head = next;
|
|
1319
|
+
}
|
|
1320
|
+
if (head.trim() === "") head = clipToWidth(text, limit);
|
|
1321
|
+
return [head.trimEnd(), text.slice(head.length)];
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* {@link width}, under a name nothing shadows.
|
|
1326
|
+
*
|
|
1327
|
+
* `picker` binds `width` to its own float's column count, which is a number — so calling the
|
|
1328
|
+
* measuring function inside it is a type error at best and a silent wrong answer at worst.
|
|
1329
|
+
*/
|
|
1330
|
+
const columnsOf = width;
|
|
1331
|
+
|
|
1332
|
+
/** How many columns of leading space `text` has. */
|
|
1333
|
+
function leading(text: string): number {
|
|
1334
|
+
return text.length - text.replace(/^ +/, "").length;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* Type a directory path, with completion as you go.
|
|
1339
|
+
*
|
|
1340
|
+
* The list under the field is the directories that match what you have typed so far, refreshed on
|
|
1341
|
+
* every keystroke. `<Tab>` takes the highlighted one into the field so you can keep going, the
|
|
1342
|
+
* movement keys pick a different one, and `<CR>` accepts either the highlighted row or exactly what
|
|
1343
|
+
* you typed — because the directory you want may not be one this offers.
|
|
1344
|
+
*
|
|
1345
|
+
* `<C-w>` deletes a whole segment rather than a character, which is the difference between walking
|
|
1346
|
+
* back up a tree and holding backspace.
|
|
1347
|
+
*
|
|
1348
|
+
* Resolves to the path, or `null` if dismissed.
|
|
1349
|
+
*/
|
|
1350
|
+
export async function pathPicker(
|
|
1351
|
+
neosh: Neosh,
|
|
1352
|
+
title: string,
|
|
1353
|
+
opts: { initial?: string; width?: number; height?: number } = {},
|
|
1354
|
+
): Promise<string | null> {
|
|
1355
|
+
return picker<string>(neosh, [], {
|
|
1356
|
+
title,
|
|
1357
|
+
width: Math.max(40, opts.width ?? 72),
|
|
1358
|
+
height: Math.max(4, opts.height ?? 10),
|
|
1359
|
+
query: opts.initial ?? "",
|
|
1360
|
+
placeholder: "no directory matches — <CR> takes what you typed",
|
|
1361
|
+
source: async (query) => {
|
|
1362
|
+
const paths = await neosh.path.complete(query).catch(() => []);
|
|
1363
|
+
return paths.map((path) => ({ label: path, value: path }));
|
|
1364
|
+
},
|
|
1365
|
+
// What you typed, when it is not one of the offered rows. A completion list that refuses a path
|
|
1366
|
+
// it did not think of is a list that gets in the way.
|
|
1367
|
+
freeform: (query) => (query.trim() === "" ? null : query.trim()),
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* A single-line text field. Resolves to the text, or `null` if dismissed.
|
|
1373
|
+
*
|
|
1374
|
+
* Built on the same capture the picker uses, so it behaves the same way: bindings still win, and
|
|
1375
|
+
* `<Esc>` gets you out.
|
|
1376
|
+
*/
|
|
1377
|
+
export async function prompt(
|
|
1378
|
+
neosh: Neosh,
|
|
1379
|
+
title: string,
|
|
1380
|
+
opts: { initial?: string; width?: number } = {},
|
|
1381
|
+
): Promise<string | null> {
|
|
1382
|
+
const width = Math.max(24, opts.width ?? 60);
|
|
1383
|
+
const buf = await neosh.buf.create({ name: "[prompt]", scratch: true, kind: KIND_PROMPT });
|
|
1384
|
+
const win = await neosh.float.open(buf, {
|
|
1385
|
+
anchor: { kind: "screen" },
|
|
1386
|
+
width: { kind: "fixed", n: width },
|
|
1387
|
+
height: { kind: "fixed", n: 2 },
|
|
1388
|
+
border: "rounded",
|
|
1389
|
+
focusable: true,
|
|
1390
|
+
closeOnBlur: true,
|
|
1391
|
+
// Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
|
|
1392
|
+
// is what `bindWidgetKeys` does and still does — only ever covered the keys it uses; `^T`,
|
|
1393
|
+
// `^G`, `^L` and the rest fell straight through and opened a second panel behind this one,
|
|
1394
|
+
// with focus somewhere neither of them expected. `^Q` and `^R` still work, so a widget that
|
|
1395
|
+
// fails to bind a way out is never a terminal somebody has to kill: see
|
|
1396
|
+
// `ui.modal_escape_keys`.
|
|
1397
|
+
modal: true,
|
|
1398
|
+
z: 200,
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
watchKeys(neosh);
|
|
1402
|
+
const keys = await widgetKeys(neosh);
|
|
1403
|
+
|
|
1404
|
+
let text = opts.initial ?? "";
|
|
1405
|
+
const render = async () => {
|
|
1406
|
+
await neosh.buf.setLines(buf, 0, -1, [title, `> ${text}`]);
|
|
1407
|
+
// The caret belongs where the typing goes. Left at the origin it sits on the title, and the
|
|
1408
|
+
// field reads as inert no matter what appears in it.
|
|
1409
|
+
await neosh.win.setCursor(win, 1, byteLength(`> ${text}`));
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
let settle: (v: string | null) => void = () => {};
|
|
1413
|
+
const done = new Promise<string | null>((resolve) => {
|
|
1414
|
+
settle = resolve;
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
const command = `neosh.ui.prompt.key.${++pickerSeq}`;
|
|
1418
|
+
const disposers: Disposable[] = [];
|
|
1419
|
+
let closed = false;
|
|
1420
|
+
const close = async (value: string | null) => {
|
|
1421
|
+
if (closed) return;
|
|
1422
|
+
closed = true;
|
|
1423
|
+
for (const d of disposers) d.dispose();
|
|
1424
|
+
await neosh.win.close(win).catch(() => {});
|
|
1425
|
+
settle(value);
|
|
1426
|
+
};
|
|
1427
|
+
|
|
1428
|
+
disposers.push(
|
|
1429
|
+
await neosh.cmd.register(command, async (_args, key: KeyContext | undefined) => {
|
|
1430
|
+
if (!key) return;
|
|
1431
|
+
switch (actionFor(keys, key.key)) {
|
|
1432
|
+
case "dismiss":
|
|
1433
|
+
await close(null);
|
|
1434
|
+
return;
|
|
1435
|
+
case "accept":
|
|
1436
|
+
await close(text);
|
|
1437
|
+
return;
|
|
1438
|
+
case "clear":
|
|
1439
|
+
text = "";
|
|
1440
|
+
break;
|
|
1441
|
+
case "delete_word":
|
|
1442
|
+
text = dropSegment(text);
|
|
1443
|
+
break;
|
|
1444
|
+
default: {
|
|
1445
|
+
if (key.key.code.kind === "backspace") {
|
|
1446
|
+
text = text.slice(0, -1);
|
|
1447
|
+
break;
|
|
1448
|
+
}
|
|
1449
|
+
if (key.key.code.kind !== "char" || key.key.mods.ctrl || key.key.mods.alt) return;
|
|
1450
|
+
text += key.key.code.c;
|
|
1451
|
+
break;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
await render();
|
|
1455
|
+
}, { desc: "prompt key" }),
|
|
1456
|
+
);
|
|
1457
|
+
|
|
1458
|
+
await neosh.focus.push(win);
|
|
1459
|
+
disposers.push(await neosh.keymap.capture(win, command));
|
|
1460
|
+
await bindWidgetKeys(neosh, win, command, keys);
|
|
1461
|
+
await render();
|
|
1462
|
+
return done;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* Route every widget key to the widget's own handler while its window is focused.
|
|
1467
|
+
*
|
|
1468
|
+
* A raw capture only receives what *no binding claimed*. That used to be the whole story, and it
|
|
1469
|
+
* meant `<C-n>` never reached a picker: `<C-n>` is bound globally to "new conversation", so you
|
|
1470
|
+
* would be typing a filter and suddenly find yourself in a new conversation with the picker gone.
|
|
1471
|
+
*
|
|
1472
|
+
* Window-scoped bindings outrank global ones and are dropped with the window, so a widget owns its
|
|
1473
|
+
* keys for exactly as long as it is on screen and not one keystroke longer. Modes are enumerated
|
|
1474
|
+
* because a binding is per mode and a picker can be opened from any of them.
|
|
1475
|
+
*
|
|
1476
|
+
* These floats are also `modal`, which is the other half and the half this could not be: a widget
|
|
1477
|
+
* can name the keys it *wants* and never the ones it merely does not want to happen. Both are
|
|
1478
|
+
* needed — modality stops `<C-t>` opening a panel behind the picker, and these bindings are what
|
|
1479
|
+
* point `<C-n>` at "next" rather than at nothing.
|
|
1480
|
+
*/
|
|
1481
|
+
async function bindWidgetKeys(
|
|
1482
|
+
neosh: Neosh,
|
|
1483
|
+
win: WindowId,
|
|
1484
|
+
command: string,
|
|
1485
|
+
keys: Map<WidgetAction, KeySpec[]>,
|
|
1486
|
+
extra: readonly string[] = [],
|
|
1487
|
+
): Promise<void> {
|
|
1488
|
+
const lhs = new Set<string>(extra);
|
|
1489
|
+
for (const specs of keys.values()) {
|
|
1490
|
+
for (const spec of specs) lhs.add(spec.lhs);
|
|
1491
|
+
}
|
|
1492
|
+
for (const mode of ["normal", "insert", "visual", "chat"] as const) {
|
|
1493
|
+
for (const key of lhs) {
|
|
1494
|
+
// A key that will not parse on the host side is not worth failing the whole widget over; the
|
|
1495
|
+
// capture still delivers it if nothing else claimed it.
|
|
1496
|
+
await neosh.keymap.set(mode, key, command, { scope: { kind: "window", win } }).catch(() => {});
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* Highlight groups the widgets here use, linked to ones a theme already defines.
|
|
1503
|
+
*
|
|
1504
|
+
* Call once from your plugin's `activate` if you want them; they are links, not colors, so an
|
|
1505
|
+
* unknown theme still renders something sensible.
|
|
1506
|
+
*/
|
|
1507
|
+
export async function defineHighlights(_neosh: Neosh): Promise<void> {
|
|
1508
|
+
// Nothing to define any more: the groups below are part of the core palette, so they resolve in
|
|
1509
|
+
// every theme and follow a theme switch without this library hearing about it. Kept as a no-op
|
|
1510
|
+
// because plugins call it, and because a future widget may need a group of its own.
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/** Re-exported so a caller can type a picker's list without importing from two places. */
|
|
1514
|
+
export type { BufferId, WindowId };
|
|
1515
|
+
|
|
1516
|
+
// ---------------------------------------------------------------------------
|
|
1517
|
+
// Version-control display
|
|
1518
|
+
// ---------------------------------------------------------------------------
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* The single letter git itself uses for a state.
|
|
1522
|
+
*
|
|
1523
|
+
* Written out rather than derived from the enum name, because two of them do not agree with their
|
|
1524
|
+
* spelling: untracked is `?`, not `U`, and `U` means *unmerged*. A sidebar that labels every new
|
|
1525
|
+
* file `U` is telling the user they have a merge conflict.
|
|
1526
|
+
*/
|
|
1527
|
+
export function stateLetter(state: FileState | null | undefined): string {
|
|
1528
|
+
switch (state) {
|
|
1529
|
+
case "added": return "A";
|
|
1530
|
+
case "modified": return "M";
|
|
1531
|
+
case "deleted": return "D";
|
|
1532
|
+
case "renamed": return "R";
|
|
1533
|
+
case "copied": return "C";
|
|
1534
|
+
case "type_changed": return "T";
|
|
1535
|
+
case "conflicted": return "U";
|
|
1536
|
+
case "untracked": return "?";
|
|
1537
|
+
case "ignored": return "!";
|
|
1538
|
+
default: return " ";
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/** The two-column `XY` prefix git status prints: staged state, then working-tree state. */
|
|
1543
|
+
export function statusPrefix(change: FileChange): string {
|
|
1544
|
+
return `${stateLetter(change.staged)}${stateLetter(change.unstaged)}`;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/**
|
|
1548
|
+
* Fit a path into `width` display columns, keeping the end.
|
|
1549
|
+
*
|
|
1550
|
+
* The tail is what identifies a file; the head is what repeats. Measured in columns rather than
|
|
1551
|
+
* characters, so a path with CJK in it does not overflow a narrow panel.
|
|
1552
|
+
*
|
|
1553
|
+
* Git reports untracked *directories* with a trailing slash — `crates/` — which `basename` turns
|
|
1554
|
+
* into an empty string. Kept here so every caller gets that right.
|
|
1555
|
+
*/
|
|
1556
|
+
export function shortenPath(path: string, width: number): string {
|
|
1557
|
+
const trimmed = path.endsWith("/") ? path.slice(0, -1) : path;
|
|
1558
|
+
const suffix = path.endsWith("/") ? "/" : "";
|
|
1559
|
+
const chars = Array.from(trimmed);
|
|
1560
|
+
const budget = Math.max(4, width - suffix.length);
|
|
1561
|
+
if (chars.length <= budget) return trimmed + suffix;
|
|
1562
|
+
return `\u2026${chars.slice(-(budget - 1)).join("")}${suffix}`;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// ---------------------------------------------------------------------------
|
|
1566
|
+
// Motion
|
|
1567
|
+
// ---------------------------------------------------------------------------
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* Ambient motion, on one shared clock.
|
|
1571
|
+
*
|
|
1572
|
+
* The rule this follows is not a terminal compromise — it is the same rule the web app states
|
|
1573
|
+
* outright: *no continuously repainting animations*. Its four animations are duty-cycled with
|
|
1574
|
+
* `steps()` so a 2-second pulse is a dozen discrete frames rather than 240. A terminal is that
|
|
1575
|
+
* model already: discrete cells, discrete frames, and a diffing renderer that writes only what
|
|
1576
|
+
* changed.
|
|
1577
|
+
*
|
|
1578
|
+
* So: **one clock**, module-global and therefore shared by every plugin, ticking at
|
|
1579
|
+
* {@link TICK_MS}. Every spinner in the session is on the same frame and every pulse toggles in the
|
|
1580
|
+
* same repaint, rather than each row owning a timer and smearing writes across the second.
|
|
1581
|
+
*
|
|
1582
|
+
* Measured cost of the whole system in a real terminal: ~1.3% of one core and 0.6 KiB/s at 80 ms,
|
|
1583
|
+
* because the renderer sends only changed cells. That holds over SSH.
|
|
1584
|
+
*/
|
|
1585
|
+
|
|
1586
|
+
/** One frame of the shared clock. 100 ms × 10 braille frames is exactly the web's 1000 ms spin. */
|
|
1587
|
+
export const TICK_MS = 100;
|
|
1588
|
+
|
|
1589
|
+
/** The spinner the web app uses 28 times over, as braille. */
|
|
1590
|
+
const BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
1591
|
+
/** For a terminal that cannot draw braille. Four frames at 250 ms is the same 1000 ms cycle. */
|
|
1592
|
+
const ASCII = ["|", "/", "-", "\\"];
|
|
1593
|
+
|
|
1594
|
+
let ticks = 0;
|
|
1595
|
+
let handle: number | null = null;
|
|
1596
|
+
const listeners = new Set<() => void>();
|
|
1597
|
+
let ascii = false;
|
|
1598
|
+
let enabled = true;
|
|
1599
|
+
|
|
1600
|
+
/**
|
|
1601
|
+
* Subscribe to the shared clock.
|
|
1602
|
+
*
|
|
1603
|
+
* The clock starts on the first subscriber and stops on the last, so an idle session has no timer
|
|
1604
|
+
* at all — and unloading a plugin, which disposes its subscriptions, takes its share with it.
|
|
1605
|
+
*
|
|
1606
|
+
* Push the result into `ctx.subscriptions`. A subscriber that is never disposed keeps the clock
|
|
1607
|
+
* running for the life of the session.
|
|
1608
|
+
*/
|
|
1609
|
+
export function onTick(cb: () => void): Disposable {
|
|
1610
|
+
listeners.add(cb);
|
|
1611
|
+
if (handle === null) {
|
|
1612
|
+
handle = setInterval(() => {
|
|
1613
|
+
ticks++;
|
|
1614
|
+
for (const l of listeners) {
|
|
1615
|
+
try {
|
|
1616
|
+
l();
|
|
1617
|
+
} catch {
|
|
1618
|
+
// One misbehaving subscriber must not stop every other spinner in the session.
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
}, TICK_MS) as unknown as number;
|
|
1622
|
+
}
|
|
1623
|
+
return {
|
|
1624
|
+
dispose() {
|
|
1625
|
+
listeners.delete(cb);
|
|
1626
|
+
if (listeners.size === 0 && handle !== null) {
|
|
1627
|
+
clearInterval(handle);
|
|
1628
|
+
handle = null;
|
|
1629
|
+
}
|
|
1630
|
+
},
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/**
|
|
1635
|
+
* The current spinner frame.
|
|
1636
|
+
*
|
|
1637
|
+
* Read it, do not own it: every caller reading the same function on the same clock is what keeps
|
|
1638
|
+
* two spinners on screen from wobbling against each other.
|
|
1639
|
+
*/
|
|
1640
|
+
export function spinnerFrame(): string {
|
|
1641
|
+
if (!enabled) return ascii ? "*" : "•";
|
|
1642
|
+
const frames = ascii ? ASCII : BRAILLE;
|
|
1643
|
+
const divisor = ascii ? Math.round(250 / TICK_MS) : 1;
|
|
1644
|
+
return frames[Math.floor(ticks / divisor) % frames.length] ?? frames[0]!;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
/**
|
|
1648
|
+
* The 1 Hz duty cycle behind every pulsing dot: one second bright, one second dim.
|
|
1649
|
+
*
|
|
1650
|
+
* Two states, never a ramp — the web's `status-pulse` quantizes to exactly two opacities and this
|
|
1651
|
+
* is the same thing with the `dim` attribute. Returning a boolean rather than a glyph keeps the
|
|
1652
|
+
* cell width and the accessible text stable while only the attribute changes.
|
|
1653
|
+
*/
|
|
1654
|
+
export function pulseBright(): boolean {
|
|
1655
|
+
if (!enabled) return true;
|
|
1656
|
+
return Math.floor(ticks / Math.round(1000 / TICK_MS)) % 2 === 0;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/** Highlight group for a pulsing indicator, for use as `hlGroup`. */
|
|
1660
|
+
export function pulseHl(bright: string, dim = "Comment"): string {
|
|
1661
|
+
return pulseBright() ? bright : dim;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/**
|
|
1665
|
+
* Configure motion for this session.
|
|
1666
|
+
*
|
|
1667
|
+
* Called once by whoever owns the setting — the bundled plugins read `ui.motion` and
|
|
1668
|
+
* `ui.ascii_only`. With motion off, `spinnerFrame` returns a single static glyph and `pulseBright`
|
|
1669
|
+
* is always true, so callers need no branch of their own.
|
|
1670
|
+
*/
|
|
1671
|
+
export function configureMotion(opts: { enabled?: boolean; ascii?: boolean }): void {
|
|
1672
|
+
if (opts.enabled !== undefined) enabled = opts.enabled;
|
|
1673
|
+
if (opts.ascii !== undefined) ascii = opts.ascii;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
export function motionEnabled(): boolean {
|
|
1677
|
+
return enabled;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/** Elapsed time as the web app words it: `12s`, `1m 04s`, `1h 02m`. */
|
|
1681
|
+
export function elapsed(ms: number): string {
|
|
1682
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
1683
|
+
if (total < 60) return `${total}s`;
|
|
1684
|
+
const minutes = Math.floor(total / 60);
|
|
1685
|
+
const seconds = total % 60;
|
|
1686
|
+
if (minutes < 60) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
1687
|
+
const hours = Math.floor(minutes / 60);
|
|
1688
|
+
return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
/**
|
|
1692
|
+
* A horizontal meter, as cells.
|
|
1693
|
+
*
|
|
1694
|
+
* Used for the context-window gauge and for anything with a fraction. Repaint only when the filled
|
|
1695
|
+
* count changes: a meter that rewrites itself every tick to draw the same thing is the exact cost
|
|
1696
|
+
* this module exists to avoid.
|
|
1697
|
+
*/
|
|
1698
|
+
export function meter(fraction: number, width: number, opts: { ascii?: boolean } = {}): string {
|
|
1699
|
+
const w = Math.max(1, width);
|
|
1700
|
+
const clamped = Math.min(1, Math.max(0, Number.isFinite(fraction) ? fraction : 0));
|
|
1701
|
+
const filled = Math.round(clamped * w);
|
|
1702
|
+
const [on, off] = opts.ascii ?? ascii ? ["#", "-"] : ["█", "░"];
|
|
1703
|
+
return on!.repeat(filled) + off!.repeat(w - filled);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
/** `1.2k`, `45.3k`, `1.8M` — the compact forms a status line has room for. */
|
|
1707
|
+
export function compact(n: number): string {
|
|
1708
|
+
const v = Math.max(0, Math.round(n));
|
|
1709
|
+
if (v < 1000) return String(v);
|
|
1710
|
+
if (v < 1_000_000) {
|
|
1711
|
+
const k = v / 1000;
|
|
1712
|
+
return `${k < 10 ? k.toFixed(1) : Math.round(k)}k`;
|
|
1713
|
+
}
|
|
1714
|
+
const m = v / 1_000_000;
|
|
1715
|
+
return `${m < 10 ? m.toFixed(1) : Math.round(m)}M`;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
/** `$0.0042`, `$1.23`, `$12.30` — enough precision to be useful at both ends. */
|
|
1719
|
+
export function money(usd: number): string {
|
|
1720
|
+
if (!Number.isFinite(usd) || usd <= 0) return "$0";
|
|
1721
|
+
if (usd < 0.01) return `$${usd.toFixed(4)}`;
|
|
1722
|
+
if (usd < 1) return `$${usd.toFixed(3)}`;
|
|
1723
|
+
return `$${usd.toFixed(2)}`;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// ---------------------------------------------------------------------------
|
|
1727
|
+
// Cursored list
|
|
1728
|
+
// ---------------------------------------------------------------------------
|
|
1729
|
+
|
|
1730
|
+
/**
|
|
1731
|
+
* A row in a {@link CursoredList}.
|
|
1732
|
+
*
|
|
1733
|
+
* `right` is rendered flush against the pane's right edge by the frontend, which is the only thing
|
|
1734
|
+
* that knows how wide the pane is or how many columns a character occupies. Building that alignment
|
|
1735
|
+
* here would mean measuring display width in a plugin, which is exactly what the byte-offset
|
|
1736
|
+
* protocol exists to prevent.
|
|
1737
|
+
*/
|
|
1738
|
+
export interface ListRow<T = unknown> {
|
|
1739
|
+
text: string;
|
|
1740
|
+
/**
|
|
1741
|
+
* The whole of what this row says, when `text` is a clipped version of it.
|
|
1742
|
+
*
|
|
1743
|
+
* A panel is a column of fixed width and a conversation title is not, so rows get cut — and a row
|
|
1744
|
+
* cut at the edge is a row you cannot read, which in a list of conversations means you cannot
|
|
1745
|
+
* tell two of them apart. Set this to the row as it *would* have been written with unlimited
|
|
1746
|
+
* room, and the list shows the rest of it on continuation lines whenever the cursor is on that
|
|
1747
|
+
* row, folding back to one line the moment the cursor leaves.
|
|
1748
|
+
*
|
|
1749
|
+
* The cursor is what asks. Only one row can be under it, so only one row is ever more than a line
|
|
1750
|
+
* tall, and the column stays scannable — which is the thing a list is for and the thing that
|
|
1751
|
+
* wrapping everything all the time takes away.
|
|
1752
|
+
*
|
|
1753
|
+
* Include the same prefix `text` has: the marker, the glyph, the indent. What comes back is the
|
|
1754
|
+
* difference between the two, so `text: " ▸ some long ti…"` with `full: " ▸ some long title"`
|
|
1755
|
+
* continues with `tle` and not with the glyph again. Leave out anything the clip *kept* — a
|
|
1756
|
+
* trailing unread dot, a count — since that is already on screen and saying it twice under the
|
|
1757
|
+
* row is how a marker stops meaning anything. Nothing happens without
|
|
1758
|
+
* {@link CursoredListOptions.width}, because nothing here can measure a column it was not told
|
|
1759
|
+
* about.
|
|
1760
|
+
*/
|
|
1761
|
+
full?: string;
|
|
1762
|
+
/**
|
|
1763
|
+
* Keep this row unfolded whether or not the cursor is on it.
|
|
1764
|
+
*
|
|
1765
|
+
* The cursor asking is the rule, and this is the one exception worth having: the conversation
|
|
1766
|
+
* you are *in* is not a row you are considering, it is where you are, and a panel that abbreviates
|
|
1767
|
+
* it to `Fix the login re…` until you happen to move the cursor onto it is abbreviating the one
|
|
1768
|
+
* row you already know you want. Needs {@link ListRow.full} and
|
|
1769
|
+
* {@link CursoredListOptions.width}, like every other unfolding here. Use it for a row that is
|
|
1770
|
+
* *current*, never for one that is merely long — every row that opts in is a row the column can
|
|
1771
|
+
* no longer be scanned down.
|
|
1772
|
+
*/
|
|
1773
|
+
expand?: boolean;
|
|
1774
|
+
/**
|
|
1775
|
+
* The column continuation lines line up under. Defaults to the row's own indent plus two.
|
|
1776
|
+
*
|
|
1777
|
+
* Set it where the default would be wrong — a row whose text starts with a marker and a glyph
|
|
1778
|
+
* has its *content* several columns in from its indent, and continuations that ignore that read
|
|
1779
|
+
* as a second row rather than as the rest of this one.
|
|
1780
|
+
*/
|
|
1781
|
+
indent?: number;
|
|
1782
|
+
/** Highlight group for the whole row. Link to one the theme defines. */
|
|
1783
|
+
hl?: string;
|
|
1784
|
+
/**
|
|
1785
|
+
* Highlights for pieces of the row, over the top of `hl`: a favourite marker, a state glyph, a
|
|
1786
|
+
* matched substring. `from`/`to` are UTF-8 byte offsets into `text` — the same unit every column
|
|
1787
|
+
* on the wire uses — so build them with {@link byteLength} rather than `.length`.
|
|
1788
|
+
*/
|
|
1789
|
+
spans?: Array<{ from: number; to: number; hl: string }>;
|
|
1790
|
+
/** Flush-right status: a timestamp, a count, a state word. */
|
|
1791
|
+
right?: { text: string; hl?: string };
|
|
1792
|
+
/** Rows that cannot be landed on: headings, separators, blanks. */
|
|
1793
|
+
inert?: boolean;
|
|
1794
|
+
value?: T;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
export interface CursoredListOptions {
|
|
1798
|
+
/** Highlight for the row the cursor is on. Defaults to the theme's `Sidebar.Selected`. */
|
|
1799
|
+
cursorHl?: string;
|
|
1800
|
+
/** Called after every move, with the row landed on. */
|
|
1801
|
+
onMove?(index: number): void;
|
|
1802
|
+
/**
|
|
1803
|
+
* How many columns the panel has, asked at render time.
|
|
1804
|
+
*
|
|
1805
|
+
* Asked rather than passed once, because a panel is resized and a width captured at construction
|
|
1806
|
+
* is a width that goes stale on the first drag. Without it {@link ListRow.full} does nothing:
|
|
1807
|
+
* a plugin cannot measure the dock it was given, and guessing is how a continuation line ends up
|
|
1808
|
+
* wrapping one column past the edge for the rest of the session.
|
|
1809
|
+
*/
|
|
1810
|
+
width?(): number;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* The list behaviour every panel in a workspace needs, in one place.
|
|
1815
|
+
*
|
|
1816
|
+
* Owns a cursor that skips inert rows, renders text and marks together, and keeps the cursor on
|
|
1817
|
+
* screen. It does **not** own a window, a buffer or any keys — a docked sidebar and a floating
|
|
1818
|
+
* picker want different chrome and different bindings, and the part worth sharing is the part
|
|
1819
|
+
* below.
|
|
1820
|
+
*/
|
|
1821
|
+
export class CursoredList<T = unknown> {
|
|
1822
|
+
private rows: ListRow<T>[] = [];
|
|
1823
|
+
private cursor = 0;
|
|
1824
|
+
|
|
1825
|
+
constructor(
|
|
1826
|
+
private readonly neosh: Neosh,
|
|
1827
|
+
private readonly buf: BufferId,
|
|
1828
|
+
private readonly ns: number,
|
|
1829
|
+
private readonly opts: CursoredListOptions = {},
|
|
1830
|
+
) {}
|
|
1831
|
+
|
|
1832
|
+
get index(): number {
|
|
1833
|
+
return this.cursor;
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
get current(): ListRow<T> | undefined {
|
|
1837
|
+
return this.rows[this.cursor];
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
get value(): T | undefined {
|
|
1841
|
+
return this.rows[this.cursor]?.value;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/** Every row's value, top to bottom, skipping rows that have none. What a panel publishes. */
|
|
1845
|
+
get values(): T[] {
|
|
1846
|
+
return this.rows.flatMap((r) => (r.value === undefined ? [] : [r.value]));
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
get length(): number {
|
|
1850
|
+
return this.rows.length;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
/**
|
|
1854
|
+
* Replace the contents, keeping the cursor where it makes sense.
|
|
1855
|
+
*
|
|
1856
|
+
* Anchored to `value` identity rather than index: a list that reorders under a running turn would
|
|
1857
|
+
* otherwise move the selection out from under the user's next keystroke.
|
|
1858
|
+
*/
|
|
1859
|
+
setRows(rows: ListRow<T>[], sameAs?: (a: T, b: T) => boolean): void {
|
|
1860
|
+
const previous = this.rows[this.cursor]?.value;
|
|
1861
|
+
this.rows = rows;
|
|
1862
|
+
if (previous !== undefined && sameAs) {
|
|
1863
|
+
const found = rows.findIndex((r) => r.value !== undefined && sameAs(r.value, previous));
|
|
1864
|
+
if (found >= 0) {
|
|
1865
|
+
this.cursor = found;
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
this.clamp();
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
private clamp(): void {
|
|
1873
|
+
if (this.rows.length === 0) {
|
|
1874
|
+
this.cursor = 0;
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
this.cursor = Math.min(Math.max(0, this.cursor), this.rows.length - 1);
|
|
1878
|
+
if (this.rows[this.cursor]?.inert) {
|
|
1879
|
+
const after = this.rows.findIndex((r, i) => i >= this.cursor && !r.inert);
|
|
1880
|
+
const anywhere = this.rows.findIndex((r) => !r.inert);
|
|
1881
|
+
this.cursor = after >= 0 ? after : Math.max(0, anywhere);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/**
|
|
1886
|
+
* Move by `delta` selectable rows.
|
|
1887
|
+
*
|
|
1888
|
+
* Wraps by default, because a list you cannot get back to the top of is a chore. A page step
|
|
1889
|
+
* passes `wrap: false`: `^D` at the foot of the list means "there is no more", and one that
|
|
1890
|
+
* silently reappears at the top is a keypress that loses your place in a column you were
|
|
1891
|
+
* reading downwards.
|
|
1892
|
+
*
|
|
1893
|
+
* `delta` counts rows you can land on, not buffer rows — headings, rules and blanks are not
|
|
1894
|
+
* places, so `5j` past two separators moves five conversations rather than three.
|
|
1895
|
+
*/
|
|
1896
|
+
move(delta: number, opts: { wrap?: boolean } = {}): void {
|
|
1897
|
+
if (this.rows.length === 0 || delta === 0) return;
|
|
1898
|
+
const wrap = opts.wrap ?? true;
|
|
1899
|
+
const step = delta < 0 ? -1 : 1;
|
|
1900
|
+
let remaining = Math.abs(delta);
|
|
1901
|
+
let i = this.cursor;
|
|
1902
|
+
let landed = -1;
|
|
1903
|
+
// Bounded by the whole list per row asked for: an all-inert list has nowhere to go, and a
|
|
1904
|
+
// wrapping search for a row that does not exist would otherwise spin.
|
|
1905
|
+
const limit = this.rows.length * Math.abs(delta) + this.rows.length;
|
|
1906
|
+
for (let n = 0; n < limit && remaining > 0; n++) {
|
|
1907
|
+
let next = i + step;
|
|
1908
|
+
if (next < 0 || next >= this.rows.length) {
|
|
1909
|
+
if (!wrap) break;
|
|
1910
|
+
next = (next + this.rows.length) % this.rows.length;
|
|
1911
|
+
}
|
|
1912
|
+
i = next;
|
|
1913
|
+
if (!this.rows[i]?.inert) {
|
|
1914
|
+
landed = i;
|
|
1915
|
+
remaining--;
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
if (landed < 0) return;
|
|
1919
|
+
this.cursor = landed;
|
|
1920
|
+
this.opts.onMove?.(landed);
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/**
|
|
1924
|
+
* The first or last row you can land on — `gg` and `G`.
|
|
1925
|
+
*
|
|
1926
|
+
* A separate verb from a large `move`, because "the end" is a place and "a hundred rows down"
|
|
1927
|
+
* is a guess about where the end is.
|
|
1928
|
+
*/
|
|
1929
|
+
toEnd(which: "first" | "last"): void {
|
|
1930
|
+
const found = which === "first"
|
|
1931
|
+
? this.rows.findIndex((r) => !r.inert)
|
|
1932
|
+
: this.rows.reduce((acc, r, i) => (r.inert ? acc : i), -1);
|
|
1933
|
+
if (found < 0) return;
|
|
1934
|
+
this.cursor = found;
|
|
1935
|
+
this.opts.onMove?.(found);
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/** The n-th row you can land on, 1-based, the way `5G` counts. */
|
|
1939
|
+
nth(n: number): void {
|
|
1940
|
+
let seen = 0;
|
|
1941
|
+
for (const [i, row] of this.rows.entries()) {
|
|
1942
|
+
if (row.inert) continue;
|
|
1943
|
+
seen += 1;
|
|
1944
|
+
if (seen === n) {
|
|
1945
|
+
this.cursor = i;
|
|
1946
|
+
this.opts.onMove?.(i);
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
this.toEnd("last");
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
/** Put the cursor on the first row whose value matches. */
|
|
1954
|
+
select(match: (value: T) => boolean): boolean {
|
|
1955
|
+
const found = this.rows.findIndex((r) => r.value !== undefined && match(r.value));
|
|
1956
|
+
if (found < 0) return false;
|
|
1957
|
+
this.cursor = found;
|
|
1958
|
+
return true;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
/**
|
|
1962
|
+
* Write the rows and their marks.
|
|
1963
|
+
*
|
|
1964
|
+
* `showCursor` is false when the panel does not have the keyboard: a cursor drawn on an unfocused
|
|
1965
|
+
* list claims an attention it does not have, and two visible cursors is worse than none.
|
|
1966
|
+
*
|
|
1967
|
+
* `pinned` is how many rows at the end sit against the *bottom edge* rather than after the
|
|
1968
|
+
* content: a gauge or a key strip is a foot, and a foot that floats halfway up a half-empty
|
|
1969
|
+
* column is chrome that moves every time the list above it grows by one. It costs blank lines
|
|
1970
|
+
* and nothing else — when the list is longer than the window there is no room to pin anything
|
|
1971
|
+
* to, and the rows go back to being the end of the list, which is where scrolling will find
|
|
1972
|
+
* them.
|
|
1973
|
+
*/
|
|
1974
|
+
async render(
|
|
1975
|
+
opts: { showCursor?: boolean; win?: WindowId; pinned?: number } = {},
|
|
1976
|
+
): Promise<void> {
|
|
1977
|
+
const cursorHl = this.opts.cursorHl ?? "Sidebar.Selected";
|
|
1978
|
+
const columns = this.opts.width?.() ?? 0;
|
|
1979
|
+
const drawn: DrawnRow[] = [];
|
|
1980
|
+
/** Which buffer line each row starts on. Not the row's index once anything has unfolded. */
|
|
1981
|
+
const lineOf: number[] = [];
|
|
1982
|
+
/** How tall the cursor's row turned out, so scrolling can keep all of it on screen. */
|
|
1983
|
+
let block = 1;
|
|
1984
|
+
|
|
1985
|
+
this.rows.forEach((row, i) => {
|
|
1986
|
+
lineOf[i] = drawn.length;
|
|
1987
|
+
const eol = byteLength(row.text);
|
|
1988
|
+
const onCursor = opts.showCursor !== false && i === this.cursor;
|
|
1989
|
+
const marks: DrawnMark[] = [];
|
|
1990
|
+
if (onCursor && eol > 0) {
|
|
1991
|
+
marks.push({ col: 0, opts: { hlGroup: cursorHl, endCol: eol, priority: 200 } });
|
|
1992
|
+
}
|
|
1993
|
+
if (row.hl && eol > 0) {
|
|
1994
|
+
marks.push({ col: 0, opts: { hlGroup: row.hl, endCol: eol } });
|
|
1995
|
+
}
|
|
1996
|
+
// Above the row's own highlight, below the cursor's 200: a marker keeps its colour on an
|
|
1997
|
+
// ordinary row and yields to the selection, which is the row the eye is already on.
|
|
1998
|
+
for (const s of row.spans ?? []) {
|
|
1999
|
+
if (s.to <= s.from || s.from >= eol) continue;
|
|
2000
|
+
marks.push({
|
|
2001
|
+
col: s.from,
|
|
2002
|
+
opts: { hlGroup: s.hl, endCol: Math.min(s.to, eol), priority: 100 },
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
if (row.right) {
|
|
2006
|
+
marks.push({
|
|
2007
|
+
col: 0,
|
|
2008
|
+
opts: {
|
|
2009
|
+
virtText: [{ text: row.right.text, hlGroup: row.right.hl ?? "Comment" }],
|
|
2010
|
+
virtTextPos: "right",
|
|
2011
|
+
},
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
drawn.push({ text: row.text, marks });
|
|
2015
|
+
|
|
2016
|
+
// The rest of a row that did not fit: under the cursor, or on a row that asked to stay open.
|
|
2017
|
+
if ((!onCursor && !row.expand) || row.full === undefined || columns < 1) return;
|
|
2018
|
+
const indent = Math.max(0, Math.min(row.indent ?? leading(row.text) + 2, columns - 8));
|
|
2019
|
+
const rest = overflowOf(row.text, row.full, columns - indent);
|
|
2020
|
+
// Only the cursor's block is scrolled to. A row that is permanently open is not somewhere
|
|
2021
|
+
// the cursor is, so counting its height here would scroll the panel to a row nobody moved to.
|
|
2022
|
+
if (onCursor) block = 1 + rest.length;
|
|
2023
|
+
for (const line of rest) {
|
|
2024
|
+
const text = `${" ".repeat(indent)}${line}`;
|
|
2025
|
+
const end = byteLength(text);
|
|
2026
|
+
const cont: DrawnMark[] = [];
|
|
2027
|
+
// The band runs the whole block. A continuation drawn on the terminal background reads as
|
|
2028
|
+
// a separate row that happens to be indented, which is the one thing it must not be. Only
|
|
2029
|
+
// where there is a band to run: an always-open row that is not under the cursor gets its
|
|
2030
|
+
// own colour and nothing else, or every one of them would look selected.
|
|
2031
|
+
if (onCursor) cont.push({ col: 0, opts: { hlGroup: cursorHl, endCol: end, priority: 200 } });
|
|
2032
|
+
if (row.hl) cont.push({ col: 0, opts: { hlGroup: row.hl, endCol: end } });
|
|
2033
|
+
drawn.push({ text, marks: cont });
|
|
2034
|
+
}
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
// The foot, against the bottom edge. Measured after everything is drawn rather than counted in
|
|
2038
|
+
// rows, because an unfolded row is several lines tall and padding by row count would leave the
|
|
2039
|
+
// strip one line short for every row that happened to be open.
|
|
2040
|
+
const pinned = Math.min(Math.max(0, opts.pinned ?? 0), this.rows.length);
|
|
2041
|
+
// One round trip, used twice — and skipped when neither the foot nor the cursor needs it, since
|
|
2042
|
+
// this runs on a tick while a turn is in flight and an unfocused panel that pins nothing has
|
|
2043
|
+
// nothing to measure.
|
|
2044
|
+
const needed = pinned > 0 || opts.showCursor !== false;
|
|
2045
|
+
const view = opts.win === undefined || !needed
|
|
2046
|
+
? null
|
|
2047
|
+
: await this.neosh.win.viewport(opts.win).catch(() => null);
|
|
2048
|
+
if (pinned > 0) {
|
|
2049
|
+
const at = lineOf[this.rows.length - pinned] ?? drawn.length;
|
|
2050
|
+
const filler = (view?.height ?? 0) - drawn.length;
|
|
2051
|
+
if (filler > 0) {
|
|
2052
|
+
drawn.splice(at, 0, ...Array.from({ length: filler }, () => ({ text: "", marks: [] })));
|
|
2053
|
+
for (let i = this.rows.length - pinned; i < this.rows.length; i++) {
|
|
2054
|
+
lineOf[i] = (lineOf[i] ?? 0) + filler;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// One call, not one per mark. A panel that wrote its text, cleared its namespace and then set
|
|
2060
|
+
// its marks a call at a time was observable halfway through — and a frame landing after the
|
|
2061
|
+
// clear drew every row unmarked, which is `Normal`, which is near-white. That is what a sidebar
|
|
2062
|
+
// full of running agents was flashing.
|
|
2063
|
+
await this.neosh.buf.render(this.buf, this.ns, 0, -1, drawn);
|
|
2064
|
+
|
|
2065
|
+
// Keep the cursor on screen without recentring on every keystroke. In buffer lines, not row
|
|
2066
|
+
// indices: an unfolded row is several lines tall and the two stopped agreeing the moment one
|
|
2067
|
+
// was — which would scroll to the wrong place by however many rows above it had ever unfolded.
|
|
2068
|
+
if (opts.win !== undefined && opts.showCursor !== false) {
|
|
2069
|
+
const v = view;
|
|
2070
|
+
if (v && v.height > 0) {
|
|
2071
|
+
const at = lineOf[this.cursor] ?? this.cursor;
|
|
2072
|
+
// The whole of the row, not only the line it starts on: scrolling until the *first* line
|
|
2073
|
+
// is visible leaves the continuation you unfolded it for below the bottom edge.
|
|
2074
|
+
const end = at + Math.min(block, v.height) - 1;
|
|
2075
|
+
const top = v.top_line;
|
|
2076
|
+
if (at < top) await this.neosh.win.scrollTo(opts.win, at);
|
|
2077
|
+
else if (end >= top + v.height) {
|
|
2078
|
+
await this.neosh.win.scrollTo(opts.win, end - v.height + 1);
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
// ---------------------------------------------------------------------------
|
|
2086
|
+
// Two-pane picker
|
|
2087
|
+
// ---------------------------------------------------------------------------
|
|
2088
|
+
|
|
2089
|
+
/**
|
|
2090
|
+
* One entry in the left rail: a provider, an account, a category.
|
|
2091
|
+
*
|
|
2092
|
+
* The mark is drawn in its own highlight group, which is how a rail of eleven providers stays
|
|
2093
|
+
* legible — you find the one you want by shape and colour before you have read a word.
|
|
2094
|
+
*/
|
|
2095
|
+
export interface RailItem<G> {
|
|
2096
|
+
/** A brand mark, one or two columns. Drawn in `mark.hl`. */
|
|
2097
|
+
mark?: { text: string; hl?: string };
|
|
2098
|
+
label: string;
|
|
2099
|
+
/** A state marker at the right edge — signed in, needs a key, unavailable. */
|
|
2100
|
+
badge?: { text: string; hl?: string };
|
|
2101
|
+
/** Heading this sits under. Groups appear in the order their first member does. */
|
|
2102
|
+
group?: string;
|
|
2103
|
+
/** Listed, dimmed, and skipped by the cursor. For a provider whose driver is missing. */
|
|
2104
|
+
disabled?: boolean;
|
|
2105
|
+
value: G;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/** One row of the right-hand list. */
|
|
2109
|
+
export interface PaneItem<T> extends PickerItem<T> {
|
|
2110
|
+
/** A middle column, aligned across rows: a tier, a size, a state. */
|
|
2111
|
+
badge?: { text: string; hl?: string };
|
|
2112
|
+
/**
|
|
2113
|
+
* A collapsible section this row belongs to.
|
|
2114
|
+
*
|
|
2115
|
+
* Rows in a section are hidden behind a header that says how many there are. What it is for:
|
|
2116
|
+
* last year's models, which you want reachable and do not want in the way.
|
|
2117
|
+
*/
|
|
2118
|
+
section?: string;
|
|
2119
|
+
disabled?: boolean;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
export interface RailPickerOptions<G, T> {
|
|
2123
|
+
title?: string;
|
|
2124
|
+
/** Columns for the rail. The list takes the rest. */
|
|
2125
|
+
railWidth?: number;
|
|
2126
|
+
/** Total inner width. */
|
|
2127
|
+
width?: number;
|
|
2128
|
+
/** Rows of body. */
|
|
2129
|
+
height?: number;
|
|
2130
|
+
rail: RailItem<G>[];
|
|
2131
|
+
/** Which rail entry to open on. */
|
|
2132
|
+
railAt?: number;
|
|
2133
|
+
/** The rows for a rail entry. Called on open and on every rail move. */
|
|
2134
|
+
items(group: G): Promise<PaneItem<T>[]>;
|
|
2135
|
+
/** Which row to start on, given the rows. */
|
|
2136
|
+
itemAt?(items: PaneItem<T>[]): number;
|
|
2137
|
+
/** The key strip at the foot. Keep it to one line. */
|
|
2138
|
+
hints?: string;
|
|
2139
|
+
/**
|
|
2140
|
+
* What the two panes are called, for the affordance in the title row.
|
|
2141
|
+
*
|
|
2142
|
+
* `"providers"` and `"models"` rather than `"panes"` and `"list"`, wherever the caller knows —
|
|
2143
|
+
* a key labelled with what it reaches is a key somebody presses.
|
|
2144
|
+
*/
|
|
2145
|
+
railLabel?: string;
|
|
2146
|
+
paneLabel?: string;
|
|
2147
|
+
/**
|
|
2148
|
+
* Shown in place of the list when there is nothing in it.
|
|
2149
|
+
*
|
|
2150
|
+
* Not decoration: an empty pane beside a populated rail reads as a broken widget, and the two
|
|
2151
|
+
* reasons it can be empty — your filter matched nothing, or this provider serves nothing until
|
|
2152
|
+
* it can authenticate — need different things done about them.
|
|
2153
|
+
*/
|
|
2154
|
+
placeholder?: string;
|
|
2155
|
+
/**
|
|
2156
|
+
* Keys `onKey` wants that a *binding elsewhere* would otherwise take.
|
|
2157
|
+
*
|
|
2158
|
+
* The raw capture only receives what no keymap claimed, so a caller reaching for `<C-s>` gets
|
|
2159
|
+
* nothing — that key belongs to the transcript reader. Listing it here binds it to this widget
|
|
2160
|
+
* for as long as the widget is open, the same way its own movement keys are bound.
|
|
2161
|
+
*
|
|
2162
|
+
* Bare letters do not need this and should not use it: they reach `onKey` already, and taking
|
|
2163
|
+
* one means it can never be typed into the filter — which is how a model picker ends up unable
|
|
2164
|
+
* to search for "sonnet".
|
|
2165
|
+
*/
|
|
2166
|
+
ownKeys?: readonly string[];
|
|
2167
|
+
/**
|
|
2168
|
+
* A key the caller wants for itself, checked before the widget's own.
|
|
2169
|
+
*
|
|
2170
|
+
* Return `"close"` to dismiss, `"reload"` to re-fetch the current list, `"handled"` to redraw,
|
|
2171
|
+
* and nothing at all to let the widget have the key. This is what lets a model picker put "sign
|
|
2172
|
+
* in" and "set effort" on keys without a second modal.
|
|
2173
|
+
*/
|
|
2174
|
+
onKey?(
|
|
2175
|
+
key: KeyContext,
|
|
2176
|
+
ctx: { rail: G | undefined; item: T | undefined },
|
|
2177
|
+
): Promise<"handled" | "reload" | "close" | undefined> | "handled" | "reload" | "close" | undefined;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
const RAIL_NS = "neosh.ui.rail";
|
|
2181
|
+
|
|
2182
|
+
/**
|
|
2183
|
+
* A picker in two panes: a rail of categories, and the rows belonging to the selected one.
|
|
2184
|
+
*
|
|
2185
|
+
* The shape exists because one flat list cannot answer two questions at once. "Which provider" and
|
|
2186
|
+
* "which model" are different choices with different cardinalities — a dozen providers, hundreds of
|
|
2187
|
+
* models — and flattening them produces a list where the thing you want is thirty rows below a
|
|
2188
|
+
* provider you do not use.
|
|
2189
|
+
*
|
|
2190
|
+
* Both panes are one buffer with a rule down the middle rather than two floats, because two floats
|
|
2191
|
+
* cannot be kept adjacent without each of them knowing the other's width, and neither of them may
|
|
2192
|
+
* measure anything. One buffer makes the rule a character, which is a thing a plugin can place.
|
|
2193
|
+
*
|
|
2194
|
+
* Typing filters the list. `pane_next`/`pane_prev` — `<Tab>`/`<S-Tab>` and `←`/`→` by default —
|
|
2195
|
+
* move between the panes, and the rail's own entries are reachable with the same movement keys as
|
|
2196
|
+
* the list, so nothing here needs a mouse or a chord you have to be told about.
|
|
2197
|
+
*/
|
|
2198
|
+
export async function railPicker<G, T>(
|
|
2199
|
+
neosh: Neosh,
|
|
2200
|
+
opts: RailPickerOptions<G, T>,
|
|
2201
|
+
): Promise<T | null> {
|
|
2202
|
+
const height = Math.max(3, opts.height ?? 14);
|
|
2203
|
+
const total = Math.max(40, opts.width ?? 84);
|
|
2204
|
+
const railWidth = Math.min(Math.max(12, opts.railWidth ?? 22), total - 24);
|
|
2205
|
+
const paneWidth = total - railWidth - 1; // the rule takes a column
|
|
2206
|
+
|
|
2207
|
+
const buf = await neosh.buf.create({ name: `[${opts.title ?? "select"}]`, scratch: true, kind: KIND_PICKER });
|
|
2208
|
+
const ns = await neosh.ns.create(RAIL_NS);
|
|
2209
|
+
const win = await neosh.float.open(buf, {
|
|
2210
|
+
anchor: { kind: "screen" },
|
|
2211
|
+
width: { kind: "fixed", n: total },
|
|
2212
|
+
// title, filter, rule, body, rule, hints
|
|
2213
|
+
height: { kind: "fixed", n: height + 5 },
|
|
2214
|
+
border: "rounded",
|
|
2215
|
+
focusable: true,
|
|
2216
|
+
closeOnBlur: true,
|
|
2217
|
+
// Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
|
|
2218
|
+
// is what `bindWidgetKeys` does and still does — only ever covered the keys it uses; `^T`,
|
|
2219
|
+
// `^G`, `^L` and the rest fell straight through and opened a second panel behind this one,
|
|
2220
|
+
// with focus somewhere neither of them expected. `^Q` and `^R` still work, so a widget that
|
|
2221
|
+
// fails to bind a way out is never a terminal somebody has to kill: see
|
|
2222
|
+
// `ui.modal_escape_keys`.
|
|
2223
|
+
modal: true,
|
|
2224
|
+
z: 200,
|
|
2225
|
+
});
|
|
2226
|
+
|
|
2227
|
+
watchKeys(neosh);
|
|
2228
|
+
const keys = await widgetKeys(neosh);
|
|
2229
|
+
const railLabel = opts.railLabel ?? "panes";
|
|
2230
|
+
const paneLabel = opts.paneLabel ?? "list";
|
|
2231
|
+
|
|
2232
|
+
// ---- rail rows, headings interleaved ------------------------------------
|
|
2233
|
+
type RailRow =
|
|
2234
|
+
| { kind: "heading"; text: string }
|
|
2235
|
+
| { kind: "entry"; item: RailItem<G>; index: number };
|
|
2236
|
+
|
|
2237
|
+
const railRows: RailRow[] = [];
|
|
2238
|
+
{
|
|
2239
|
+
let group: string | undefined;
|
|
2240
|
+
opts.rail.forEach((item, index) => {
|
|
2241
|
+
if (item.group !== undefined && item.group !== group) {
|
|
2242
|
+
group = item.group;
|
|
2243
|
+
railRows.push({ kind: "heading", text: item.group });
|
|
2244
|
+
}
|
|
2245
|
+
railRows.push({ kind: "entry", item, index });
|
|
2246
|
+
});
|
|
2247
|
+
}
|
|
2248
|
+
const selectableRail = railRows
|
|
2249
|
+
.map((r, at) => (r.kind === "entry" && !r.item.disabled ? at : -1))
|
|
2250
|
+
.filter((at) => at >= 0);
|
|
2251
|
+
|
|
2252
|
+
let railAt =
|
|
2253
|
+
selectableRail.find(
|
|
2254
|
+
(at) => (railRows[at] as { index: number }).index === (opts.railAt ?? 0),
|
|
2255
|
+
) ?? selectableRail[0] ?? 0;
|
|
2256
|
+
let railTop = 0;
|
|
2257
|
+
|
|
2258
|
+
// ---- pane state ----------------------------------------------------------
|
|
2259
|
+
let all: PaneItem<T>[] = [];
|
|
2260
|
+
let open = new Set<string>();
|
|
2261
|
+
let query = "";
|
|
2262
|
+
let cursor = 0;
|
|
2263
|
+
let paneTop = 0;
|
|
2264
|
+
/** In the rail, or in the list. */
|
|
2265
|
+
let focus: "rail" | "pane" = "pane";
|
|
2266
|
+
|
|
2267
|
+
type PaneRow =
|
|
2268
|
+
| { kind: "section"; name: string; count: number }
|
|
2269
|
+
| { kind: "item"; item: PaneItem<T>; positions: number[] };
|
|
2270
|
+
|
|
2271
|
+
let rows: PaneRow[] = [];
|
|
2272
|
+
|
|
2273
|
+
const rebuild = () => {
|
|
2274
|
+
const ranked = query === ""
|
|
2275
|
+
? all.map((item) => ({ item, positions: [] as number[], score: 0 }))
|
|
2276
|
+
: all
|
|
2277
|
+
.map((item) => {
|
|
2278
|
+
const hay = item.keywords ? `${item.label} ${item.keywords}` : item.label;
|
|
2279
|
+
const m = fuzzy(hay, query);
|
|
2280
|
+
return m ? { item, positions: m.positions, score: m.score } : null;
|
|
2281
|
+
})
|
|
2282
|
+
.filter((x): x is NonNullable<typeof x> => x !== null)
|
|
2283
|
+
.sort((a, b) => b.score - a.score);
|
|
2284
|
+
|
|
2285
|
+
const out: PaneRow[] = [];
|
|
2286
|
+
const sections = new Map<string, number>();
|
|
2287
|
+
for (const r of ranked) {
|
|
2288
|
+
const section = r.item.section;
|
|
2289
|
+
if (section === undefined) {
|
|
2290
|
+
out.push({ kind: "item", item: r.item, positions: r.positions });
|
|
2291
|
+
continue;
|
|
2292
|
+
}
|
|
2293
|
+
sections.set(section, (sections.get(section) ?? 0) + 1);
|
|
2294
|
+
}
|
|
2295
|
+
// Filtering opens every section: a row you cannot see is a row your query did not find, and
|
|
2296
|
+
// "no matches" while the match sits folded away is the worst possible answer.
|
|
2297
|
+
for (const [name, count] of sections) {
|
|
2298
|
+
const expanded = open.has(name) || query !== "";
|
|
2299
|
+
out.push({ kind: "section", name, count });
|
|
2300
|
+
if (!expanded) continue;
|
|
2301
|
+
for (const r of ranked) {
|
|
2302
|
+
if (r.item.section === name) out.push({ kind: "item", item: r.item, positions: r.positions });
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
rows = out;
|
|
2306
|
+
cursor = Math.min(cursor, Math.max(0, rows.length - 1));
|
|
2307
|
+
};
|
|
2308
|
+
|
|
2309
|
+
// Bumped on every rail move, so a slow load cannot land after a faster one that came later.
|
|
2310
|
+
let generation = 0;
|
|
2311
|
+
|
|
2312
|
+
const load = async () => {
|
|
2313
|
+
const mine = ++generation;
|
|
2314
|
+
const entry = railRows[railAt];
|
|
2315
|
+
const got = entry?.kind === "entry" ? await opts.items(entry.item.value).catch(() => []) : [];
|
|
2316
|
+
// Holding a movement key starts one load per row, and they finish in whatever order the
|
|
2317
|
+
// network allows — so the last *answer* is routinely not the last *question*. Without this,
|
|
2318
|
+
// pressing down eight times lands you on a provider showing the empty list of a provider you
|
|
2319
|
+
// passed through on the way, which reads as "this one has no models".
|
|
2320
|
+
if (mine !== generation) return;
|
|
2321
|
+
all = got;
|
|
2322
|
+
rebuild();
|
|
2323
|
+
cursor = Math.max(0, Math.min(opts.itemAt?.(all) ?? 0, Math.max(0, rows.length - 1)));
|
|
2324
|
+
// Land on something selectable rather than on a section header.
|
|
2325
|
+
if (rows[cursor]?.kind !== "item") cursor = rows.findIndex((r) => r.kind === "item");
|
|
2326
|
+
if (cursor < 0) cursor = 0;
|
|
2327
|
+
paneTop = 0;
|
|
2328
|
+
};
|
|
2329
|
+
|
|
2330
|
+
// ---- drawing -------------------------------------------------------------
|
|
2331
|
+
const RULE_V = "│";
|
|
2332
|
+
const railCell = (row: RailRow | undefined, on: boolean): { text: string; marks: Mark[] } => {
|
|
2333
|
+
const marks: Mark[] = [];
|
|
2334
|
+
if (!row) return { text: padToWidth("", railWidth), marks };
|
|
2335
|
+
if (row.kind === "heading") {
|
|
2336
|
+
const text = padToWidth(` ${row.text}`, railWidth);
|
|
2337
|
+
marks.push({ from: 0, to: byteLength(text), hl: "Sidebar.Heading" });
|
|
2338
|
+
return { text, marks };
|
|
2339
|
+
}
|
|
2340
|
+
const { item } = row;
|
|
2341
|
+
const mark = item.mark?.text ?? " ";
|
|
2342
|
+
const badge = item.badge?.text ?? "";
|
|
2343
|
+
const room = railWidth - 3 - width(mark) - width(badge) - 1;
|
|
2344
|
+
const label = clipToWidth(item.label, Math.max(1, room));
|
|
2345
|
+
const head = `${on ? "❯" : " "} ${mark} `;
|
|
2346
|
+
const body = `${head}${label}`;
|
|
2347
|
+
const text = padToWidth(body, railWidth - width(badge) - 1) + badge + " ";
|
|
2348
|
+
if (item.mark) {
|
|
2349
|
+
const at = byteLength(`${on ? "❯" : " "} `);
|
|
2350
|
+
marks.push({ from: at, to: at + byteLength(mark), hl: item.mark.hl ?? "Normal" });
|
|
2351
|
+
}
|
|
2352
|
+
if (item.badge) {
|
|
2353
|
+
const at = byteLength(padToWidth(body, railWidth - width(badge) - 1));
|
|
2354
|
+
marks.push({ from: at, to: at + byteLength(badge), hl: item.badge.hl ?? "Comment" });
|
|
2355
|
+
}
|
|
2356
|
+
if (on) {
|
|
2357
|
+
marks.push({ from: 0, to: byteLength(text), hl: "Picker.Selected", priority: 1 });
|
|
2358
|
+
} else if (item.disabled) {
|
|
2359
|
+
marks.push({ from: 0, to: byteLength(text), hl: "Comment" });
|
|
2360
|
+
}
|
|
2361
|
+
return { text, marks };
|
|
2362
|
+
};
|
|
2363
|
+
|
|
2364
|
+
/**
|
|
2365
|
+
* One row of the list, and — when the cursor is on it — the rest of what it had to say.
|
|
2366
|
+
*
|
|
2367
|
+
* `more` is what makes the three columns bearable. A model row is a name, a rung and a sentence
|
|
2368
|
+
* about it inside 45% of half a float, so the sentence is the first thing cut and the sentence is
|
|
2369
|
+
* the whole reason a catalogue of forty models is choosable at all. It goes under the row rather
|
|
2370
|
+
* than beside it, because the columns are what let you read *down* the list and widening one for
|
|
2371
|
+
* the row you are on would move the other two every time you pressed a key.
|
|
2372
|
+
*/
|
|
2373
|
+
const paneCell = (
|
|
2374
|
+
row: PaneRow | undefined,
|
|
2375
|
+
on: boolean,
|
|
2376
|
+
): { text: string; marks: Mark[]; more: string[] } => {
|
|
2377
|
+
const marks: Mark[] = [];
|
|
2378
|
+
const more: string[] = [];
|
|
2379
|
+
if (!row) return { text: "", marks, more };
|
|
2380
|
+
if (row.kind === "section") {
|
|
2381
|
+
const text = ` ${open.has(row.name) || query !== "" ? "▾" : "▸"} ${row.count} ${row.name}`;
|
|
2382
|
+
marks.push({ from: 0, to: byteLength(text), hl: on ? "Picker.Selected" : "Comment" });
|
|
2383
|
+
return { text, marks, more };
|
|
2384
|
+
}
|
|
2385
|
+
const { item } = row;
|
|
2386
|
+
const badge = item.badge?.text ?? "";
|
|
2387
|
+
const detail = item.detail ?? "";
|
|
2388
|
+
// Three columns: label, badge, detail. The badge column is fixed so the rungs of the ladder
|
|
2389
|
+
// line up — reading down it is how you see the shape of what a provider offers.
|
|
2390
|
+
const badgeWidth = badge === "" ? 0 : 10;
|
|
2391
|
+
const labelWidth = Math.max(10, Math.floor((paneWidth - 3 - badgeWidth) * 0.45));
|
|
2392
|
+
const head = on ? " ❯ " : " ";
|
|
2393
|
+
const room = paneWidth - width(head) - labelWidth - badgeWidth;
|
|
2394
|
+
// On the cursor's row the columns are broken at words and continued underneath; everywhere else
|
|
2395
|
+
// they are clipped, because a list of forty models is read by running an eye down it.
|
|
2396
|
+
const [labelHead, labelRest] = on
|
|
2397
|
+
? takeWords(item.label, labelWidth)
|
|
2398
|
+
: [clipToWidth(item.label, labelWidth), ""];
|
|
2399
|
+
const [detailHead, detailRest] = on
|
|
2400
|
+
? takeWords(detail, Math.max(0, room))
|
|
2401
|
+
: [clipToWidth(detail, Math.max(0, room)), ""];
|
|
2402
|
+
const label = padToWidth(labelHead, labelWidth);
|
|
2403
|
+
const badgeCell = badgeWidth === 0 ? "" : padToWidth(clipToWidth(badge, badgeWidth), badgeWidth);
|
|
2404
|
+
const text = `${head}${label}${badgeCell}${detailHead}`;
|
|
2405
|
+
|
|
2406
|
+
const labelAt = byteLength(head);
|
|
2407
|
+
const offsets = byteOffsets(item.label);
|
|
2408
|
+
for (const at of row.positions) {
|
|
2409
|
+
const from = offsets[at];
|
|
2410
|
+
const to = offsets[at + 1];
|
|
2411
|
+
if (from === undefined || to === undefined) continue;
|
|
2412
|
+
marks.push({ from: labelAt + from, to: labelAt + to, hl: "Picker.Match", priority: 200 });
|
|
2413
|
+
}
|
|
2414
|
+
if (badgeWidth > 0) {
|
|
2415
|
+
const at = labelAt + byteLength(label);
|
|
2416
|
+
marks.push({ from: at, to: at + byteLength(badgeCell), hl: item.badge?.hl ?? "Comment" });
|
|
2417
|
+
}
|
|
2418
|
+
if (detailHead !== "") {
|
|
2419
|
+
const at = labelAt + byteLength(label) + byteLength(badgeCell);
|
|
2420
|
+
marks.push({ from: at, to: byteLength(text), hl: "Picker.Detail" });
|
|
2421
|
+
}
|
|
2422
|
+
if (item.disabled) marks.push({ from: 0, to: byteLength(text), hl: "Comment" });
|
|
2423
|
+
if (on) marks.push({ from: 0, to: byteLength(text), hl: "Picker.Selected", priority: 1 });
|
|
2424
|
+
|
|
2425
|
+
if (on) {
|
|
2426
|
+
// The name first, if the name itself did not fit — an id is what you are choosing by, and
|
|
2427
|
+
// half of one is not a thing you can choose by.
|
|
2428
|
+
const nameCol = width(head);
|
|
2429
|
+
if (labelRest.trim() !== "") {
|
|
2430
|
+
for (const line of wrapToWidth(labelRest.trim(), Math.max(8, paneWidth - nameCol - 2))) {
|
|
2431
|
+
more.push(`${" ".repeat(nameCol)}${line}`);
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
const detailCol = nameCol + labelWidth + badgeWidth;
|
|
2435
|
+
if (detailRest.trim() !== "") {
|
|
2436
|
+
for (const line of wrapToWidth(detailRest.trim(), Math.max(8, paneWidth - detailCol - 1))) {
|
|
2437
|
+
more.push(`${" ".repeat(detailCol)}${line}`);
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
return { text, marks, more };
|
|
2442
|
+
};
|
|
2443
|
+
|
|
2444
|
+
interface Mark {
|
|
2445
|
+
from: number;
|
|
2446
|
+
to: number;
|
|
2447
|
+
hl: string;
|
|
2448
|
+
priority?: number;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
const render = async () => {
|
|
2452
|
+
// Each pane scrolls on its own: moving down a long model list must not scroll the rail out
|
|
2453
|
+
// from under the provider you are looking at.
|
|
2454
|
+
if (railAt < railTop) railTop = railAt;
|
|
2455
|
+
if (railAt >= railTop + height) railTop = railAt - height + 1;
|
|
2456
|
+
// The unfolded row is paid for out of the list rather than out of the float: growing the float
|
|
2457
|
+
// instead would move the key strip under the reader's eyes on every keystroke, and the strip
|
|
2458
|
+
// is the row that says how to get out.
|
|
2459
|
+
const unfolded = focus === "pane" ? paneCell(rows[cursor], true).more.length : 0;
|
|
2460
|
+
const room = Math.max(1, height - unfolded);
|
|
2461
|
+
if (cursor < paneTop) paneTop = cursor;
|
|
2462
|
+
if (cursor >= paneTop + room) paneTop = cursor - room + 1;
|
|
2463
|
+
|
|
2464
|
+
const lines: string[] = [];
|
|
2465
|
+
const marks: { line: number; mark: Mark }[] = [];
|
|
2466
|
+
|
|
2467
|
+
// The title row, and at its right edge the key for *the pane you are not in*. Live rather
|
|
2468
|
+
// than a legend: the shortcut row below can only say the key exists, and a two-pane widget
|
|
2469
|
+
// whose second pane has no visible way in is a pane nobody finds.
|
|
2470
|
+
const title = opts.title ?? "";
|
|
2471
|
+
const cross = focus === "pane" ? `${keyLabel(keys, "pane_prev")} ${railLabel}` : `${keyLabel(keys, "pane_next")} ${paneLabel}`;
|
|
2472
|
+
const gap = Math.max(1, total - width(title) - width(cross));
|
|
2473
|
+
lines.push(`${title}${" ".repeat(gap)}${cross}`);
|
|
2474
|
+
marks.push({ line: 0, mark: { from: 0, to: byteLength(title), hl: "Float.Title" } });
|
|
2475
|
+
marks.push({
|
|
2476
|
+
line: 0,
|
|
2477
|
+
mark: {
|
|
2478
|
+
from: byteLength(title) + gap,
|
|
2479
|
+
to: byteLength(lines[0]!),
|
|
2480
|
+
hl: "Composer.HintKey",
|
|
2481
|
+
},
|
|
2482
|
+
});
|
|
2483
|
+
lines.push(`> ${query}`);
|
|
2484
|
+
lines.push("─".repeat(railWidth) + "┬" + "─".repeat(paneWidth));
|
|
2485
|
+
marks.push({ line: 2, mark: { from: 0, to: byteLength(lines[2]!), hl: "Separator" } });
|
|
2486
|
+
|
|
2487
|
+
const empty = rows.length === 0
|
|
2488
|
+
? ` ${query === "" ? (opts.placeholder ?? "nothing here") : "no matches"}`
|
|
2489
|
+
: null;
|
|
2490
|
+
|
|
2491
|
+
// The two columns are filled independently and then zipped, because they no longer advance
|
|
2492
|
+
// together: the pane's cursor row is several lines tall when it has unfolded, and the rail
|
|
2493
|
+
// beside it must keep listing providers one per line rather than skipping the ones the
|
|
2494
|
+
// unfolded row happened to sit across.
|
|
2495
|
+
const paneLines: { text: string; marks: Mark[] }[] = [];
|
|
2496
|
+
if (empty !== null) {
|
|
2497
|
+
paneLines.push({ text: empty, marks: [{ from: 0, to: byteLength(empty), hl: "Comment" }] });
|
|
2498
|
+
} else {
|
|
2499
|
+
for (let i = paneTop; i < rows.length && paneLines.length < height; i++) {
|
|
2500
|
+
const on = focus === "pane" && i === cursor;
|
|
2501
|
+
const cell = paneCell(rows[i], on);
|
|
2502
|
+
paneLines.push({ text: cell.text, marks: cell.marks });
|
|
2503
|
+
if (!on) continue;
|
|
2504
|
+
for (const text of cell.more) {
|
|
2505
|
+
paneLines.push({
|
|
2506
|
+
text,
|
|
2507
|
+
marks: [{ from: 0, to: byteLength(text), hl: "Picker.Selected", priority: 1 }],
|
|
2508
|
+
});
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
for (let i = 0; i < height; i++) {
|
|
2514
|
+
const left = railCell(railRows[railTop + i], focus === "rail" && railTop + i === railAt);
|
|
2515
|
+
const right = paneLines[i] ?? { text: "", marks: [] as Mark[] };
|
|
2516
|
+
const line = lines.length;
|
|
2517
|
+
lines.push(`${left.text}${RULE_V}${right.text}`);
|
|
2518
|
+
for (const m of left.marks) marks.push({ line, mark: m });
|
|
2519
|
+
const shift = byteLength(left.text) + byteLength(RULE_V);
|
|
2520
|
+
marks.push({ line, mark: { from: byteLength(left.text), to: shift, hl: "Separator" } });
|
|
2521
|
+
for (const m of right.marks) {
|
|
2522
|
+
marks.push({ line, mark: { from: m.from + shift, to: m.to + shift, hl: m.hl, priority: m.priority } });
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
lines.push("─".repeat(railWidth) + "┴" + "─".repeat(paneWidth));
|
|
2527
|
+
marks.push({
|
|
2528
|
+
line: lines.length - 1,
|
|
2529
|
+
mark: { from: 0, to: byteLength(lines[lines.length - 1]!), hl: "Separator" },
|
|
2530
|
+
});
|
|
2531
|
+
const hints = opts.hints ?? "↵ use ⇥ panes ^N/^P move esc close";
|
|
2532
|
+
lines.push(` ${hints}`);
|
|
2533
|
+
marks.push({
|
|
2534
|
+
line: lines.length - 1,
|
|
2535
|
+
mark: { from: 0, to: byteLength(lines[lines.length - 1]!), hl: "Sidebar.Dim" },
|
|
2536
|
+
});
|
|
2537
|
+
|
|
2538
|
+
// The marks were already collected against their rows; they now travel with them. One call
|
|
2539
|
+
// rather than one per mark, so there is no frame in which the text has arrived and the colour
|
|
2540
|
+
// has not — an unmarked row draws in `Normal`, which is near-white.
|
|
2541
|
+
const drawn: DrawnRow[] = lines.map((text) => ({ text, marks: [] }));
|
|
2542
|
+
for (const { line, mark } of marks) {
|
|
2543
|
+
drawn[line]?.marks!.push({
|
|
2544
|
+
col: mark.from,
|
|
2545
|
+
opts: { hlGroup: mark.hl, endCol: mark.to, priority: mark.priority ?? 0 },
|
|
2546
|
+
});
|
|
2547
|
+
}
|
|
2548
|
+
await neosh.buf.render(buf, ns, 0, -1, drawn);
|
|
2549
|
+
// The caret belongs where the typing goes. Parked at the origin, the filter reads as inert.
|
|
2550
|
+
await neosh.win.setCursor(win, 1, byteLength(`> ${query}`));
|
|
2551
|
+
};
|
|
2552
|
+
|
|
2553
|
+
// ---- keys ----------------------------------------------------------------
|
|
2554
|
+
let settle: (v: T | null) => void = () => {};
|
|
2555
|
+
const done = new Promise<T | null>((resolve) => {
|
|
2556
|
+
settle = resolve;
|
|
2557
|
+
});
|
|
2558
|
+
|
|
2559
|
+
const command = `${RAIL_NS}.key.${++pickerSeq}`;
|
|
2560
|
+
const disposers: Disposable[] = [];
|
|
2561
|
+
let closed = false;
|
|
2562
|
+
const close = async (value: T | null) => {
|
|
2563
|
+
if (closed) return;
|
|
2564
|
+
closed = true;
|
|
2565
|
+
for (const d of disposers) d.dispose();
|
|
2566
|
+
await neosh.win.close(win).catch(() => {});
|
|
2567
|
+
settle(value);
|
|
2568
|
+
};
|
|
2569
|
+
|
|
2570
|
+
const moveRail = async (delta: number) => {
|
|
2571
|
+
const at = selectableRail.indexOf(railAt);
|
|
2572
|
+
const next = selectableRail[Math.min(selectableRail.length - 1, Math.max(0, at + delta))];
|
|
2573
|
+
if (next === undefined || next === railAt) return;
|
|
2574
|
+
railAt = next;
|
|
2575
|
+
// The filter belongs to the list that was showing. Carrying it to a different provider hides
|
|
2576
|
+
// most of what you just switched to, for a reason that has scrolled off the screen.
|
|
2577
|
+
query = "";
|
|
2578
|
+
await load();
|
|
2579
|
+
};
|
|
2580
|
+
|
|
2581
|
+
/**
|
|
2582
|
+
* Move the list cursor, stepping over rows that cannot be landed on.
|
|
2583
|
+
*
|
|
2584
|
+
* Section headers *are* landable — `↵` on one folds it — so the only thing skipped is a row the
|
|
2585
|
+
* caller marked unusable. Skipping in the direction of travel, and giving up at the end rather
|
|
2586
|
+
* than reversing, so holding a movement key never bounces.
|
|
2587
|
+
*/
|
|
2588
|
+
const movePane = (delta: number) => {
|
|
2589
|
+
const step = delta > 0 ? 1 : -1;
|
|
2590
|
+
let at = cursor;
|
|
2591
|
+
for (let n = 0; n < Math.abs(delta); n++) {
|
|
2592
|
+
let candidate = at + step;
|
|
2593
|
+
while (candidate >= 0 && candidate < rows.length) {
|
|
2594
|
+
const row = rows[candidate];
|
|
2595
|
+
if (row && !(row.kind === "item" && row.item.disabled)) break;
|
|
2596
|
+
candidate += step;
|
|
2597
|
+
}
|
|
2598
|
+
if (candidate < 0 || candidate >= rows.length) break;
|
|
2599
|
+
at = candidate;
|
|
2600
|
+
}
|
|
2601
|
+
cursor = Math.max(0, Math.min(Math.max(0, rows.length - 1), at));
|
|
2602
|
+
};
|
|
2603
|
+
|
|
2604
|
+
const currentItem = (): PaneItem<T> | undefined => {
|
|
2605
|
+
const row = rows[cursor];
|
|
2606
|
+
return row?.kind === "item" ? row.item : undefined;
|
|
2607
|
+
};
|
|
2608
|
+
|
|
2609
|
+
disposers.push(
|
|
2610
|
+
await neosh.cmd.register(command, async (_args, key) => {
|
|
2611
|
+
if (!key) return;
|
|
2612
|
+
const railEntry = railRows[railAt];
|
|
2613
|
+
const outcome = await opts.onKey?.(key, {
|
|
2614
|
+
rail: railEntry?.kind === "entry" ? railEntry.item.value : undefined,
|
|
2615
|
+
item: currentItem()?.value,
|
|
2616
|
+
});
|
|
2617
|
+
if (outcome === "close") {
|
|
2618
|
+
await close(null);
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
if (outcome === "reload") {
|
|
2622
|
+
await load();
|
|
2623
|
+
await render();
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
if (outcome === "handled") {
|
|
2627
|
+
await render();
|
|
2628
|
+
return;
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
switch (actionFor(keys, key.key, RAIL_ACTIONS)) {
|
|
2632
|
+
case "dismiss":
|
|
2633
|
+
await close(null);
|
|
2634
|
+
return;
|
|
2635
|
+
case "accept": {
|
|
2636
|
+
const row = rows[cursor];
|
|
2637
|
+
if (focus === "rail") {
|
|
2638
|
+
focus = "pane";
|
|
2639
|
+
break;
|
|
2640
|
+
}
|
|
2641
|
+
if (row?.kind === "section") {
|
|
2642
|
+
if (open.has(row.name)) open.delete(row.name);
|
|
2643
|
+
else open.add(row.name);
|
|
2644
|
+
rebuild();
|
|
2645
|
+
break;
|
|
2646
|
+
}
|
|
2647
|
+
const item = row?.kind === "item" ? row.item : undefined;
|
|
2648
|
+
if (!item || item.disabled) return;
|
|
2649
|
+
await close(item.value);
|
|
2650
|
+
return;
|
|
2651
|
+
}
|
|
2652
|
+
case "pane_next":
|
|
2653
|
+
focus = "pane";
|
|
2654
|
+
break;
|
|
2655
|
+
case "pane_prev":
|
|
2656
|
+
focus = "rail";
|
|
2657
|
+
break;
|
|
2658
|
+
case "next":
|
|
2659
|
+
if (focus === "rail") await moveRail(1);
|
|
2660
|
+
else movePane(1);
|
|
2661
|
+
break;
|
|
2662
|
+
case "prev":
|
|
2663
|
+
if (focus === "rail") await moveRail(-1);
|
|
2664
|
+
else movePane(-1);
|
|
2665
|
+
break;
|
|
2666
|
+
case "page_down":
|
|
2667
|
+
if (focus === "pane") movePane(height);
|
|
2668
|
+
break;
|
|
2669
|
+
case "page_up":
|
|
2670
|
+
if (focus === "pane") movePane(-height);
|
|
2671
|
+
break;
|
|
2672
|
+
case "first":
|
|
2673
|
+
if (focus === "pane") cursor = Math.max(0, rows.findIndex((r) => r.kind === "item"));
|
|
2674
|
+
break;
|
|
2675
|
+
case "last":
|
|
2676
|
+
if (focus === "pane") cursor = rows.length - 1;
|
|
2677
|
+
break;
|
|
2678
|
+
case "clear":
|
|
2679
|
+
query = "";
|
|
2680
|
+
rebuild();
|
|
2681
|
+
break;
|
|
2682
|
+
case "delete_word":
|
|
2683
|
+
query = dropSegment(query);
|
|
2684
|
+
rebuild();
|
|
2685
|
+
break;
|
|
2686
|
+
default: {
|
|
2687
|
+
if (key.key.code.kind === "backspace") {
|
|
2688
|
+
query = query.slice(0, -1);
|
|
2689
|
+
rebuild();
|
|
2690
|
+
break;
|
|
2691
|
+
}
|
|
2692
|
+
if (key.key.code.kind !== "char" || key.key.mods.ctrl || key.key.mods.alt) return;
|
|
2693
|
+
// Typing is about the list, wherever the focus is: nobody switches panes first.
|
|
2694
|
+
focus = "pane";
|
|
2695
|
+
query += key.key.code.c;
|
|
2696
|
+
cursor = 0;
|
|
2697
|
+
rebuild();
|
|
2698
|
+
if (rows[cursor]?.kind !== "item") movePane(1);
|
|
2699
|
+
break;
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
await render();
|
|
2703
|
+
}, { desc: "rail picker key" }),
|
|
2704
|
+
);
|
|
2705
|
+
|
|
2706
|
+
await neosh.focus.push(win);
|
|
2707
|
+
disposers.push(await neosh.keymap.capture(win, command));
|
|
2708
|
+
await bindWidgetKeys(neosh, win, command, keys, opts.ownKeys ?? []);
|
|
2709
|
+
await load();
|
|
2710
|
+
await render();
|
|
2711
|
+
return done;
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
|
|
2715
|
+
// ---------------------------------------------------------------------------
|
|
2716
|
+
// What a panel owes the plugins that build on it
|
|
2717
|
+
//
|
|
2718
|
+
// A section somebody contributed, placed by name; a decoration somebody put on one of the panel's
|
|
2719
|
+
// own rows; the row itself with the decoration applied. Three functions any list panel can use —
|
|
2720
|
+
// the bundled sidebar does — so that `sidebar.section`, `acme.tasks.section` and every other
|
|
2721
|
+
// `<kind>.section` mean exactly the same thing to the plugin contributing to them.
|
|
2722
|
+
// ---------------------------------------------------------------------------
|
|
2723
|
+
|
|
2724
|
+
/** A block of rows somebody else contributed to a panel. Data, so it can be listed and disabled. */
|
|
2725
|
+
export interface SectionItem {
|
|
2726
|
+
/** Drawn as a heading with a rule under it. Omit for rows with no heading. */
|
|
2727
|
+
title?: string;
|
|
2728
|
+
/** The way in, drawn dim at the right of the heading: `^L`, `^G`. */
|
|
2729
|
+
hint?: string;
|
|
2730
|
+
/** Coarse placement relative to the panel's own blocks. Defaults to `below`. */
|
|
2731
|
+
at?: "above" | "below";
|
|
2732
|
+
/** Finer: sit directly before or after one of the panel's slots, or another section's id. */
|
|
2733
|
+
before?: string;
|
|
2734
|
+
after?: string;
|
|
2735
|
+
rows?: Array<{
|
|
2736
|
+
text: string;
|
|
2737
|
+
hl?: string;
|
|
2738
|
+
/** Highlights for pieces of the row: UTF-8 byte offsets into *your* `text`. */
|
|
2739
|
+
spans?: Array<{ from: number; to: number; hl: string }>;
|
|
2740
|
+
right?: { text: string; hl?: string };
|
|
2741
|
+
/** Run on `↵`. Without one the row is inert — a label rather than a verb. */
|
|
2742
|
+
command?: string;
|
|
2743
|
+
args?: string[];
|
|
2744
|
+
}>;
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
/** A verb on a panel's rows, contributed by somebody else and bound by the panel. */
|
|
2748
|
+
export interface ActionItem {
|
|
2749
|
+
/** Key notation, as `keymap.set` takes it. */
|
|
2750
|
+
key: string;
|
|
2751
|
+
/** For the hint strip and `^Z`. */
|
|
2752
|
+
label: string;
|
|
2753
|
+
command: string;
|
|
2754
|
+
/** Which rows it applies to — a row kind the panel names, `custom` for contributed rows, or `any`. */
|
|
2755
|
+
on?: string;
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
/** A mark on a row a panel already draws, keyed by what the row is about. */
|
|
2759
|
+
export interface DecorationItem {
|
|
2760
|
+
/** The row it is about, in the panel's own terms: `{ project: cwd }`, `{ task: id }`. */
|
|
2761
|
+
target: Record<string, string>;
|
|
2762
|
+
/** A short mark after the name, in `hl`. The name is clipped to make room. */
|
|
2763
|
+
badge?: { text: string; hl?: string };
|
|
2764
|
+
/** The row's highlight, when the panel has no opinion of its own. */
|
|
2765
|
+
hl?: string;
|
|
2766
|
+
/** The right-hand column, on a row that is not busy. */
|
|
2767
|
+
right?: { text: string; hl?: string };
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
/** Every decoration on one target, merged. */
|
|
2771
|
+
export interface Decoration {
|
|
2772
|
+
badge?: { text: string; hl?: string };
|
|
2773
|
+
hl?: string;
|
|
2774
|
+
right?: { text: string; hl?: string };
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
/** The key a decoration's `target` files under: `project:/w/x`, `task:17`. */
|
|
2778
|
+
export function decorationKey(target: unknown): string | null {
|
|
2779
|
+
if (!target || typeof target !== "object") return null;
|
|
2780
|
+
const entries = Object.entries(target as Record<string, unknown>)
|
|
2781
|
+
.filter(([, v]) => typeof v === "string")
|
|
2782
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
2783
|
+
const first = entries[0];
|
|
2784
|
+
return first ? `${first[0]}:${first[1] as string}` : null;
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
/**
|
|
2788
|
+
* Every decoration on a point, folded by target: later contributions (lower priority) fill in what
|
|
2789
|
+
* earlier ones left unsaid, and badges are joined with a space, because two plugins each with one
|
|
2790
|
+
* word to say about a row are both right.
|
|
2791
|
+
*/
|
|
2792
|
+
export function mergeDecorations(
|
|
2793
|
+
items: Array<Contribution & { item: DecorationItem }>,
|
|
2794
|
+
): Map<string, Decoration> {
|
|
2795
|
+
const out = new Map<string, Decoration>();
|
|
2796
|
+
for (const c of items) {
|
|
2797
|
+
const key = decorationKey(c.item?.target);
|
|
2798
|
+
if (key === null) continue;
|
|
2799
|
+
const d = out.get(key) ?? {};
|
|
2800
|
+
const badge = c.item.badge;
|
|
2801
|
+
if (typeof badge?.text === "string" && badge.text !== "") {
|
|
2802
|
+
d.badge = d.badge
|
|
2803
|
+
? { text: `${d.badge.text} ${badge.text}`, hl: d.badge.hl }
|
|
2804
|
+
: { text: badge.text, hl: badge.hl };
|
|
2805
|
+
}
|
|
2806
|
+
if (d.hl === undefined && typeof c.item.hl === "string") d.hl = c.item.hl;
|
|
2807
|
+
if (d.right === undefined && typeof c.item.right?.text === "string") {
|
|
2808
|
+
d.right = { text: c.item.right.text, hl: c.item.right.hl };
|
|
2809
|
+
}
|
|
2810
|
+
out.set(key, d);
|
|
2811
|
+
}
|
|
2812
|
+
return out;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
/** How many columns a decoration's badge will take, for a row builder to leave free. */
|
|
2816
|
+
export function badgeWidth(d: Decoration | undefined): number {
|
|
2817
|
+
return d?.badge ? byteLength(` ${d.badge.text}`) : 0;
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
/**
|
|
2821
|
+
* A decoration, applied to a row the panel built.
|
|
2822
|
+
*
|
|
2823
|
+
* The badge goes on the end of the clipped text with its own span — the builder left room for it
|
|
2824
|
+
* with {@link badgeWidth}. `hl` only fills a row the panel left plain; `right` only replaces the
|
|
2825
|
+
* column on a row that is not `busy`, because a row's own state — working, asking, failed — is
|
|
2826
|
+
* what the column exists to show.
|
|
2827
|
+
*/
|
|
2828
|
+
export function decorateRow<T>(
|
|
2829
|
+
row: ListRow<T>,
|
|
2830
|
+
d: Decoration | undefined,
|
|
2831
|
+
busy = false,
|
|
2832
|
+
): ListRow<T> {
|
|
2833
|
+
if (!d) return row;
|
|
2834
|
+
if (d.badge) {
|
|
2835
|
+
const mark = ` ${d.badge.text}`;
|
|
2836
|
+
const from = byteLength(row.text);
|
|
2837
|
+
row.text = `${row.text}${mark}`;
|
|
2838
|
+
if (row.full !== undefined) row.full = `${row.full}${mark}`;
|
|
2839
|
+
if (d.badge.hl) {
|
|
2840
|
+
row.spans = [...(row.spans ?? []), { from, to: from + byteLength(mark), hl: d.badge.hl }];
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
if (d.hl && row.hl === undefined) row.hl = d.hl;
|
|
2844
|
+
if (d.right && !busy) row.right = { text: `${d.right.text} `, hl: d.right.hl };
|
|
2845
|
+
return row;
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
/**
|
|
2849
|
+
* Where every contributed section goes, as a walk over a panel's slots.
|
|
2850
|
+
*
|
|
2851
|
+
* `before`/`after` names a slot or another section's id; `at` is the coarse version. An anchor on
|
|
2852
|
+
* another section resolves once that section has found its place, so a chain settles in a pass or
|
|
2853
|
+
* two, and a section whose anchor never appears falls back to `at`. Sections with the same
|
|
2854
|
+
* placement keep the registry's priority order.
|
|
2855
|
+
*/
|
|
2856
|
+
export function placeSections<S extends string>(
|
|
2857
|
+
slots: readonly S[],
|
|
2858
|
+
sections: Array<Contribution & { item: SectionItem }>,
|
|
2859
|
+
): Array<S | (Contribution & { item: SectionItem })> {
|
|
2860
|
+
type Entry = S | (Contribution & { item: SectionItem });
|
|
2861
|
+
const order: Entry[] = [...slots];
|
|
2862
|
+
const idOf = (e: Entry) => (typeof e === "string" ? e : e.id);
|
|
2863
|
+
const indexOf = (name: string) => order.findIndex((e) => idOf(e) === name);
|
|
2864
|
+
const first = slots[0];
|
|
2865
|
+
|
|
2866
|
+
let pending = sections.filter((c) => c.item && typeof c.item === "object");
|
|
2867
|
+
for (let pass = 0; pass < 8 && pending.length > 0; pass++) {
|
|
2868
|
+
const next: typeof pending = [];
|
|
2869
|
+
for (const c of pending) {
|
|
2870
|
+
const before = typeof c.item.before === "string" ? indexOf(c.item.before) : -1;
|
|
2871
|
+
const after = typeof c.item.after === "string" ? indexOf(c.item.after) : -1;
|
|
2872
|
+
if (before >= 0) order.splice(before, 0, c);
|
|
2873
|
+
else if (after >= 0) order.splice(after + 1, 0, c);
|
|
2874
|
+
else if (typeof c.item.before === "string" || typeof c.item.after === "string") {
|
|
2875
|
+
next.push(c);
|
|
2876
|
+
} else if ((c.item.at ?? "below") === "above" && first !== undefined) {
|
|
2877
|
+
order.splice(indexOf(first), 0, c);
|
|
2878
|
+
} else {
|
|
2879
|
+
order.push(c);
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
if (next.length === pending.length) {
|
|
2883
|
+
for (const c of next) {
|
|
2884
|
+
if ((c.item.at ?? "below") === "above" && first !== undefined) {
|
|
2885
|
+
order.splice(indexOf(first), 0, c);
|
|
2886
|
+
} else order.push(c);
|
|
2887
|
+
}
|
|
2888
|
+
break;
|
|
2889
|
+
}
|
|
2890
|
+
pending = next;
|
|
2891
|
+
}
|
|
2892
|
+
return order;
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
/**
|
|
2896
|
+
* Rows for a contributed section, every field checked rather than trusted.
|
|
2897
|
+
*
|
|
2898
|
+
* A contribution is JSON from a plugin the panel has never heard of, and a panel that throws on a
|
|
2899
|
+
* missing `text` is a panel a third party can break by getting one row wrong.
|
|
2900
|
+
*/
|
|
2901
|
+
export function sectionRows<T>(
|
|
2902
|
+
c: Contribution & { item: SectionItem },
|
|
2903
|
+
opts: { width: number; custom: (command: string, args: string[]) => T },
|
|
2904
|
+
): ListRow<T>[] {
|
|
2905
|
+
const rows: ListRow<T>[] = [];
|
|
2906
|
+
const contributed = Array.isArray(c.item?.rows) ? c.item.rows : [];
|
|
2907
|
+
if (contributed.length === 0 && !c.item?.title) return rows;
|
|
2908
|
+
rows.push({ text: "", inert: true });
|
|
2909
|
+
if (typeof c.item.title === "string" && c.item.title !== "") {
|
|
2910
|
+
const hint = typeof c.item.hint === "string" ? c.item.hint : undefined;
|
|
2911
|
+
rows.push({
|
|
2912
|
+
text: ` ${clipToWidth(c.item.title, Math.max(1, opts.width - 2))}`,
|
|
2913
|
+
hl: "Sidebar.Heading",
|
|
2914
|
+
right: hint ? { text: `${hint} `, hl: "Sidebar.Dim" } : undefined,
|
|
2915
|
+
inert: true,
|
|
2916
|
+
});
|
|
2917
|
+
rows.push({ text: "─".repeat(Math.max(1, opts.width)), hl: "Separator", inert: true });
|
|
2918
|
+
}
|
|
2919
|
+
const margin = " ";
|
|
2920
|
+
for (const r of contributed) {
|
|
2921
|
+
if (typeof r?.text !== "string") continue;
|
|
2922
|
+
const text = `${margin}${clipToWidth(r.text, Math.max(4, opts.width - 2 - margin.length))}`;
|
|
2923
|
+
const eol = byteLength(text);
|
|
2924
|
+
const spans = Array.isArray(r.spans)
|
|
2925
|
+
? r.spans.flatMap((sp) => {
|
|
2926
|
+
if (typeof sp?.from !== "number" || typeof sp?.to !== "number" || typeof sp?.hl !== "string") {
|
|
2927
|
+
return [];
|
|
2928
|
+
}
|
|
2929
|
+
const from = Math.max(0, Math.floor(sp.from)) + byteLength(margin);
|
|
2930
|
+
const to = Math.min(Math.max(0, Math.floor(sp.to)) + byteLength(margin), eol);
|
|
2931
|
+
return to > from ? [{ from, to, hl: sp.hl }] : [];
|
|
2932
|
+
})
|
|
2933
|
+
: [];
|
|
2934
|
+
const args = Array.isArray(r.args) ? r.args.filter((a): a is string => typeof a === "string") : [];
|
|
2935
|
+
rows.push({
|
|
2936
|
+
text,
|
|
2937
|
+
full: `${margin}${r.text}`,
|
|
2938
|
+
indent: margin.length,
|
|
2939
|
+
hl: typeof r.hl === "string" ? r.hl : undefined,
|
|
2940
|
+
spans: spans.length > 0 ? spans : undefined,
|
|
2941
|
+
right: typeof r.right?.text === "string" ? { text: `${r.right.text} `, hl: r.right.hl } : undefined,
|
|
2942
|
+
inert: typeof r.command !== "string",
|
|
2943
|
+
value: typeof r.command === "string" ? opts.custom(r.command, args) : undefined,
|
|
2944
|
+
});
|
|
2945
|
+
}
|
|
2946
|
+
return rows;
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
// ---------------------------------------------------------------------------
|
|
2950
|
+
// ListPanel
|
|
2951
|
+
// ---------------------------------------------------------------------------
|
|
2952
|
+
|
|
2953
|
+
/** What a {@link ListPanel} needs to know about its own rows. */
|
|
2954
|
+
export interface ListPanelOptions<T> {
|
|
2955
|
+
/** The buffer kind — `acme.tasks`. Everything else hangs off it: the points are
|
|
2956
|
+
* `<kind>.section`, `<kind>.action`, `<kind>.decoration`; the verbs are `<kind>.up` and so on;
|
|
2957
|
+
* the cursor event is `<kind>.cursor`. */
|
|
2958
|
+
kind: string;
|
|
2959
|
+
/** Buffer name. Defaults to `[<kind>]`. */
|
|
2960
|
+
name?: string;
|
|
2961
|
+
dock?: "left" | "right" | "bottom";
|
|
2962
|
+
/** Columns (or rows, for a bottom dock). Asked on every open and draw. */
|
|
2963
|
+
size?: () => number | Promise<number>;
|
|
2964
|
+
/** The panel's own rows, rebuilt on every draw. A row's `value` is what every verb receives. */
|
|
2965
|
+
rows: () => ListRow<T>[] | Promise<ListRow<T>[]>;
|
|
2966
|
+
/** A row's identity, for anchoring the cursor across redraws and filing decorations:
|
|
2967
|
+
* `{ project: cwd }` becomes `project:<cwd>`. */
|
|
2968
|
+
key?: (value: T) => Record<string, string> | null;
|
|
2969
|
+
/** The row kind a contributed action's `on` may name: `"project"`, `"session"`. */
|
|
2970
|
+
kindOf?: (value: T) => string;
|
|
2971
|
+
/** The arguments a contributed action receives for a row. Defaults to `[kindOf(value), ...key values]`. */
|
|
2972
|
+
argsFor?: (value: T) => string[];
|
|
2973
|
+
/** `↵` on a row. A contributed row runs its own command instead. */
|
|
2974
|
+
onOpen?: (value: T) => void | Promise<void>;
|
|
2975
|
+
/** Named blocks a section may sit `before`/`after`. Defaults to `["main"]`; `rows` fill the first. */
|
|
2976
|
+
slots?: readonly string[];
|
|
2977
|
+
cursorHl?: string;
|
|
2978
|
+
/** Open the panel as soon as it is created. Default true. */
|
|
2979
|
+
open?: boolean;
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
/** A contributed row's value, for panels whose own rows are something else. */
|
|
2983
|
+
export type CustomRow = { kind: "custom"; command: string; args: string[] };
|
|
2984
|
+
|
|
2985
|
+
/**
|
|
2986
|
+
* A docked list panel that is a surface, not a program — the three panel mechanisms, and a
|
|
2987
|
+
* published cursor, for the price of a kind and a `rows` function.
|
|
2988
|
+
*
|
|
2989
|
+
* Given `kind: "acme.tasks"`, this creates the buffer with that kind, opens it in a dock, binds
|
|
2990
|
+
* every verb as a named command at `buf_kind` scope (`acme.tasks.down`, `.up`, `.first`, `.last`,
|
|
2991
|
+
* `.open`, `.leave`, `.toggle`, `.focus`, `.refresh`, `.cursor`, `.rows`) so `^Z` lists them and
|
|
2992
|
+
* `init.ts` can move them, reads `acme.tasks.section` / `.action` / `.decoration` exactly as the
|
|
2993
|
+
* sidebar reads its own, publishes the row under the cursor as the buffer var `cursor` and the
|
|
2994
|
+
* event `acme.tasks.cursor`, and redraws when any of that changes. What is left for the plugin is
|
|
2995
|
+
* what the rows say.
|
|
2996
|
+
*/
|
|
2997
|
+
export class ListPanel<T> {
|
|
2998
|
+
private buf!: BufferId;
|
|
2999
|
+
private ns!: number;
|
|
3000
|
+
private list!: CursoredList<T | CustomRow>;
|
|
3001
|
+
private win: WindowId | null = null;
|
|
3002
|
+
private focused = false;
|
|
3003
|
+
private capture: Disposable | null = null;
|
|
3004
|
+
private width = 30;
|
|
3005
|
+
private actions: Array<Contribution & { item: ActionItem }> = [];
|
|
3006
|
+
private bound: Array<{ key: string; command: string }> = [];
|
|
3007
|
+
private registered: Disposable[] = [];
|
|
3008
|
+
private drawing = false;
|
|
3009
|
+
private again = false;
|
|
3010
|
+
readonly subscriptions: Disposable[] = [];
|
|
3011
|
+
|
|
3012
|
+
private constructor(
|
|
3013
|
+
private readonly neosh: Neosh,
|
|
3014
|
+
private readonly opts: ListPanelOptions<T>,
|
|
3015
|
+
) {}
|
|
3016
|
+
|
|
3017
|
+
/** Create the panel: buffer, verbs, points, and — unless `open: false` — the window. */
|
|
3018
|
+
static async create<T>(neosh: Neosh, opts: ListPanelOptions<T>): Promise<ListPanel<T>> {
|
|
3019
|
+
const p = new ListPanel<T>(neosh, opts);
|
|
3020
|
+
await p.setup();
|
|
3021
|
+
if (opts.open !== false) await p.open();
|
|
3022
|
+
return p;
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
get kind(): string {
|
|
3026
|
+
return this.opts.kind;
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
/** The contribution points this panel reads. */
|
|
3030
|
+
get points(): { section: string; action: string; decoration: string } {
|
|
3031
|
+
const k = this.opts.kind;
|
|
3032
|
+
return { section: `${k}.section`, action: `${k}.action`, decoration: `${k}.decoration` };
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
/** The row under the cursor, or `null`. */
|
|
3036
|
+
get cursor(): T | CustomRow | null {
|
|
3037
|
+
return this.list.value ?? null;
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
/** Every row that can be landed on. */
|
|
3041
|
+
get rows(): Array<T | CustomRow> {
|
|
3042
|
+
return this.list.values;
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
isOpen(): boolean {
|
|
3046
|
+
return this.win !== null;
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
private keyOf(value: T | CustomRow): string | null {
|
|
3050
|
+
if (this.isCustom(value)) return null;
|
|
3051
|
+
return decorationKey(this.opts.key?.(value) ?? null);
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
private isCustom(value: T | CustomRow): value is CustomRow {
|
|
3055
|
+
return typeof value === "object" && value !== null && (value as CustomRow).kind === "custom" &&
|
|
3056
|
+
typeof (value as CustomRow).command === "string";
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
private async setup(): Promise<void> {
|
|
3060
|
+
const { neosh, opts } = this;
|
|
3061
|
+
const k = opts.kind;
|
|
3062
|
+
this.buf = await neosh.buf.create({ name: opts.name ?? `[${k}]`, scratch: true, kind: k });
|
|
3063
|
+
this.ns = await neosh.ns.create(k);
|
|
3064
|
+
this.list = new CursoredList<T | CustomRow>(neosh, this.buf, this.ns, {
|
|
3065
|
+
cursorHl: opts.cursorHl,
|
|
3066
|
+
width: () => this.width,
|
|
3067
|
+
onMove: () => this.published(),
|
|
3068
|
+
});
|
|
3069
|
+
|
|
3070
|
+
const scope = { kind: "buf_kind", name: k } as const;
|
|
3071
|
+
const verb = async (name: string, keys: string[], desc: string, fn: () => void | Promise<void>, redraw = true) => {
|
|
3072
|
+
this.subscriptions.push(
|
|
3073
|
+
await neosh.cmd.register(`${k}.${name}`, async () => {
|
|
3074
|
+
await fn();
|
|
3075
|
+
if (redraw) await this.draw();
|
|
3076
|
+
}, { desc }),
|
|
3077
|
+
);
|
|
3078
|
+
for (const key of keys) {
|
|
3079
|
+
await neosh.keymap.set("chat", key, `${k}.${name}`, { scope, desc });
|
|
3080
|
+
}
|
|
3081
|
+
};
|
|
3082
|
+
await verb("down", ["j", "<Down>", "<C-n>"], "Next row", () => this.list.move(1));
|
|
3083
|
+
await verb("up", ["k", "<Up>", "<C-p>"], "Previous row", () => this.list.move(-1));
|
|
3084
|
+
await verb("first", ["gg"], "First row", () => this.list.toEnd("first"));
|
|
3085
|
+
await verb("last", ["G"], "Last row", () => this.list.toEnd("last"));
|
|
3086
|
+
await verb("open", ["<CR>"], "Open the row under the cursor", async () => {
|
|
3087
|
+
const v = this.list.value;
|
|
3088
|
+
if (v === undefined) return;
|
|
3089
|
+
if (this.isCustom(v)) {
|
|
3090
|
+
await neosh.cmd.exec(v.command, v.args).catch((e: unknown) => neosh.notify(String(e), "warn"));
|
|
3091
|
+
} else {
|
|
3092
|
+
await opts.onOpen?.(v);
|
|
3093
|
+
}
|
|
3094
|
+
});
|
|
3095
|
+
await verb("leave", ["<Esc>", "q"], "Back to the composer", () => this.leave(), false);
|
|
3096
|
+
await verb("refresh", [], "Redraw the panel now", () => {}, true);
|
|
3097
|
+
this.subscriptions.push(
|
|
3098
|
+
await neosh.cmd.register(`${k}.toggle`, () => (this.isOpen() ? this.close() : this.open()), {
|
|
3099
|
+
desc: "Show or hide the panel",
|
|
3100
|
+
}),
|
|
3101
|
+
);
|
|
3102
|
+
this.subscriptions.push(
|
|
3103
|
+
await neosh.cmd.register(`${k}.focus`, () => this.enter(), { desc: "Move into the panel" }),
|
|
3104
|
+
);
|
|
3105
|
+
this.subscriptions.push(
|
|
3106
|
+
await neosh.cmd.register(`${k}.cursor`, () => this.cursor, { desc: "The row under the cursor" }),
|
|
3107
|
+
);
|
|
3108
|
+
this.subscriptions.push(
|
|
3109
|
+
await neosh.cmd.register(`${k}.rows`, () => this.rows, { desc: "Every row you can land on" }),
|
|
3110
|
+
);
|
|
3111
|
+
// A sink for the keys nothing claimed, so an unbound letter does not fall through to the
|
|
3112
|
+
// composer and start a message.
|
|
3113
|
+
this.subscriptions.push(
|
|
3114
|
+
await neosh.cmd.register(`${k}.key`, () => {}, { desc: "Swallow an unbound key in the panel" }),
|
|
3115
|
+
);
|
|
3116
|
+
|
|
3117
|
+
const { section, action, decoration } = this.points;
|
|
3118
|
+
this.subscriptions.push(
|
|
3119
|
+
neosh.ext.onChange((e) => {
|
|
3120
|
+
if (e.point === section || e.point === decoration) void this.draw();
|
|
3121
|
+
if (e.point === action) void this.syncActions();
|
|
3122
|
+
}),
|
|
3123
|
+
);
|
|
3124
|
+
this.subscriptions.push(
|
|
3125
|
+
neosh.event.on("neosh.viewport", (e) => {
|
|
3126
|
+
const d = e.data as { win?: number; width?: number } | null;
|
|
3127
|
+
if (d?.win === this.win && typeof d.width === "number") {
|
|
3128
|
+
this.width = d.width;
|
|
3129
|
+
void this.draw();
|
|
3130
|
+
}
|
|
3131
|
+
}),
|
|
3132
|
+
);
|
|
3133
|
+
await this.syncActions();
|
|
3134
|
+
this.subscriptions.push({
|
|
3135
|
+
dispose: () => {
|
|
3136
|
+
for (const d of this.registered) d.dispose();
|
|
3137
|
+
for (const b of this.bound) void neosh.keymap.del("chat", b.key, scope).catch(() => {});
|
|
3138
|
+
},
|
|
3139
|
+
});
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
/** Bind the verbs other plugins contributed, on their behalf, with the row as arguments. */
|
|
3143
|
+
private async syncActions(): Promise<void> {
|
|
3144
|
+
const { neosh, opts } = this;
|
|
3145
|
+
const k = opts.kind;
|
|
3146
|
+
const scope = { kind: "buf_kind", name: k } as const;
|
|
3147
|
+
const got = await neosh.ext.list<ActionItem>(this.points.action).catch(() => []);
|
|
3148
|
+
for (const b of this.bound) await neosh.keymap.del("chat", b.key, scope).catch(() => {});
|
|
3149
|
+
for (const d of this.registered) d.dispose();
|
|
3150
|
+
this.bound = [];
|
|
3151
|
+
this.registered = [];
|
|
3152
|
+
const mine = new Set(
|
|
3153
|
+
(await neosh.keymap.list("chat").catch(() => []))
|
|
3154
|
+
.filter((m) => m.scope.kind === "buf_kind" && m.scope.name === k && !m.command.startsWith(`${k}.action.`))
|
|
3155
|
+
.map((m) => m.lhs),
|
|
3156
|
+
);
|
|
3157
|
+
this.actions = got.filter((c) => typeof c.item?.key === "string" && typeof c.item?.command === "string");
|
|
3158
|
+
for (const c of this.actions) {
|
|
3159
|
+
const name = `${k}.action.${c.plugin}.${c.id}`;
|
|
3160
|
+
const reg = await neosh.cmd.register(name, async () => {
|
|
3161
|
+
const v = this.list.value;
|
|
3162
|
+
if (v === undefined) return;
|
|
3163
|
+
const on = c.item.on ?? "any";
|
|
3164
|
+
const rowKind = this.isCustom(v) ? "custom" : opts.kindOf?.(v) ?? "row";
|
|
3165
|
+
if (on !== "any" && on !== rowKind) return;
|
|
3166
|
+
const args = this.isCustom(v)
|
|
3167
|
+
? ["custom", ...v.args]
|
|
3168
|
+
: opts.argsFor?.(v) ?? [rowKind, ...Object.values(opts.key?.(v) ?? {})];
|
|
3169
|
+
await neosh.cmd.exec(c.item.command, args).catch((e: unknown) => neosh.notify(String(e), "warn"));
|
|
3170
|
+
await this.draw();
|
|
3171
|
+
}, { desc: c.item.label }).catch(() => null);
|
|
3172
|
+
if (reg) this.registered.push(reg);
|
|
3173
|
+
if (mine.has(c.item.key)) {
|
|
3174
|
+
neosh.log.warn(`${c.plugin} asked for '${c.item.key}' in ${k}, which is already a panel key`);
|
|
3175
|
+
continue;
|
|
3176
|
+
}
|
|
3177
|
+
await neosh.keymap.set("chat", c.item.key, name, { scope, desc: c.item.label }).catch(() => {});
|
|
3178
|
+
this.bound.push({ key: c.item.key, command: name });
|
|
3179
|
+
}
|
|
3180
|
+
await this.draw();
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
private published(): void {
|
|
3184
|
+
const v = this.list.value ?? null;
|
|
3185
|
+
void this.neosh.vars.set({ scope: "buffer", buf: this.buf }, "cursor", v).catch(() => {});
|
|
3186
|
+
void this.neosh.event.emit(`${this.opts.kind}.cursor`, v);
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
/** Redraw: the plugin's rows, decorated, with contributed sections placed among the slots. */
|
|
3190
|
+
async draw(): Promise<void> {
|
|
3191
|
+
if (this.win === null) return;
|
|
3192
|
+
if (this.drawing) {
|
|
3193
|
+
this.again = true;
|
|
3194
|
+
return;
|
|
3195
|
+
}
|
|
3196
|
+
this.drawing = true;
|
|
3197
|
+
try {
|
|
3198
|
+
do {
|
|
3199
|
+
this.again = false;
|
|
3200
|
+
const { neosh, opts } = this;
|
|
3201
|
+
const own = await opts.rows();
|
|
3202
|
+
const [sections, decorations] = await Promise.all([
|
|
3203
|
+
neosh.ext.list<SectionItem>(this.points.section).catch(() => []),
|
|
3204
|
+
neosh.ext.list<DecorationItem>(this.points.decoration).catch(() => []),
|
|
3205
|
+
]);
|
|
3206
|
+
const merged = mergeDecorations(decorations);
|
|
3207
|
+
const slots = opts.slots ?? ["main"];
|
|
3208
|
+
const order = placeSections(slots, sections);
|
|
3209
|
+
const rows: ListRow<T | CustomRow>[] = [];
|
|
3210
|
+
let body = 0;
|
|
3211
|
+
for (const entry of order) {
|
|
3212
|
+
if (typeof entry === "string") {
|
|
3213
|
+
if (entry === slots[0]) {
|
|
3214
|
+
for (const r of own) {
|
|
3215
|
+
const key = r.value === undefined ? null : this.keyOf(r.value);
|
|
3216
|
+
rows.push(key === null ? r : decorateRow(r, merged.get(key)));
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
body = rows.length;
|
|
3220
|
+
} else {
|
|
3221
|
+
rows.push(...sectionRows<T | CustomRow>(entry, {
|
|
3222
|
+
width: this.width,
|
|
3223
|
+
custom: (command, args) => ({ kind: "custom", command, args }),
|
|
3224
|
+
}));
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
this.list.setRows(rows, (a, b) => {
|
|
3228
|
+
const ka = this.keyOf(a);
|
|
3229
|
+
return ka !== null && ka === this.keyOf(b);
|
|
3230
|
+
});
|
|
3231
|
+
await this.list.render({ showCursor: this.focused, win: this.win ?? undefined, pinned: rows.length - body });
|
|
3232
|
+
} while (this.again);
|
|
3233
|
+
} finally {
|
|
3234
|
+
this.drawing = false;
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
async open(): Promise<void> {
|
|
3239
|
+
if (this.win !== null) return;
|
|
3240
|
+
const size = (await this.opts.size?.()) ?? 30;
|
|
3241
|
+
this.width = size;
|
|
3242
|
+
this.win = await this.neosh.win.open(this.buf, this.opts.dock ?? "left", { size });
|
|
3243
|
+
await this.draw();
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
async close(): Promise<void> {
|
|
3247
|
+
if (this.win === null) return;
|
|
3248
|
+
if (this.focused) await this.leave();
|
|
3249
|
+
await this.neosh.win.close(this.win).catch(() => {});
|
|
3250
|
+
this.win = null;
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
/** Take the keyboard. */
|
|
3254
|
+
async enter(): Promise<void> {
|
|
3255
|
+
if (this.win === null) await this.open();
|
|
3256
|
+
if (this.win === null || this.focused) return;
|
|
3257
|
+
await this.neosh.focus.push(this.win);
|
|
3258
|
+
this.capture = await this.neosh.keymap.capture(this.win, `${this.opts.kind}.key`);
|
|
3259
|
+
this.focused = true;
|
|
3260
|
+
this.published();
|
|
3261
|
+
await this.draw();
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
/** Give it back. */
|
|
3265
|
+
async leave(): Promise<void> {
|
|
3266
|
+
if (!this.focused) return;
|
|
3267
|
+
this.focused = false;
|
|
3268
|
+
this.capture?.dispose();
|
|
3269
|
+
this.capture = null;
|
|
3270
|
+
await this.neosh.focus.pop();
|
|
3271
|
+
await this.draw();
|
|
3272
|
+
}
|
|
3273
|
+
|
|
3274
|
+
/** Close the window and unregister everything. */
|
|
3275
|
+
dispose(): void {
|
|
3276
|
+
void this.close();
|
|
3277
|
+
for (const d of this.subscriptions) d.dispose();
|
|
3278
|
+
}
|
|
3279
|
+
}
|