@prometheus-ai/tui 0.5.3 → 0.5.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/dist/types/autocomplete.d.ts +3 -1
- package/dist/types/components/box.d.ts +1 -1
- package/dist/types/components/editor.d.ts +35 -2
- package/dist/types/components/image.d.ts +22 -3
- package/dist/types/components/input.d.ts +6 -1
- package/dist/types/components/loader.d.ts +9 -2
- package/dist/types/components/markdown.d.ts +3 -1
- package/dist/types/components/scroll-view.d.ts +23 -1
- package/dist/types/components/select-list.d.ts +19 -1
- package/dist/types/components/settings-list.d.ts +87 -7
- package/dist/types/components/spacer.d.ts +1 -1
- package/dist/types/components/tab-bar.d.ts +37 -4
- package/dist/types/components/text.d.ts +2 -2
- package/dist/types/components/truncated-text.d.ts +1 -1
- package/dist/types/fuzzy.d.ts +10 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/keybindings.d.ts +5 -3
- package/dist/types/keys.d.ts +1 -1
- package/dist/types/kill-ring.d.ts +0 -7
- package/dist/types/kitty-graphics.d.ts +16 -31
- package/dist/types/loop-watchdog.d.ts +39 -0
- package/dist/types/mouse.d.ts +41 -0
- package/dist/types/stdin-buffer.d.ts +17 -0
- package/dist/types/terminal-capabilities.d.ts +74 -18
- package/dist/types/terminal.d.ts +34 -36
- package/dist/types/tui.d.ts +191 -79
- package/dist/types/utils.d.ts +5 -2
- package/package.json +4 -4
- package/src/autocomplete.ts +79 -65
- package/src/components/box.ts +43 -63
- package/src/components/editor.ts +471 -136
- package/src/components/image.ts +85 -9
- package/src/components/input.ts +12 -3
- package/src/components/loader.ts +35 -21
- package/src/components/markdown.ts +174 -53
- package/src/components/scroll-view.ts +63 -2
- package/src/components/select-list.ts +233 -38
- package/src/components/settings-list.ts +626 -64
- package/src/components/spacer.ts +9 -5
- package/src/components/tab-bar.ts +153 -28
- package/src/components/text.ts +6 -2
- package/src/components/truncated-text.ts +10 -2
- package/src/fuzzy.ts +214 -59
- package/src/index.ts +3 -1
- package/src/keybindings.ts +72 -14
- package/src/keys.ts +1 -1
- package/src/kill-ring.ts +5 -0
- package/src/kitty-graphics.ts +2 -101
- package/src/loop-watchdog.ts +106 -0
- package/src/mouse.ts +55 -0
- package/src/stdin-buffer.ts +291 -81
- package/src/terminal-capabilities.ts +206 -168
- package/src/terminal.ts +367 -110
- package/src/tui.ts +2102 -1729
- package/src/utils.ts +92 -60
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { matchesKey } from "../keys";
|
|
1
2
|
import type { Component } from "../tui";
|
|
2
3
|
import { Ellipsis, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
|
|
3
4
|
|
|
@@ -20,6 +21,18 @@ export interface ScrollViewOptions {
|
|
|
20
21
|
theme?: ScrollViewTheme;
|
|
21
22
|
trackChar?: string;
|
|
22
23
|
thumbChar?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Indicator appended when a row overflows `contentWidth`. Defaults to
|
|
26
|
+
* {@link Ellipsis.Unicode}. Pass {@link Ellipsis.Omit} when callers wrap
|
|
27
|
+
* lines to width themselves and only trailing padding can overflow (e.g.
|
|
28
|
+
* the plan-review overlay), so no stray `…` lands on every padded row.
|
|
29
|
+
*/
|
|
30
|
+
ellipsis?: Ellipsis;
|
|
31
|
+
/**
|
|
32
|
+
* Rows moved per keystroke when {@link ScrollView.handleScrollKey} sees a
|
|
33
|
+
* Shift+Arrow (the "scroll faster" affordance). Defaults to 5.
|
|
34
|
+
*/
|
|
35
|
+
fastScrollLines?: number;
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
function normalizeScrollbarMode(scrollbar: ScrollViewOptions["scrollbar"]): ScrollbarMode {
|
|
@@ -48,6 +61,8 @@ export class ScrollView implements Component {
|
|
|
48
61
|
#theme: Required<ScrollViewTheme>;
|
|
49
62
|
#trackChar: string;
|
|
50
63
|
#thumbChar: string;
|
|
64
|
+
#ellipsis: Ellipsis;
|
|
65
|
+
#fastScrollLines: number;
|
|
51
66
|
|
|
52
67
|
constructor(lines: readonly string[], options: ScrollViewOptions) {
|
|
53
68
|
this.#lines = [...lines];
|
|
@@ -60,6 +75,8 @@ export class ScrollView implements Component {
|
|
|
60
75
|
};
|
|
61
76
|
this.#trackChar = firstCellGlyph(options.trackChar ?? DEFAULT_TRACK, DEFAULT_TRACK);
|
|
62
77
|
this.#thumbChar = firstCellGlyph(options.thumbChar ?? DEFAULT_THUMB, DEFAULT_THUMB);
|
|
78
|
+
this.#ellipsis = options.ellipsis ?? Ellipsis.Unicode;
|
|
79
|
+
this.#fastScrollLines = Math.max(1, Math.trunc(options.fastScrollLines ?? 5));
|
|
63
80
|
this.#clampScrollOffset();
|
|
64
81
|
}
|
|
65
82
|
|
|
@@ -113,11 +130,55 @@ export class ScrollView implements Component {
|
|
|
113
130
|
this.#scrollOffset = this.getMaxScrollOffset();
|
|
114
131
|
}
|
|
115
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Apply a standard navigation key to the viewport. Shift+Arrow scrolls by
|
|
135
|
+
* {@link ScrollViewOptions.fastScrollLines} (the "scroll faster" affordance);
|
|
136
|
+
* plain Arrow by one line; PageUp/PageDown by a page; Home/End to the ends.
|
|
137
|
+
* Returns true when the key was consumed, so callers can fall through to
|
|
138
|
+
* their own (e.g. vim-style) bindings. Generic on purpose: every ScrollView
|
|
139
|
+
* consumer gets the same scroll keys, including Shift-to-go-faster.
|
|
140
|
+
*/
|
|
141
|
+
handleScrollKey(data: string): boolean {
|
|
142
|
+
if (matchesKey(data, "shift+up")) {
|
|
143
|
+
this.scroll(-this.#fastScrollLines);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
if (matchesKey(data, "shift+down")) {
|
|
147
|
+
this.scroll(this.#fastScrollLines);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
if (matchesKey(data, "up")) {
|
|
151
|
+
this.scroll(-1);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
if (matchesKey(data, "down")) {
|
|
155
|
+
this.scroll(1);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
if (matchesKey(data, "pageUp")) {
|
|
159
|
+
this.page(-1);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
if (matchesKey(data, "pageDown")) {
|
|
163
|
+
this.page(1);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
if (matchesKey(data, "home")) {
|
|
167
|
+
this.scrollToTop();
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
if (matchesKey(data, "end")) {
|
|
171
|
+
this.scrollToBottom();
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
116
177
|
invalidate(): void {
|
|
117
178
|
// No cached layout to invalidate.
|
|
118
179
|
}
|
|
119
180
|
|
|
120
|
-
render(width: number): string[] {
|
|
181
|
+
render(width: number): readonly string[] {
|
|
121
182
|
this.#clampScrollOffset();
|
|
122
183
|
const safeWidth = Number.isFinite(width) ? Math.max(0, Math.trunc(width)) : 0;
|
|
123
184
|
if (this.#height === 0) return [];
|
|
@@ -128,7 +189,7 @@ export class ScrollView implements Component {
|
|
|
128
189
|
for (let row = 0; row < this.#height; row++) {
|
|
129
190
|
const sourceIndex = this.#totalRows === undefined ? this.#scrollOffset + row : row;
|
|
130
191
|
const source = this.#lines[sourceIndex] ?? "";
|
|
131
|
-
const truncated = truncateToWidth(replaceTabs(source), contentWidth,
|
|
192
|
+
const truncated = truncateToWidth(replaceTabs(source), contentWidth, this.#ellipsis);
|
|
132
193
|
if (!showScrollbar) {
|
|
133
194
|
lines.push(truncated);
|
|
134
195
|
continue;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { popLoopPhase, pushLoopPhase } from "@prometheus-ai/utils";
|
|
1
2
|
import { fuzzyFilter } from "../fuzzy";
|
|
2
3
|
import { getKeybindings } from "../keybindings";
|
|
3
4
|
import { extractPrintableText } from "../keys";
|
|
4
5
|
import type { SymbolTheme } from "../symbols";
|
|
5
6
|
import type { Component } from "../tui";
|
|
6
|
-
import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
|
|
7
|
+
import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
7
8
|
import { ScrollView } from "./scroll-view";
|
|
8
9
|
|
|
9
10
|
const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
|
|
@@ -34,6 +35,8 @@ export interface SelectListTheme {
|
|
|
34
35
|
scrollInfo: (text: string) => string;
|
|
35
36
|
noMatch: (text: string) => string;
|
|
36
37
|
symbols: SymbolTheme;
|
|
38
|
+
/** Hover band applied to the full row under the mouse pointer. */
|
|
39
|
+
hovered?: (text: string) => string;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
export interface SelectListTruncatePrimaryContext {
|
|
@@ -50,12 +53,40 @@ export interface SelectListLayoutOptions {
|
|
|
50
53
|
truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
|
|
51
54
|
/** Enable type-to-filter search when the item count exceeds maxVisible. Defaults to true. */
|
|
52
55
|
overflowSearch?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Wrap long descriptions onto continuation rows indented under the
|
|
58
|
+
* description column instead of truncating. Defaults to false so existing
|
|
59
|
+
* single-line consumers are unaffected. Navigation remains item-to-item;
|
|
60
|
+
* the scrollbar tracks visual rows so the thumb stays correct when items
|
|
61
|
+
* wrap unevenly.
|
|
62
|
+
*/
|
|
63
|
+
wrapDescription?: boolean;
|
|
53
64
|
}
|
|
54
65
|
|
|
66
|
+
type SelectItemLayout =
|
|
67
|
+
| {
|
|
68
|
+
kind: "description";
|
|
69
|
+
prefix: string;
|
|
70
|
+
truncatedValue: string;
|
|
71
|
+
spacing: string;
|
|
72
|
+
descriptionSingleLine: string;
|
|
73
|
+
descriptionStart: number;
|
|
74
|
+
remainingWidth: number;
|
|
75
|
+
}
|
|
76
|
+
| {
|
|
77
|
+
kind: "primary";
|
|
78
|
+
prefix: string;
|
|
79
|
+
truncatedValue: string;
|
|
80
|
+
spacing: "";
|
|
81
|
+
};
|
|
82
|
+
|
|
55
83
|
export class SelectList implements Component {
|
|
56
84
|
#filteredItems: ReadonlyArray<SelectItem>;
|
|
57
85
|
#filterQuery = "";
|
|
58
86
|
#selectedIndex: number = 0;
|
|
87
|
+
#hoveredIndex: number | null = null;
|
|
88
|
+
/** Per-render map of 0-based output line → filtered-item index. */
|
|
89
|
+
#hitRows: (number | undefined)[] = [];
|
|
59
90
|
|
|
60
91
|
onSelect?: (item: SelectItem) => void;
|
|
61
92
|
onCancel?: () => void;
|
|
@@ -78,12 +109,43 @@ export class SelectList implements Component {
|
|
|
78
109
|
this.#selectedIndex = Math.max(0, Math.min(index, this.#filteredItems.length - 1));
|
|
79
110
|
}
|
|
80
111
|
|
|
112
|
+
/** Resolve a 0-based rendered-line index to a filtered-item index. */
|
|
113
|
+
hitTest(line: number): number | undefined {
|
|
114
|
+
return this.#hitRows[line];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Highlight the item under the pointer (null clears). */
|
|
118
|
+
setHoverIndex(index: number | null): void {
|
|
119
|
+
this.#hoveredIndex = index;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Move the selection one step for a wheel notch. */
|
|
123
|
+
handleWheel(delta: -1 | 1): void {
|
|
124
|
+
if (this.#filteredItems.length === 0) return;
|
|
125
|
+
const next = clamp(this.#selectedIndex + delta, 0, this.#filteredItems.length - 1);
|
|
126
|
+
if (next === this.#selectedIndex) return;
|
|
127
|
+
this.#selectedIndex = next;
|
|
128
|
+
this.#notifySelectionChange();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Mouse click: select the item under the pointer and confirm it. */
|
|
132
|
+
clickItem(index: number): void {
|
|
133
|
+
const item = this.#filteredItems[index];
|
|
134
|
+
if (!item) return;
|
|
135
|
+
if (index !== this.#selectedIndex) {
|
|
136
|
+
this.#selectedIndex = index;
|
|
137
|
+
this.#notifySelectionChange();
|
|
138
|
+
}
|
|
139
|
+
this.onSelect?.(item);
|
|
140
|
+
}
|
|
141
|
+
|
|
81
142
|
invalidate(): void {
|
|
82
143
|
// No cached state to invalidate currently
|
|
83
144
|
}
|
|
84
145
|
|
|
85
|
-
render(width: number): string[] {
|
|
146
|
+
render(width: number): readonly string[] {
|
|
86
147
|
const lines: string[] = [];
|
|
148
|
+
this.#hitRows = [];
|
|
87
149
|
const showSearchStatus = this.#shouldRenderSearchStatus();
|
|
88
150
|
|
|
89
151
|
// If no items match filter, show message
|
|
@@ -96,34 +158,60 @@ export class SelectList implements Component {
|
|
|
96
158
|
}
|
|
97
159
|
|
|
98
160
|
const primaryColumnWidth = this.#getPrimaryColumnWidth();
|
|
161
|
+
const wrapEnabled = this.layout.wrapDescription === true;
|
|
162
|
+
// `maxVisible` is the picker's visual row budget. For non-wrap layouts
|
|
163
|
+
// every item is one row, so the budget matches the original item count.
|
|
164
|
+
const visualBudget = this.maxVisible;
|
|
165
|
+
|
|
166
|
+
// Compute per-item visual row counts at the conservative width (i.e.
|
|
167
|
+
// assume the scrollbar column might be reserved). For non-wrap layouts
|
|
168
|
+
// every count is 1, so visualTotal == #filteredItems and overflow falls
|
|
169
|
+
// back to the original `N > maxVisible` predicate exactly.
|
|
170
|
+
const conservativeRowWidth = Math.max(0, width - 1);
|
|
171
|
+
const rowCounts = new Array<number>(this.#filteredItems.length);
|
|
172
|
+
let visualTotal = 0;
|
|
173
|
+
for (let i = 0; i < this.#filteredItems.length; i++) {
|
|
174
|
+
const item = this.#filteredItems[i];
|
|
175
|
+
if (!item) {
|
|
176
|
+
rowCounts[i] = 0;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
rowCounts[i] = wrapEnabled ? this.#computeItemRowCount(item, conservativeRowWidth, primaryColumnWidth) : 1;
|
|
180
|
+
visualTotal += rowCounts[i];
|
|
181
|
+
}
|
|
99
182
|
|
|
100
|
-
|
|
101
|
-
const startIndex = Math.max(
|
|
102
|
-
0,
|
|
103
|
-
Math.min(this.#selectedIndex - Math.floor(this.maxVisible / 2), this.#filteredItems.length - this.maxVisible),
|
|
104
|
-
);
|
|
105
|
-
const endIndex = Math.min(startIndex + this.maxVisible, this.#filteredItems.length);
|
|
106
|
-
|
|
107
|
-
// Render visible items
|
|
108
|
-
const overflow = this.#filteredItems.length > this.maxVisible;
|
|
183
|
+
const overflow = visualTotal > visualBudget;
|
|
109
184
|
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
|
|
185
|
+
|
|
186
|
+
// Pick a window centered on the selected item that fits in visualBudget
|
|
187
|
+
// rows. Falls through to the original item-count window when every row
|
|
188
|
+
// count is 1.
|
|
189
|
+
const { startIndex, endIndex, visualOffset } = this.#pickWindow(rowCounts, visualBudget);
|
|
190
|
+
|
|
191
|
+
// Render visible items. Cap rows at the budget so a single item that
|
|
192
|
+
// wraps to more than `visualBudget` rows (pathological — e.g. a 5-row
|
|
193
|
+
// description with maxVisible=3) still keeps the popup bounded; the
|
|
194
|
+
// scrollbar carries the offscreen rows.
|
|
110
195
|
const rows: string[] = [];
|
|
111
|
-
for (let i = startIndex; i < endIndex; i++) {
|
|
196
|
+
for (let i = startIndex; i < endIndex && rows.length < visualBudget; i++) {
|
|
112
197
|
const item = this.#filteredItems[i];
|
|
113
198
|
if (!item) continue;
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
199
|
+
const hovered = this.theme.hovered !== undefined && i === this.#hoveredIndex && i !== this.#selectedIndex;
|
|
200
|
+
const itemRows = this.#renderItem(item, i === this.#selectedIndex, rowWidth, primaryColumnWidth);
|
|
201
|
+
for (const row of itemRows) {
|
|
202
|
+
if (rows.length >= visualBudget) break;
|
|
203
|
+
this.#hitRows[rows.length] = i;
|
|
204
|
+
rows.push(hovered && this.theme.hovered ? this.theme.hovered(row) : row);
|
|
205
|
+
}
|
|
118
206
|
}
|
|
119
207
|
|
|
120
208
|
const sv = new ScrollView(rows, {
|
|
121
209
|
height: rows.length,
|
|
122
210
|
scrollbar: "auto",
|
|
123
|
-
totalRows:
|
|
211
|
+
totalRows: visualTotal,
|
|
124
212
|
theme: { track: t => this.theme.scrollInfo(t), thumb: t => this.theme.selectedPrefix(t) },
|
|
125
213
|
});
|
|
126
|
-
sv.setScrollOffset(
|
|
214
|
+
sv.setScrollOffset(visualOffset);
|
|
127
215
|
lines.push(...sv.render(width));
|
|
128
216
|
|
|
129
217
|
// Add search status when relevant (scrollbar now indicates overflow)
|
|
@@ -178,17 +266,112 @@ export class SelectList implements Component {
|
|
|
178
266
|
}
|
|
179
267
|
}
|
|
180
268
|
|
|
181
|
-
#renderItem(
|
|
269
|
+
#renderItem(item: SelectItem, isSelected: boolean, width: number, primaryColumnWidth: number): string[] {
|
|
270
|
+
const layout = this.#computeItemLayout(item, isSelected, width, primaryColumnWidth);
|
|
271
|
+
const { prefix, truncatedValue, spacing } = layout;
|
|
272
|
+
|
|
273
|
+
if (layout.kind === "description") {
|
|
274
|
+
const { descriptionSingleLine, descriptionStart, remainingWidth } = layout;
|
|
275
|
+
if (this.layout.wrapDescription) {
|
|
276
|
+
const wrapped = wrapTextWithAnsi(descriptionSingleLine, remainingWidth);
|
|
277
|
+
if (wrapped.length === 0) wrapped.push("");
|
|
278
|
+
const indent = padding(descriptionStart);
|
|
279
|
+
const first = wrapped[0] ?? "";
|
|
280
|
+
if (isSelected) {
|
|
281
|
+
const rows = [this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${first}`)];
|
|
282
|
+
for (let i = 1; i < wrapped.length; i++) {
|
|
283
|
+
rows.push(this.theme.selectedText(`${indent}${wrapped[i]}`));
|
|
284
|
+
}
|
|
285
|
+
return rows;
|
|
286
|
+
}
|
|
287
|
+
const rows = [prefix + truncatedValue + this.theme.description(spacing + first)];
|
|
288
|
+
for (let i = 1; i < wrapped.length; i++) {
|
|
289
|
+
rows.push(this.theme.description(`${indent}${wrapped[i]}`));
|
|
290
|
+
}
|
|
291
|
+
return rows;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
|
|
295
|
+
if (isSelected) {
|
|
296
|
+
return [this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`)];
|
|
297
|
+
}
|
|
298
|
+
return [prefix + truncatedValue + this.theme.description(spacing + truncatedDesc)];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (isSelected) {
|
|
302
|
+
return [this.theme.selectedText(`${prefix}${truncatedValue}`)];
|
|
303
|
+
}
|
|
304
|
+
return [prefix + truncatedValue];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#computeItemRowCount(item: SelectItem, width: number, primaryColumnWidth: number): number {
|
|
308
|
+
// Selection style does not change row count; pass isSelected=false to
|
|
309
|
+
// keep the cheap path uniform for items outside the visible window.
|
|
310
|
+
const layout = this.#computeItemLayout(item, false, width, primaryColumnWidth);
|
|
311
|
+
if (layout.kind !== "description") return 1;
|
|
312
|
+
const wrapped = wrapTextWithAnsi(layout.descriptionSingleLine, layout.remainingWidth);
|
|
313
|
+
return Math.max(1, wrapped.length);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Pick a contiguous window of items containing `selectedIndex` such that
|
|
318
|
+
* their visual rows fit within `budget`. Centers the selection roughly
|
|
319
|
+
* mid-window: first expands up by ⌊budget/2⌋ rows, then fills downward,
|
|
320
|
+
* then back upward with any remaining budget. For non-wrap layouts (every
|
|
321
|
+
* `rowCounts[i] === 1`) this resolves to the same `[start, start+maxVisible)`
|
|
322
|
+
* window the prior arithmetic produced.
|
|
323
|
+
*/
|
|
324
|
+
#pickWindow(
|
|
325
|
+
rowCounts: ReadonlyArray<number>,
|
|
326
|
+
budget: number,
|
|
327
|
+
): { startIndex: number; endIndex: number; visualOffset: number } {
|
|
328
|
+
const n = rowCounts.length;
|
|
329
|
+
const selected = Math.max(0, Math.min(this.#selectedIndex, n - 1));
|
|
330
|
+
if (n === 0) return { startIndex: 0, endIndex: 0, visualOffset: 0 };
|
|
331
|
+
|
|
332
|
+
const half = Math.floor(budget / 2);
|
|
333
|
+
let lo = selected;
|
|
334
|
+
let rowsAboveSelected = 0;
|
|
335
|
+
// Step 1: expand upward up to `half` rows above the selection so it
|
|
336
|
+
// lands near the visual middle, matching the prior centering.
|
|
337
|
+
while (lo > 0 && rowsAboveSelected + (rowCounts[lo - 1] ?? 0) <= half) {
|
|
338
|
+
lo--;
|
|
339
|
+
rowsAboveSelected += rowCounts[lo] ?? 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Step 2: expand downward until the budget is filled. The selected
|
|
343
|
+
// item's own rows are always counted; if it alone exceeds `budget`
|
|
344
|
+
// the surplus is clipped at render time and the scrollbar carries it.
|
|
345
|
+
let hi = selected + 1;
|
|
346
|
+
let used = rowsAboveSelected + (rowCounts[selected] ?? 0);
|
|
347
|
+
while (hi < n && used + (rowCounts[hi] ?? 0) <= budget) {
|
|
348
|
+
used += rowCounts[hi] ?? 0;
|
|
349
|
+
hi++;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Step 3: if room remains (selection sat near the bottom), keep
|
|
353
|
+
// expanding upward.
|
|
354
|
+
while (lo > 0 && used + (rowCounts[lo - 1] ?? 0) <= budget) {
|
|
355
|
+
lo--;
|
|
356
|
+
used += rowCounts[lo] ?? 0;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let visualOffset = 0;
|
|
360
|
+
for (let i = 0; i < lo; i++) visualOffset += rowCounts[i] ?? 0;
|
|
361
|
+
return { startIndex: lo, endIndex: hi, visualOffset };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
#computeItemLayout(
|
|
182
365
|
item: SelectItem,
|
|
183
366
|
isSelected: boolean,
|
|
184
367
|
width: number,
|
|
185
|
-
descriptionSingleLine: string | undefined,
|
|
186
368
|
primaryColumnWidth: number,
|
|
187
|
-
):
|
|
369
|
+
): SelectItemLayout {
|
|
188
370
|
const prefix = isSelected
|
|
189
371
|
? `${this.theme.symbols.cursor} `
|
|
190
372
|
: padding(visibleWidth(this.theme.symbols.cursor) + 1);
|
|
191
373
|
const prefixWidth = visibleWidth(prefix);
|
|
374
|
+
const descriptionSingleLine = item.description ? sanitizeSingleLine(item.description) : undefined;
|
|
192
375
|
|
|
193
376
|
if (descriptionSingleLine && width > 40) {
|
|
194
377
|
const effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4));
|
|
@@ -200,23 +383,26 @@ export class SelectList implements Component {
|
|
|
200
383
|
const remainingWidth = width - descriptionStart - 2; // -2 for safety
|
|
201
384
|
|
|
202
385
|
if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
386
|
+
return {
|
|
387
|
+
kind: "description",
|
|
388
|
+
prefix,
|
|
389
|
+
truncatedValue,
|
|
390
|
+
spacing,
|
|
391
|
+
descriptionSingleLine,
|
|
392
|
+
descriptionStart,
|
|
393
|
+
remainingWidth,
|
|
394
|
+
};
|
|
210
395
|
}
|
|
211
396
|
}
|
|
212
397
|
|
|
213
|
-
const
|
|
214
|
-
const truncatedValue = this.#truncatePrimary(item, isSelected,
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
398
|
+
const fallbackMax = width - prefixWidth - 2;
|
|
399
|
+
const truncatedValue = this.#truncatePrimary(item, isSelected, fallbackMax, fallbackMax);
|
|
400
|
+
return {
|
|
401
|
+
kind: "primary",
|
|
402
|
+
prefix,
|
|
403
|
+
truncatedValue,
|
|
404
|
+
spacing: "",
|
|
405
|
+
};
|
|
220
406
|
}
|
|
221
407
|
|
|
222
408
|
#getPrimaryColumnWidth(): number {
|
|
@@ -297,9 +483,18 @@ export class SelectList implements Component {
|
|
|
297
483
|
|
|
298
484
|
#setFilter(filter: string, notify: boolean): void {
|
|
299
485
|
this.#filterQuery = filter;
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
486
|
+
if (filter.trim()) {
|
|
487
|
+
// Breadcrumb the fuzzy match so the loop watchdog can attribute a
|
|
488
|
+
// large-list filter stall instead of logging it as "unknown".
|
|
489
|
+
pushLoopPhase("ui.select-filter");
|
|
490
|
+
try {
|
|
491
|
+
this.#filteredItems = fuzzyFilter([...this.items], filter, item => this.#getFilterText(item));
|
|
492
|
+
} finally {
|
|
493
|
+
popLoopPhase();
|
|
494
|
+
}
|
|
495
|
+
} else {
|
|
496
|
+
this.#filteredItems = this.items;
|
|
497
|
+
}
|
|
303
498
|
this.#selectedIndex = 0;
|
|
304
499
|
if (notify) {
|
|
305
500
|
this.#notifySelectionChange();
|