@luan.sh/pi-libtui 0.3.8
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/LICENSE +21 -0
- package/README.md +208 -0
- package/native-revision +1 -0
- package/package.json +58 -0
- package/src/activity-presentation.ts +75 -0
- package/src/appearance.ts +632 -0
- package/src/background-surface.ts +125 -0
- package/src/color/palette.ts +186 -0
- package/src/color/preview.ts +157 -0
- package/src/color/resolver.ts +75 -0
- package/src/color/theme.ts +531 -0
- package/src/component-stack.ts +247 -0
- package/src/content/markdown-code.ts +60 -0
- package/src/content/terminal-text.ts +135 -0
- package/src/content/text.ts +105 -0
- package/src/controls/action-panel.ts +420 -0
- package/src/controls/dialog-button-bar.ts +287 -0
- package/src/controls/multi-select.ts +467 -0
- package/src/controls/picker-panel.ts +447 -0
- package/src/controls/screen-icon-actions.ts +134 -0
- package/src/controls/searchable-select.ts +394 -0
- package/src/controls/select-box.ts +224 -0
- package/src/controls/selectable-list.ts +433 -0
- package/src/controls/selection-action-bar.ts +382 -0
- package/src/controls/semantic-input.ts +23 -0
- package/src/controls/tab.ts +189 -0
- package/src/cursor.ts +288 -0
- package/src/decoration/editor-pills.ts +156 -0
- package/src/decoration/glyphs.ts +326 -0
- package/src/decoration/pointer-interaction.ts +123 -0
- package/src/decoration/powerline-pill.ts +104 -0
- package/src/decoration/status.ts +185 -0
- package/src/decoration/transient-pill.ts +176 -0
- package/src/diff/index.ts +21 -0
- package/src/diff/model.ts +76 -0
- package/src/diff/parse.ts +310 -0
- package/src/diff/pierre-syntax.ts +86 -0
- package/src/diff/render.ts +504 -0
- package/src/diff/structured.ts +264 -0
- package/src/diff/view.ts +75 -0
- package/src/editor/chrome.ts +341 -0
- package/src/editor/composition.ts +53 -0
- package/src/editor/layout.ts +81 -0
- package/src/editor/presentation.ts +35 -0
- package/src/editor/protocol.ts +188 -0
- package/src/editor.ts +41 -0
- package/src/extension.ts +30 -0
- package/src/folding.ts +192 -0
- package/src/host/cursor-bridge.ts +210 -0
- package/src/host/editor-bridge.ts +148 -0
- package/src/host/extension-host.ts +231 -0
- package/src/host/mouse-bridge.ts +212 -0
- package/src/host/mouse-dispatch.ts +400 -0
- package/src/host/mouse-input.ts +85 -0
- package/src/host/mouse-targets.ts +238 -0
- package/src/host/pi-layout-adapter.ts +298 -0
- package/src/host/pi-native-selection.ts +227 -0
- package/src/host/split-pane-bridge.ts +720 -0
- package/src/host/transcript-bridge.ts +236 -0
- package/src/index.ts +334 -0
- package/src/line-layout.ts +6 -0
- package/src/motion.ts +1409 -0
- package/src/mouse/events.ts +55 -0
- package/src/mouse/fullscreen-layout.ts +260 -0
- package/src/mouse/registry.ts +348 -0
- package/src/mouse.ts +37 -0
- package/src/native-binary.ts +189 -0
- package/src/overlay/anchored.ts +91 -0
- package/src/overlay/detail-card.ts +158 -0
- package/src/overlay/dialog.ts +266 -0
- package/src/overlay/floating.ts +106 -0
- package/src/overlay/framed-editor.ts +236 -0
- package/src/overlay/fullscreen.ts +170 -0
- package/src/overlay/hover-tooltip.ts +109 -0
- package/src/overlay/modal-mount.ts +118 -0
- package/src/panels.ts +207 -0
- package/src/render-cache.ts +80 -0
- package/src/render-epoch.ts +48 -0
- package/src/request-animation.ts +160 -0
- package/src/scrollbar.ts +27 -0
- package/src/selection.ts +148 -0
- package/src/split-pane.ts +232 -0
- package/src/status-presentation.ts +411 -0
- package/src/stream.ts +543 -0
- package/src/syntax.ts +316 -0
- package/src/terminal/bridge-client.ts +243 -0
- package/src/terminal/embedding.ts +6 -0
- package/src/terminal/output.ts +151 -0
- package/src/terminal/projection.ts +362 -0
- package/src/terminal/pty-host.ts +263 -0
- package/src/terminal/pty-pane.ts +308 -0
- package/src/terminal-colors.ts +334 -0
- package/src/tool/action.ts +170 -0
- package/src/tool/activity.ts +500 -0
- package/src/tool/call-preview.ts +44 -0
- package/src/tool/disclosure-action.ts +89 -0
- package/src/tool/expanded-region-viewport.ts +103 -0
- package/src/tool/index.ts +26 -0
- package/src/tool/output.ts +271 -0
- package/src/tool/transcript.ts +150 -0
- package/src/tool/view-region.ts +581 -0
- package/test/appearance.test.ts +183 -0
- package/test/bounded-stream.test.ts +150 -0
- package/test/bridge-client.test.ts +42 -0
- package/test/color-preview.test.ts +23 -0
- package/test/component-stack.test.ts +206 -0
- package/test/components.test.ts +1165 -0
- package/test/content-text.test.ts +21 -0
- package/test/cursor-bridge.test.ts +151 -0
- package/test/cursor.test.ts +147 -0
- package/test/editor-chrome.test.ts +119 -0
- package/test/editor-layout.test.ts +30 -0
- package/test/editor-registry.test.ts +203 -0
- package/test/editor-theme.test.ts +33 -0
- package/test/editor-token-pill.test.ts +72 -0
- package/test/floating-overlay.test.ts +57 -0
- package/test/folding.test.ts +147 -0
- package/test/harmonious-theme.test.ts +23 -0
- package/test/interaction.test.ts +104 -0
- package/test/key-icons.test.ts +62 -0
- package/test/markdown-code.test.ts +24 -0
- package/test/modal-overlay-mount.test.ts +94 -0
- package/test/motion.test.ts +649 -0
- package/test/mouse/bridge.test.ts +1302 -0
- package/test/mouse/extension.test.ts +205 -0
- package/test/mouse/registry.test.ts +140 -0
- package/test/mouse/screen-decoration.test.ts +167 -0
- package/test/native-binary.test.ts +52 -0
- package/test/palette.test.ts +32 -0
- package/test/panels.test.ts +74 -0
- package/test/pointer-components.test.ts +151 -0
- package/test/progress.test.ts +54 -0
- package/test/pty-host.test.ts +126 -0
- package/test/pty-pane.test.ts +62 -0
- package/test/public-surface.test.ts +75 -0
- package/test/render-cache.test.ts +25 -0
- package/test/render-epoch.test.ts +119 -0
- package/test/request-animation.test.ts +193 -0
- package/test/screen-icon-actions.test.ts +69 -0
- package/test/select-box.test.ts +86 -0
- package/test/select-pointer.test.ts +369 -0
- package/test/selectable-list.test.ts +258 -0
- package/test/selection/registry.test.ts +44 -0
- package/test/selection-action-bar.test.ts +204 -0
- package/test/split-pane-bridge.test.ts +944 -0
- package/test/split-pane.test.ts +134 -0
- package/test/status-presentation.test.ts +39 -0
- package/test/syntax.test.ts +59 -0
- package/test/terminal-colors.test.ts +115 -0
- package/test/terminal-embedding.test.ts +12 -0
- package/test/terminal-projection.test.ts +312 -0
- package/test/theme-colors.test.ts +175 -0
- package/test/tool-output.test.ts +160 -0
- package/test/tool-transcript.test.ts +1190 -0
- package/test/unified-diff.test.ts +691 -0
- package/themes/harmonious.json +61 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type Component,
|
|
4
|
+
type Focusable,
|
|
5
|
+
getKeybindings,
|
|
6
|
+
matchesKey,
|
|
7
|
+
Text,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
visibleWidth,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
import { tuiTheme } from "../color/theme.ts";
|
|
12
|
+
import { icon } from "../decoration/glyphs.ts";
|
|
13
|
+
import type { TuiMouseEvent } from "../mouse.ts";
|
|
14
|
+
import { applyScrollbar } from "../scrollbar.ts";
|
|
15
|
+
import { DialogButtonBar } from "./dialog-button-bar.ts";
|
|
16
|
+
import type { SelectOption } from "./searchable-select.ts";
|
|
17
|
+
import { SemanticInput } from "./semantic-input.ts";
|
|
18
|
+
|
|
19
|
+
/** Construction options for {@link MultiSelect}. */
|
|
20
|
+
export interface MultiSelectOptions<Value extends string> {
|
|
21
|
+
/** Title rendered above the choices unless the surrounding frame owns it. */
|
|
22
|
+
title: string;
|
|
23
|
+
/** Optional supporting text rendered below the title. */
|
|
24
|
+
description?: string;
|
|
25
|
+
/** Render the title in the content. Disable when the dialog frame owns it. */
|
|
26
|
+
showTitle?: boolean;
|
|
27
|
+
/** Complete set of available choices. */
|
|
28
|
+
options: readonly SelectOption<Value>[];
|
|
29
|
+
/** Initially selected values, in their saved order. */
|
|
30
|
+
value: readonly Value[];
|
|
31
|
+
/** Allow selected values to be reordered and display their positions. */
|
|
32
|
+
ordered?: boolean;
|
|
33
|
+
/** Place option descriptions inline or on wrapped lines below each label. */
|
|
34
|
+
descriptionLayout?: "inline" | "below";
|
|
35
|
+
/** Maximum rendered rows. Dialog hosts supply this from their available height. */
|
|
36
|
+
maxHeight?: number;
|
|
37
|
+
/** Active Pi theme used to derive semantic libtui colors. */
|
|
38
|
+
theme: Theme;
|
|
39
|
+
/** Persist the selected values in their current order. */
|
|
40
|
+
onSave(value: Value[]): void;
|
|
41
|
+
/** Preview a changed selection without persisting it. */
|
|
42
|
+
onChange?(value: Value[]): void;
|
|
43
|
+
/** Close without saving, after the component's unsaved-change confirmation. */
|
|
44
|
+
onCancel(): void;
|
|
45
|
+
/** Require a second cancel before discarding changes. Defaults to true. */
|
|
46
|
+
confirmDiscard?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A mouseable multi-select with optional ordering and explicit Save and Cancel actions. */
|
|
50
|
+
export class MultiSelect<Value extends string = string> implements Component, Focusable {
|
|
51
|
+
private selectedIndex = 0;
|
|
52
|
+
private readonly initialValue: Value[];
|
|
53
|
+
private value: Value[];
|
|
54
|
+
private discardWarning = false;
|
|
55
|
+
private readonly buttons: DialogButtonBar<"cancel" | "save">;
|
|
56
|
+
private rows: Array<{ y: number; height: number; value: Value; index: number }> = [];
|
|
57
|
+
private buttonRow: number | undefined;
|
|
58
|
+
private pressedValue: Value | undefined;
|
|
59
|
+
private maxHeight: number;
|
|
60
|
+
private viewportStart = 0;
|
|
61
|
+
private followSelection = true;
|
|
62
|
+
private readonly input: SemanticInput;
|
|
63
|
+
private filterActive = false;
|
|
64
|
+
private searchRow: number | undefined;
|
|
65
|
+
private searchHovered = false;
|
|
66
|
+
private searchPressed = false;
|
|
67
|
+
private _focused = false;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a multi-select editor.
|
|
71
|
+
* @param settings Choices, initial values, appearance, and completion callbacks.
|
|
72
|
+
*/
|
|
73
|
+
constructor(private readonly settings: MultiSelectOptions<Value>) {
|
|
74
|
+
const colors = tuiTheme(settings.theme);
|
|
75
|
+
const keybindings = getKeybindings();
|
|
76
|
+
this.initialValue = settings.value.filter((value) => settings.options.some((option) => option.value === value));
|
|
77
|
+
this.value = [...this.initialValue];
|
|
78
|
+
this.maxHeight = settings.maxHeight ?? Number.POSITIVE_INFINITY;
|
|
79
|
+
this.input = new SemanticInput(settings.theme);
|
|
80
|
+
this.buttons = new DialogButtonBar({
|
|
81
|
+
theme: settings.theme,
|
|
82
|
+
leading: () =>
|
|
83
|
+
colors.fg("text.muted", settings.ordered ? "Space toggle · ↑↓ move · ←→ reorder" : "Space toggle · ↑↓ move"),
|
|
84
|
+
buttons: [
|
|
85
|
+
{
|
|
86
|
+
value: "cancel",
|
|
87
|
+
label: "Cancel",
|
|
88
|
+
icon: "cancel",
|
|
89
|
+
foreground: "text.primary",
|
|
90
|
+
background: "action.neutral",
|
|
91
|
+
shortcuts: keybindings.getKeys("tui.select.cancel"),
|
|
92
|
+
align: "end",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
value: "save",
|
|
96
|
+
label: "Save",
|
|
97
|
+
icon: "confirm",
|
|
98
|
+
foreground: "positive",
|
|
99
|
+
background: "action.positive",
|
|
100
|
+
shortcuts: keybindings.getKeys("tui.select.confirm"),
|
|
101
|
+
align: "end",
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
requestRender() {},
|
|
105
|
+
onActivate: (action) => {
|
|
106
|
+
if (action === "save") this.save();
|
|
107
|
+
else this.cancel();
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether the component currently receives keyboard focus. */
|
|
113
|
+
get focused(): boolean {
|
|
114
|
+
return this._focused;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Forward focus to the filter field while it is active. */
|
|
118
|
+
set focused(value: boolean) {
|
|
119
|
+
this._focused = value;
|
|
120
|
+
this.input.focused = value && this.filterActive;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Limit rendered rows so a dialog can reserve its frame inside the terminal. */
|
|
124
|
+
setMaxHeight(maxHeight: number): void {
|
|
125
|
+
this.maxHeight = Math.max(0, Math.floor(maxHeight));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Handle navigation, toggling, reordering, saving, and cancellation.
|
|
130
|
+
* @param data Raw terminal input from Pi.
|
|
131
|
+
*/
|
|
132
|
+
handleInput(data: string): void {
|
|
133
|
+
const keybindings = getKeybindings();
|
|
134
|
+
const options = this.displayOptions();
|
|
135
|
+
if (this.filterActive) {
|
|
136
|
+
if (keybindings.matches(data, "tui.select.cancel")) {
|
|
137
|
+
this.clearFilter();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (
|
|
141
|
+
!keybindings.matches(data, "tui.select.up") &&
|
|
142
|
+
!keybindings.matches(data, "tui.select.down") &&
|
|
143
|
+
!keybindings.matches(data, "tui.select.confirm") &&
|
|
144
|
+
!matchesKey(data, "space")
|
|
145
|
+
) {
|
|
146
|
+
const selected = options[this.selectedIndex]?.value;
|
|
147
|
+
this.input.handleInput(data);
|
|
148
|
+
this.applyFilter(selected);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (data === "/") {
|
|
153
|
+
this.activateFilter();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (keybindings.matches(data, "tui.select.cancel")) {
|
|
157
|
+
this.cancel();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this.discardWarning = false;
|
|
161
|
+
if (keybindings.matches(data, "tui.select.confirm")) {
|
|
162
|
+
this.save();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (options.length === 0) return;
|
|
166
|
+
if (keybindings.matches(data, "tui.select.up") || data === "k") {
|
|
167
|
+
this.followSelection = true;
|
|
168
|
+
this.selectedIndex = (this.selectedIndex - 1 + options.length) % options.length;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (keybindings.matches(data, "tui.select.down") || data === "j") {
|
|
172
|
+
this.followSelection = true;
|
|
173
|
+
this.selectedIndex = (this.selectedIndex + 1) % options.length;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const selected = options[this.selectedIndex];
|
|
177
|
+
if (!selected) return;
|
|
178
|
+
if (matchesKey(data, "space")) {
|
|
179
|
+
this.toggle(selected.value);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const moveLeft = matchesKey(data, "left") || data === "h";
|
|
183
|
+
const moveRight = matchesKey(data, "right") || data === "l";
|
|
184
|
+
if (!this.settings.ordered || (!moveLeft && !moveRight)) return;
|
|
185
|
+
const index = this.value.indexOf(selected.value);
|
|
186
|
+
if (index < 0) return;
|
|
187
|
+
const next = moveLeft ? index - 1 : index + 1;
|
|
188
|
+
if (next < 0 || next >= this.value.length) return;
|
|
189
|
+
[this.value[index], this.value[next]] = [this.value[next]!, this.value[index]!];
|
|
190
|
+
this.selectedIndex = this.displayOptions().findIndex((option) => option.value === selected.value);
|
|
191
|
+
this.settings.onChange?.([...this.value]);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Handle component-local row, wheel, and action-button pointer input.
|
|
196
|
+
* @param event Pointer event from pi-libtui's shared component host.
|
|
197
|
+
* @returns True when the event targets a choice, button, or supported wheel action.
|
|
198
|
+
*/
|
|
199
|
+
onMouse(event: TuiMouseEvent): boolean {
|
|
200
|
+
if (event.type === "leave") {
|
|
201
|
+
this.pressedValue = undefined;
|
|
202
|
+
this.leaveSearch();
|
|
203
|
+
this.leaveButtons(event);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
if (this.buttonRow !== undefined && event.row === this.buttonRow) {
|
|
207
|
+
this.buttons.onMouse({ ...event, row: 0 });
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
this.leaveButtons(event);
|
|
211
|
+
if (this.searchRow !== undefined && event.row === this.searchRow) {
|
|
212
|
+
this.searchHovered = true;
|
|
213
|
+
if (event.type === "press" && (event.button === undefined || event.button === 0)) this.searchPressed = true;
|
|
214
|
+
if (event.type === "release" && (event.button === undefined || event.button === 0)) {
|
|
215
|
+
const activate = this.searchPressed;
|
|
216
|
+
this.searchPressed = false;
|
|
217
|
+
if (activate) this.activateFilter();
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
this.leaveSearch();
|
|
222
|
+
if (event.type === "wheel" && event.wheel !== undefined) {
|
|
223
|
+
this.scrollViewport(event.wheel);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
const row = this.rows.find((candidate) => event.row >= candidate.y && event.row < candidate.y + candidate.height);
|
|
227
|
+
if ((event.type === "enter" || event.type === "move") && row) {
|
|
228
|
+
this.followSelection = true;
|
|
229
|
+
this.selectedIndex = row.index;
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
if (event.type === "press" && (event.button === undefined || event.button === 0)) {
|
|
233
|
+
this.pressedValue = row?.value;
|
|
234
|
+
if (row) {
|
|
235
|
+
this.followSelection = true;
|
|
236
|
+
this.selectedIndex = row.index;
|
|
237
|
+
}
|
|
238
|
+
return row !== undefined;
|
|
239
|
+
}
|
|
240
|
+
if (event.type === "release" && (event.button === undefined || event.button === 0)) {
|
|
241
|
+
const pressedValue = this.pressedValue;
|
|
242
|
+
this.pressedValue = undefined;
|
|
243
|
+
if (row && row.value === pressedValue) this.toggle(row.value);
|
|
244
|
+
return row !== undefined;
|
|
245
|
+
}
|
|
246
|
+
return row !== undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Clear cached row and button geometry after external state changes. */
|
|
250
|
+
invalidate(): void {
|
|
251
|
+
this.rows = [];
|
|
252
|
+
this.searchRow = undefined;
|
|
253
|
+
this.buttonRow = undefined;
|
|
254
|
+
this.input.invalidate();
|
|
255
|
+
this.buttons.invalidate();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Render choices, optional discard warning, and the action row.
|
|
260
|
+
* @param width Available width in terminal cells.
|
|
261
|
+
* @returns Rendered terminal rows clipped to the supplied width.
|
|
262
|
+
*/
|
|
263
|
+
render(width: number): string[] {
|
|
264
|
+
const colors = tuiTheme(this.settings.theme);
|
|
265
|
+
const lines =
|
|
266
|
+
this.settings.showTitle === false ? [] : [this.settings.theme.bold(colors.fg("accent", this.settings.title))];
|
|
267
|
+
if (this.settings.description) {
|
|
268
|
+
lines.push(...new Text(colors.fg("text.muted", this.settings.description), 0, 0).render(width));
|
|
269
|
+
}
|
|
270
|
+
this.searchRow = lines.length;
|
|
271
|
+
lines.push(this.renderSearch(width));
|
|
272
|
+
this.rows = [];
|
|
273
|
+
const optionWidth = Math.max(1, width - 2);
|
|
274
|
+
const makeBlocks = (options: readonly SelectOption<Value>[]) =>
|
|
275
|
+
options.map((option, index) => {
|
|
276
|
+
const position = this.value.indexOf(option.value);
|
|
277
|
+
const marker =
|
|
278
|
+
position >= 0 ? colors.fg("positive", icon("checkbox-on")) : colors.fg("text.muted", icon("checkbox-off"));
|
|
279
|
+
const order = this.settings.ordered && position >= 0 ? ` ${position + 1}.` : "";
|
|
280
|
+
const prefix = `${index === this.selectedIndex ? "›" : " "} ${marker}${order} `;
|
|
281
|
+
const label =
|
|
282
|
+
this.settings.descriptionLayout === "below"
|
|
283
|
+
? option.label
|
|
284
|
+
: `${option.label}${option.description ? ` ${colors.fg("text.muted", option.description)}` : ""}`;
|
|
285
|
+
const row = `${prefix}${label}`;
|
|
286
|
+
const block = [index === this.selectedIndex ? colors.fg("accent", row) : row];
|
|
287
|
+
if (this.settings.descriptionLayout === "below" && option.description) {
|
|
288
|
+
const indent = " ".repeat(visibleWidth(prefix));
|
|
289
|
+
const descriptionWidth = Math.max(1, optionWidth - visibleWidth(indent));
|
|
290
|
+
for (const description of new Text(colors.fg("text.muted", option.description), 0, 0).render(
|
|
291
|
+
descriptionWidth,
|
|
292
|
+
)) {
|
|
293
|
+
block.push(`${indent}${description}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return { lines: block, option, index };
|
|
297
|
+
});
|
|
298
|
+
const blocks = makeBlocks(this.displayOptions());
|
|
299
|
+
const fullHeight = makeBlocks(this.orderedOptions()).reduce((height, block) => height + block.lines.length, 0);
|
|
300
|
+
const footerHeight = this.discardWarning ? 4 : 2;
|
|
301
|
+
const availableHeight = Number.isFinite(this.maxHeight)
|
|
302
|
+
? Math.max(1, this.maxHeight - lines.length - footerHeight)
|
|
303
|
+
: Math.max(1, fullHeight);
|
|
304
|
+
const optionHeight = Math.min(Math.max(1, fullHeight), availableHeight);
|
|
305
|
+
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, blocks.length - 1));
|
|
306
|
+
this.viewportStart = Math.min(this.viewportStart, Math.max(0, blocks.length - 1));
|
|
307
|
+
if (this.followSelection) {
|
|
308
|
+
this.viewportStart = Math.min(this.viewportStart, this.selectedIndex);
|
|
309
|
+
while (
|
|
310
|
+
this.viewportStart < this.selectedIndex &&
|
|
311
|
+
blocks
|
|
312
|
+
.slice(this.viewportStart, this.selectedIndex + 1)
|
|
313
|
+
.reduce((height, block) => height + block.lines.length, 0) > optionHeight
|
|
314
|
+
) {
|
|
315
|
+
this.viewportStart += 1;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
while (
|
|
319
|
+
this.viewportStart > 0 &&
|
|
320
|
+
blocks.slice(this.viewportStart).reduce((height, block) => height + block.lines.length, 0) < optionHeight
|
|
321
|
+
) {
|
|
322
|
+
this.viewportStart -= 1;
|
|
323
|
+
}
|
|
324
|
+
const totalHeight = blocks.reduce((height, block) => height + block.lines.length, 0);
|
|
325
|
+
const startLine = blocks.slice(0, this.viewportStart).reduce((height, block) => height + block.lines.length, 0);
|
|
326
|
+
let usedHeight = 0;
|
|
327
|
+
for (const block of blocks.slice(this.viewportStart)) {
|
|
328
|
+
const visibleLines = block.lines.slice(0, optionHeight - usedHeight);
|
|
329
|
+
if (visibleLines.length === 0) break;
|
|
330
|
+
const y = lines.length;
|
|
331
|
+
lines.push(...visibleLines);
|
|
332
|
+
usedHeight += visibleLines.length;
|
|
333
|
+
this.rows.push({ y, height: visibleLines.length, value: block.option.value, index: block.index });
|
|
334
|
+
if (usedHeight >= optionHeight) break;
|
|
335
|
+
}
|
|
336
|
+
if (blocks.length === 0) {
|
|
337
|
+
lines.push(colors.fg("text.muted", " No matching options"));
|
|
338
|
+
usedHeight = 1;
|
|
339
|
+
}
|
|
340
|
+
while (usedHeight < optionHeight) {
|
|
341
|
+
lines.push("");
|
|
342
|
+
usedHeight += 1;
|
|
343
|
+
}
|
|
344
|
+
this.renderScrollbar(lines, lines.length - optionHeight, optionHeight, startLine, totalHeight, width);
|
|
345
|
+
if (this.discardWarning) {
|
|
346
|
+
lines.push("", colors.fg("warning", "Unsaved changes. Press Esc again to discard or Enter to save."));
|
|
347
|
+
}
|
|
348
|
+
lines.push("");
|
|
349
|
+
this.buttonRow = lines.length;
|
|
350
|
+
lines.push(...this.buttons.render(width));
|
|
351
|
+
return lines.map((line) => truncateToWidth(line, width, ""));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private scrollViewport(delta: number): void {
|
|
355
|
+
const options = this.displayOptions();
|
|
356
|
+
if (options.length === 0 || delta === 0) return;
|
|
357
|
+
this.viewportStart = Math.max(0, Math.min(this.viewportStart + delta, options.length - 1));
|
|
358
|
+
this.followSelection = false;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private toggle(value: Value): void {
|
|
362
|
+
this.discardWarning = false;
|
|
363
|
+
const index = this.value.indexOf(value);
|
|
364
|
+
if (index >= 0) this.value.splice(index, 1);
|
|
365
|
+
else this.value.push(value);
|
|
366
|
+
this.selectedIndex = this.displayOptions().findIndex((option) => option.value === value);
|
|
367
|
+
this.settings.onChange?.([...this.value]);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private save(): void {
|
|
371
|
+
this.settings.onSave([...this.value]);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private cancel(): void {
|
|
375
|
+
if (this.settings.confirmDiscard !== false && this.hasUnsavedChanges() && !this.discardWarning) {
|
|
376
|
+
this.discardWarning = true;
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
this.settings.onCancel();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private leaveButtons(event: TuiMouseEvent): void {
|
|
383
|
+
this.buttons.onMouse({ ...event, type: "leave", row: 0 });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private renderSearch(width: number): string {
|
|
387
|
+
const colors = tuiTheme(this.settings.theme);
|
|
388
|
+
const prefix = `${colors.fg("text.muted", icon("search"))} `;
|
|
389
|
+
const available = Math.max(0, width - visibleWidth(prefix));
|
|
390
|
+
let content: string;
|
|
391
|
+
if (this.filterActive) {
|
|
392
|
+
const inputLine = this.input.render(available)[0] ?? "";
|
|
393
|
+
content = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
|
|
394
|
+
} else if (this.input.getValue()) content = colors.fg("text.primary", this.input.getValue());
|
|
395
|
+
else content = colors.fg("text.muted", "Filter…");
|
|
396
|
+
const row = truncateToWidth(`${prefix}${content}`, width, "");
|
|
397
|
+
const padded = row + " ".repeat(Math.max(0, width - visibleWidth(row)));
|
|
398
|
+
return this.searchHovered ? colors.bg("surface.raised", padded) : padded;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private renderScrollbar(
|
|
402
|
+
lines: string[],
|
|
403
|
+
start: number,
|
|
404
|
+
height: number,
|
|
405
|
+
offset: number,
|
|
406
|
+
total: number,
|
|
407
|
+
width: number,
|
|
408
|
+
): void {
|
|
409
|
+
const painted = applyScrollbar(lines.slice(start, start + height), {
|
|
410
|
+
theme: this.settings.theme,
|
|
411
|
+
width,
|
|
412
|
+
height,
|
|
413
|
+
offset,
|
|
414
|
+
total,
|
|
415
|
+
});
|
|
416
|
+
for (let row = 0; row < painted.length; row += 1) lines[start + row] = painted[row]!;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private activateFilter(): void {
|
|
420
|
+
this.filterActive = true;
|
|
421
|
+
this.input.focused = this._focused;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private clearFilter(): void {
|
|
425
|
+
const selected = this.displayOptions()[this.selectedIndex]?.value;
|
|
426
|
+
this.input.setValue("");
|
|
427
|
+
this.filterActive = false;
|
|
428
|
+
this.input.focused = false;
|
|
429
|
+
this.applyFilter(selected);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private applyFilter(selected?: Value): void {
|
|
433
|
+
this.viewportStart = 0;
|
|
434
|
+
this.followSelection = true;
|
|
435
|
+
const options = this.displayOptions();
|
|
436
|
+
const retained = selected === undefined ? -1 : options.findIndex((option) => option.value === selected);
|
|
437
|
+
this.selectedIndex = retained >= 0 ? retained : 0;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private leaveSearch(): void {
|
|
441
|
+
this.searchHovered = false;
|
|
442
|
+
this.searchPressed = false;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private displayOptions(): SelectOption<Value>[] {
|
|
446
|
+
const tokens = this.input.getValue().toLowerCase().trim().split(/\s+/u).filter(Boolean);
|
|
447
|
+
return this.orderedOptions().filter((option) => {
|
|
448
|
+
const searchText = `${option.value} ${option.label} ${option.description ?? ""}`.toLowerCase();
|
|
449
|
+
return tokens.every((token) => searchText.includes(token));
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private orderedOptions(): SelectOption<Value>[] {
|
|
454
|
+
if (!this.settings.ordered) return [...this.settings.options];
|
|
455
|
+
const byValue = new Map(this.settings.options.map((option) => [option.value, option]));
|
|
456
|
+
const selected = this.value.flatMap((value) => {
|
|
457
|
+
const option = byValue.get(value);
|
|
458
|
+
return option ? [option] : [];
|
|
459
|
+
});
|
|
460
|
+
const unselected = this.settings.options.filter((option) => !this.value.includes(option.value));
|
|
461
|
+
return [...selected, ...unselected];
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private hasUnsavedChanges(): boolean {
|
|
465
|
+
return JSON.stringify(this.value) !== JSON.stringify(this.initialValue);
|
|
466
|
+
}
|
|
467
|
+
}
|