@prometheus-ai/tui 0.5.4 → 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,8 +1,18 @@
|
|
|
1
|
+
import { fuzzyFilter } from "../fuzzy";
|
|
1
2
|
import { getKeybindings } from "../keybindings";
|
|
3
|
+
import { extractPrintableText } from "../keys";
|
|
4
|
+
import type { MouseRoutable, SgrMouseEvent } from "../mouse";
|
|
2
5
|
import type { Component } from "../tui";
|
|
3
|
-
import { Ellipsis, padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
6
|
+
import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
4
7
|
import { ScrollView } from "./scroll-view";
|
|
5
8
|
|
|
9
|
+
function sanitizeSingleLine(text: string): string {
|
|
10
|
+
return replaceTabs(text)
|
|
11
|
+
.replace(/[\r\n]+/g, " ")
|
|
12
|
+
.replace(/\s+/g, " ")
|
|
13
|
+
.trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
6
16
|
export interface SettingItem {
|
|
7
17
|
/** Unique identifier for this setting */
|
|
8
18
|
id: string;
|
|
@@ -18,6 +28,8 @@ export interface SettingItem {
|
|
|
18
28
|
submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
|
|
19
29
|
/** True when the displayed setting differs from its default value. */
|
|
20
30
|
changed?: boolean;
|
|
31
|
+
/** Render as a non-interactive section heading. Skipped by navigation and search. */
|
|
32
|
+
heading?: boolean;
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
export interface SettingsListTheme {
|
|
@@ -26,133 +38,650 @@ export interface SettingsListTheme {
|
|
|
26
38
|
description: (text: string) => string;
|
|
27
39
|
cursor: string;
|
|
28
40
|
hint: (text: string) => string;
|
|
41
|
+
/** Style for section heading rows (dimmed when outside the active section). Falls back to `hint` when omitted. */
|
|
42
|
+
heading?: (text: string, dimmed: boolean) => string;
|
|
43
|
+
/** Style for sidebar section names in the split layout. Falls back to label/hint. */
|
|
44
|
+
section?: (text: string, active: boolean) => string;
|
|
45
|
+
/** Hover band applied to the full row under the mouse pointer. */
|
|
46
|
+
hovered?: (text: string) => string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A contiguous run of items under one heading, derived from the item list. */
|
|
50
|
+
interface SettingSection {
|
|
51
|
+
name: string;
|
|
52
|
+
firstItemIndex: number;
|
|
53
|
+
lastItemIndex: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Optional behavior overrides for {@link SettingsList}. */
|
|
57
|
+
export interface SettingsListOptions {
|
|
58
|
+
/**
|
|
59
|
+
* "auto" (default) renders the section sidebar layout when headings exist
|
|
60
|
+
* and the width allows; "flat" always renders inline heading rows.
|
|
61
|
+
*/
|
|
62
|
+
layout?: "auto" | "flat";
|
|
63
|
+
/**
|
|
64
|
+
* When false, printable input is ignored (no internal type-to-filter) and
|
|
65
|
+
* the search status line is never rendered. Use when a parent component
|
|
66
|
+
* owns the query. Default true.
|
|
67
|
+
*/
|
|
68
|
+
typeToSearch?: boolean;
|
|
69
|
+
/** Text shown when the list has no items at all. */
|
|
70
|
+
emptyText?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Footer hint line (hint-styled, replaces the default navigation hint).
|
|
73
|
+
* An empty string removes the hint row and its leading blank entirely —
|
|
74
|
+
* use when the host renders its own footer.
|
|
75
|
+
*/
|
|
76
|
+
hint?: string;
|
|
77
|
+
/** Fixed split-sidebar width (columns incl. indent+gap); default derives from section names. */
|
|
78
|
+
sidebarWidth?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Searchable text for a setting item: label, id, value, description, and cycle values. */
|
|
82
|
+
export function getSettingItemFilterText(item: SettingItem): string {
|
|
83
|
+
let text = `${item.label} ${item.id} ${item.currentValue}`;
|
|
84
|
+
if (item.description) {
|
|
85
|
+
text += ` ${item.description}`;
|
|
86
|
+
}
|
|
87
|
+
if (item.values) {
|
|
88
|
+
text += ` ${item.values.join(" ")}`;
|
|
89
|
+
}
|
|
90
|
+
return sanitizeSingleLine(text);
|
|
29
91
|
}
|
|
30
92
|
|
|
31
93
|
export class SettingsList implements Component {
|
|
32
94
|
#items: SettingItem[];
|
|
95
|
+
#filteredItems: SettingItem[];
|
|
33
96
|
#theme: SettingsListTheme;
|
|
34
97
|
#selectedIndex = 0;
|
|
35
98
|
#maxVisible: number;
|
|
36
99
|
#onChange: (id: string, newValue: string) => void;
|
|
37
100
|
#onCancel: () => void;
|
|
101
|
+
#options: SettingsListOptions;
|
|
102
|
+
#filterQuery = "";
|
|
103
|
+
#sectionFocus = false;
|
|
104
|
+
#lastNotifiedSelectionId: string | undefined;
|
|
105
|
+
|
|
106
|
+
/** Fired when the selected item changes (navigation, filtering, or setItems). */
|
|
107
|
+
onSelectionChange?: (item: SettingItem | undefined) => void;
|
|
38
108
|
|
|
39
109
|
// Submenu state
|
|
40
110
|
#submenuComponent: Component | null = null;
|
|
41
|
-
#
|
|
42
|
-
|
|
111
|
+
#submenuItemId: string | null = null;
|
|
112
|
+
// Mouse support: hover highlight and per-render hit maps (content-line
|
|
113
|
+
// index → item id), rebuilt by every main-list render.
|
|
114
|
+
#hoveredItemId: string | null = null;
|
|
115
|
+
#hitRows: (string | undefined)[] = [];
|
|
116
|
+
#sidebarHitRows: (string | undefined)[] = [];
|
|
117
|
+
#sidebarHitCol = 0;
|
|
43
118
|
constructor(
|
|
44
119
|
items: SettingItem[],
|
|
45
120
|
maxVisible: number,
|
|
46
121
|
theme: SettingsListTheme,
|
|
47
122
|
onChange: (id: string, newValue: string) => void,
|
|
48
123
|
onCancel: () => void,
|
|
124
|
+
options: SettingsListOptions = {},
|
|
49
125
|
) {
|
|
50
126
|
this.#items = items;
|
|
127
|
+
this.#filteredItems = items;
|
|
51
128
|
this.#maxVisible = maxVisible;
|
|
52
129
|
this.#theme = theme;
|
|
53
130
|
this.#onChange = onChange;
|
|
54
131
|
this.#onCancel = onCancel;
|
|
132
|
+
this.#options = options;
|
|
133
|
+
this.#selectedIndex = this.#firstSelectableIndex();
|
|
134
|
+
this.#lastNotifiedSelectionId = this.getSelectedItem()?.id;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The currently selected item, or undefined when empty or on a heading. */
|
|
138
|
+
getSelectedItem(): SettingItem | undefined {
|
|
139
|
+
const item = this.#filteredItems[this.#selectedIndex];
|
|
140
|
+
return item && !item.heading ? item : undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Move selection to the item with `id`. Returns false when it is not visible. */
|
|
144
|
+
selectItem(id: string): boolean {
|
|
145
|
+
const index = this.#filteredItems.findIndex(item => !item.heading && item.id === id);
|
|
146
|
+
if (index === -1) return false;
|
|
147
|
+
this.#sectionFocus = false;
|
|
148
|
+
this.#selectedIndex = index;
|
|
149
|
+
this.#notifySelection();
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** True while keyboard focus is on the section headings instead of the setting rows. */
|
|
154
|
+
get sectionFocused(): boolean {
|
|
155
|
+
return this.#sectionFocus;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Whether section focus has anywhere to go: 2+ derived sections in the current view. */
|
|
159
|
+
hasSectionFocusTargets(): boolean {
|
|
160
|
+
return this.#sections().length >= 2;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Toggle keyboard focus between section headings and setting rows. While
|
|
165
|
+
* focused, Up/Down jump whole sections and Enter/Esc return to the rows.
|
|
166
|
+
* Engages only when {@link hasSectionFocusTargets}; returns the new state.
|
|
167
|
+
*/
|
|
168
|
+
toggleSectionFocus(): boolean {
|
|
169
|
+
this.#sectionFocus = !this.#sectionFocus && this.hasSectionFocusTargets();
|
|
170
|
+
return this.#sectionFocus;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** True while an item submenu owns input. */
|
|
174
|
+
hasOpenSubmenu(): boolean {
|
|
175
|
+
return this.#submenuComponent !== null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#notifySelection(): void {
|
|
179
|
+
const item = this.getSelectedItem();
|
|
180
|
+
if (item?.id === this.#lastNotifiedSelectionId) return;
|
|
181
|
+
this.#lastNotifiedSelectionId = item?.id;
|
|
182
|
+
this.onSelectionChange?.(item);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Resize the visible viewport (fullscreen hosts call this every render). */
|
|
186
|
+
setMaxVisible(rows: number): void {
|
|
187
|
+
const next = Math.max(3, Math.floor(rows));
|
|
188
|
+
if (next === this.#maxVisible) return;
|
|
189
|
+
this.#maxVisible = next;
|
|
190
|
+
this.#clampSelectedIndex();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Move the selection one step for a wheel notch. */
|
|
194
|
+
handleWheel(delta: -1 | 1): void {
|
|
195
|
+
if (this.#submenuComponent) return;
|
|
196
|
+
// Wheel is row-level interaction: it returns focus to the rows.
|
|
197
|
+
this.#sectionFocus = false;
|
|
198
|
+
this.#moveSelection(delta);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Highlight the item under the pointer (null clears). */
|
|
202
|
+
setHoverItem(id: string | null): void {
|
|
203
|
+
this.#hoveredItemId = id;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolve a pointer position against the last rendered frame. `line` is the
|
|
208
|
+
* 0-based content-line index within this component's render output, `col`
|
|
209
|
+
* the 0-based column. Sidebar rows resolve to the section's first item.
|
|
210
|
+
*/
|
|
211
|
+
hitTest(line: number, col: number): string | undefined {
|
|
212
|
+
if (this.#submenuComponent) return undefined;
|
|
213
|
+
if (this.#sidebarHitCol > 0 && col < this.#sidebarHitCol) {
|
|
214
|
+
return this.#sidebarHitRows[line];
|
|
215
|
+
}
|
|
216
|
+
return this.#hitRows[line];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Like {@link hitTest}, but only rows the pointer is visually on: sidebar
|
|
221
|
+
* jump targets are excluded so hovering section names does not light up
|
|
222
|
+
* pane rows.
|
|
223
|
+
*/
|
|
224
|
+
hoverTest(line: number, col: number): string | undefined {
|
|
225
|
+
if (this.#submenuComponent) return undefined;
|
|
226
|
+
if (this.#sidebarHitCol > 0 && col < this.#sidebarHitCol) return undefined;
|
|
227
|
+
return this.#hitRows[line];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Route a mouse event into an open submenu (coordinates are local to this
|
|
232
|
+
* list's rendered lines). Returns false when no submenu is open; submenus
|
|
233
|
+
* that do not implement {@link MouseRoutable} consume the event silently.
|
|
234
|
+
*/
|
|
235
|
+
routeSubmenuMouse(event: SgrMouseEvent, line: number, col: number): boolean {
|
|
236
|
+
if (!this.#submenuComponent) return false;
|
|
237
|
+
(this.#submenuComponent as Component & Partial<MouseRoutable>).routeMouse?.(event, line, col);
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
getSearchQuery(): string {
|
|
242
|
+
return this.#filterQuery;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
hasSearchQuery(): boolean {
|
|
246
|
+
return this.#filterQuery.length > 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
clearSearch(): void {
|
|
250
|
+
if (this.#filterQuery.length === 0) return;
|
|
251
|
+
this.#setFilter("");
|
|
55
252
|
}
|
|
56
253
|
|
|
57
254
|
/** Update an item's currentValue */
|
|
58
255
|
updateValue(id: string, newValue: string): void {
|
|
59
256
|
const item = this.#items.find(i => i.id === id);
|
|
60
|
-
if (item)
|
|
61
|
-
|
|
257
|
+
if (!item) return;
|
|
258
|
+
|
|
259
|
+
item.currentValue = newValue;
|
|
260
|
+
if (this.#filterQuery.trim()) {
|
|
261
|
+
this.#applyFilter();
|
|
262
|
+
this.#clampSelectedIndex();
|
|
62
263
|
}
|
|
63
264
|
}
|
|
64
265
|
|
|
65
266
|
/**
|
|
66
|
-
* Replace the entire items array. Selection is preserved
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* is
|
|
70
|
-
*
|
|
267
|
+
* Replace the entire items array. Selection is preserved by item id when
|
|
268
|
+
* the previous selection still survives the active filter, otherwise
|
|
269
|
+
* clamped to the last filtered item (or 0 if there are no matches).
|
|
270
|
+
* An open submenu is left untouched — its lifetime is bounded by its own
|
|
271
|
+
* done callback, and `#closeSubmenu` re-resolves the restored item on exit.
|
|
71
272
|
*/
|
|
72
273
|
setItems(items: SettingItem[]): void {
|
|
274
|
+
const selectedId = this.#filteredItems[this.#selectedIndex]?.id;
|
|
73
275
|
this.#items = items;
|
|
74
|
-
|
|
276
|
+
this.#applyFilter();
|
|
277
|
+
if (this.#sectionFocus && !this.hasSectionFocusTargets()) this.#sectionFocus = false;
|
|
278
|
+
|
|
279
|
+
const nextIndex = selectedId ? this.#filteredItems.findIndex(item => item.id === selectedId) : -1;
|
|
280
|
+
if (nextIndex >= 0) {
|
|
281
|
+
this.#selectedIndex = nextIndex;
|
|
282
|
+
} else {
|
|
283
|
+
this.#clampSelectedIndex();
|
|
284
|
+
}
|
|
285
|
+
this.#notifySelection();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#setFilter(filter: string): void {
|
|
289
|
+
this.#filterQuery = filter;
|
|
290
|
+
if (filter.trim()) this.#sectionFocus = false;
|
|
291
|
+
this.#applyFilter();
|
|
292
|
+
this.#selectedIndex = this.#firstSelectableIndex();
|
|
293
|
+
this.#notifySelection();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
#applyFilter(): void {
|
|
297
|
+
this.#filteredItems = this.#filterQuery.trim()
|
|
298
|
+
? fuzzyFilter(
|
|
299
|
+
this.#items.filter(item => !item.heading),
|
|
300
|
+
this.#filterQuery,
|
|
301
|
+
getSettingItemFilterText,
|
|
302
|
+
)
|
|
303
|
+
: this.#items;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
#firstSelectableIndex(): number {
|
|
307
|
+
const index = this.#filteredItems.findIndex(item => !item.heading);
|
|
308
|
+
return index >= 0 ? index : 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Move selection by one selectable item, wrapping and skipping headings. */
|
|
312
|
+
#moveSelection(delta: -1 | 1): void {
|
|
313
|
+
const len = this.#filteredItems.length;
|
|
314
|
+
if (len === 0) return;
|
|
315
|
+
let index = this.#selectedIndex;
|
|
316
|
+
for (let step = 0; step < len; step++) {
|
|
317
|
+
index = (index + delta + len) % len;
|
|
318
|
+
if (!this.#filteredItems[index]?.heading) {
|
|
319
|
+
this.#selectedIndex = index;
|
|
320
|
+
this.#notifySelection();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Sections derived from heading rows in the filtered list. */
|
|
327
|
+
#sections(): SettingSection[] {
|
|
328
|
+
const sections: SettingSection[] = [];
|
|
329
|
+
let current: SettingSection | null = null;
|
|
330
|
+
for (let i = 0; i < this.#filteredItems.length; i++) {
|
|
331
|
+
const item = this.#filteredItems[i];
|
|
332
|
+
if (item.heading) {
|
|
333
|
+
current = { name: item.label, firstItemIndex: -1, lastItemIndex: -1 };
|
|
334
|
+
sections.push(current);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (!current) {
|
|
338
|
+
current = { name: "", firstItemIndex: i, lastItemIndex: i };
|
|
339
|
+
sections.push(current);
|
|
340
|
+
}
|
|
341
|
+
if (current.firstItemIndex < 0) current.firstItemIndex = i;
|
|
342
|
+
current.lastItemIndex = i;
|
|
343
|
+
}
|
|
344
|
+
return sections.filter(section => section.firstItemIndex >= 0);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
#activeSectionIndex(sections: SettingSection[]): number {
|
|
348
|
+
for (let i = sections.length - 1; i >= 0; i--) {
|
|
349
|
+
if (sections[i].firstItemIndex <= this.#selectedIndex) return i;
|
|
350
|
+
}
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Jump to the next/previous section; page through items when there are no sections. */
|
|
355
|
+
#jumpSection(delta: -1 | 1): void {
|
|
356
|
+
const sections = this.#sections();
|
|
357
|
+
if (sections.length < 2) {
|
|
358
|
+
const len = this.#filteredItems.length;
|
|
359
|
+
if (len === 0) return;
|
|
360
|
+
this.#selectedIndex = Math.max(0, Math.min(this.#selectedIndex + delta * this.#maxVisible, len - 1));
|
|
361
|
+
this.#clampSelectedIndex();
|
|
362
|
+
} else {
|
|
363
|
+
const next = (this.#activeSectionIndex(sections) + delta + sections.length) % sections.length;
|
|
364
|
+
this.#selectedIndex = sections[next].firstItemIndex;
|
|
365
|
+
}
|
|
366
|
+
this.#notifySelection();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
#clampSelectedIndex(): void {
|
|
370
|
+
if (this.#filteredItems.length === 0) {
|
|
75
371
|
this.#selectedIndex = 0;
|
|
76
|
-
|
|
77
|
-
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
this.#selectedIndex = Math.max(0, Math.min(this.#selectedIndex, this.#filteredItems.length - 1));
|
|
375
|
+
if (!this.#filteredItems[this.#selectedIndex]?.heading) return;
|
|
376
|
+
// Landed on a heading: prefer the next selectable item, else the previous one.
|
|
377
|
+
for (let i = this.#selectedIndex + 1; i < this.#filteredItems.length; i++) {
|
|
378
|
+
if (!this.#filteredItems[i].heading) {
|
|
379
|
+
this.#selectedIndex = i;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
for (let i = this.#selectedIndex - 1; i >= 0; i--) {
|
|
384
|
+
if (!this.#filteredItems[i].heading) {
|
|
385
|
+
this.#selectedIndex = i;
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
78
388
|
}
|
|
79
389
|
}
|
|
80
390
|
|
|
391
|
+
#renderSearchStatus(width: number): string {
|
|
392
|
+
const query = sanitizeSingleLine(this.#filterQuery);
|
|
393
|
+
const statusText = query ? ` Search: ${query}` : " Type to search";
|
|
394
|
+
return this.#theme.hint(truncateToWidth(statusText, width, Ellipsis.Omit));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
#shouldRenderSearchStatus(): boolean {
|
|
398
|
+
if (this.#options.typeToSearch === false) return false;
|
|
399
|
+
return this.#items.length > this.#maxVisible || this.#filterQuery.length > 0;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
#handleSearchInput(data: string): boolean {
|
|
403
|
+
if (this.#options.typeToSearch === false) return false;
|
|
404
|
+
if (this.#items.length === 0) return false;
|
|
405
|
+
|
|
406
|
+
const kb = getKeybindings();
|
|
407
|
+
if (kb.matches(data, "tui.editor.deleteCharBackward")) {
|
|
408
|
+
if (this.#filterQuery.length === 0) return false;
|
|
409
|
+
const chars = [...this.#filterQuery];
|
|
410
|
+
chars.pop();
|
|
411
|
+
this.#setFilter(chars.join(""));
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const printableText = extractPrintableText(data);
|
|
416
|
+
if (printableText === undefined) return false;
|
|
417
|
+
if (this.#filterQuery.length === 0 && printableText.trim().length === 0) return false;
|
|
418
|
+
|
|
419
|
+
this.#setFilter(this.#filterQuery + printableText);
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
|
|
81
423
|
invalidate(): void {
|
|
82
424
|
this.#submenuComponent?.invalidate?.();
|
|
83
425
|
}
|
|
84
426
|
|
|
85
|
-
|
|
86
|
-
|
|
427
|
+
/**
|
|
428
|
+
* Every render path is padded to the same stable height so interacting with
|
|
429
|
+
* the list (navigating sections, opening submenus, filtering, condition-gated
|
|
430
|
+
* rows appearing) never resizes the panel. A live region that thrashes its
|
|
431
|
+
* height forces the terminal to re-anchor and can strand scrollback rows.
|
|
432
|
+
*/
|
|
433
|
+
#stableHeight(): number {
|
|
434
|
+
// viewport + blank + 3 description rows, plus the optional search status
|
|
435
|
+
// row and the optional blank+hint footer.
|
|
436
|
+
let height = this.#maxVisible + 4;
|
|
437
|
+
if (this.#options.typeToSearch !== false) height += 1;
|
|
438
|
+
if (this.#options.hint !== "") height += 2;
|
|
439
|
+
return height;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
#padLines(lines: string[]): string[] {
|
|
443
|
+
while (lines.length < this.#stableHeight()) lines.push("");
|
|
444
|
+
return lines;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
render(width: number): readonly string[] {
|
|
448
|
+
// Hit maps describe exactly the frame being produced now.
|
|
449
|
+
this.#hitRows = [];
|
|
450
|
+
this.#sidebarHitRows = [];
|
|
451
|
+
this.#sidebarHitCol = 0;
|
|
452
|
+
// If submenu is active, render it instead (padded to the list's stable
|
|
453
|
+
// height so opening/closing a submenu does not resize the panel).
|
|
87
454
|
if (this.#submenuComponent) {
|
|
88
|
-
return this.#submenuComponent.render(width);
|
|
455
|
+
return this.#padLines([...this.#submenuComponent.render(width)]);
|
|
89
456
|
}
|
|
90
457
|
|
|
91
|
-
return this.#renderMainList(width);
|
|
458
|
+
return this.#padLines(this.#renderMainList(width));
|
|
92
459
|
}
|
|
93
460
|
|
|
94
|
-
#renderItemRow(
|
|
95
|
-
|
|
461
|
+
#renderItemRow(
|
|
462
|
+
item: SettingItem,
|
|
463
|
+
index: number,
|
|
464
|
+
maxLabelWidth: number,
|
|
465
|
+
rowWidth: number,
|
|
466
|
+
dimmed = false,
|
|
467
|
+
headingCursor = false,
|
|
468
|
+
): string {
|
|
469
|
+
if (item.heading) {
|
|
470
|
+
const headingStyle = this.#theme.heading ?? ((text: string) => this.#theme.hint(text));
|
|
471
|
+
const prefix = headingCursor ? this.#theme.cursor : " ";
|
|
472
|
+
return truncateToWidth(`${prefix}${headingStyle(item.label, dimmed)}`, Math.max(0, rowWidth));
|
|
473
|
+
}
|
|
474
|
+
// While section focus owns the keyboard, the row cursor hides so the
|
|
475
|
+
// section cursor is the single focus indicator.
|
|
476
|
+
const isSelected = index === this.#selectedIndex && !this.#sectionFocus;
|
|
96
477
|
const prefix = isSelected ? this.#theme.cursor : " ";
|
|
97
478
|
const prefixWidth = visibleWidth(prefix);
|
|
98
479
|
const labelPadded = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
|
|
99
|
-
const labelText = this.#theme.label(labelPadded, isSelected, item.changed === true);
|
|
100
480
|
const separator = " ";
|
|
101
481
|
const valueMaxWidth = rowWidth - prefixWidth - maxLabelWidth - visibleWidth(separator) - 2;
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
)
|
|
107
|
-
|
|
482
|
+
const valuePlain = truncateToWidth(item.currentValue, valueMaxWidth, Ellipsis.Omit);
|
|
483
|
+
const hovered = !isSelected && this.#theme.hovered !== undefined && item.id === this.#hoveredItemId;
|
|
484
|
+
// De-emphasized rows (outside the active section) render as plain text
|
|
485
|
+
// under one dim wash so inner label/value colors don't fight it.
|
|
486
|
+
if (dimmed && !isSelected) {
|
|
487
|
+
const text = this.#theme.hint(
|
|
488
|
+
truncateToWidth(` ${labelPadded}${separator}${valuePlain}`, Math.max(0, rowWidth)),
|
|
489
|
+
);
|
|
490
|
+
return hovered && this.#theme.hovered ? this.#theme.hovered(text) : text;
|
|
491
|
+
}
|
|
492
|
+
const labelText = this.#theme.label(labelPadded, isSelected, item.changed === true);
|
|
493
|
+
const valueText = this.#theme.value(valuePlain, isSelected, item.changed === true);
|
|
494
|
+
const text = truncateToWidth(prefix + labelText + separator + valueText, Math.max(0, rowWidth));
|
|
495
|
+
// Pointer hover paints a band behind the whole row, distinct from the
|
|
496
|
+
// keyboard selection (cursor glyph + accent) which stays where it is.
|
|
497
|
+
if (hovered && this.#theme.hovered) {
|
|
498
|
+
return this.#theme.hovered(text);
|
|
499
|
+
}
|
|
500
|
+
return text;
|
|
108
501
|
}
|
|
109
502
|
|
|
110
503
|
#renderMainList(width: number): string[] {
|
|
111
504
|
const lines: string[] = [];
|
|
112
505
|
|
|
113
506
|
if (this.#items.length === 0) {
|
|
114
|
-
lines.push(this.#theme.hint("
|
|
507
|
+
lines.push(this.#theme.hint(` ${this.#options.emptyText ?? "No settings available"}`));
|
|
115
508
|
return lines;
|
|
116
509
|
}
|
|
117
510
|
|
|
118
|
-
|
|
119
|
-
|
|
511
|
+
if (this.#filteredItems.length === 0) {
|
|
512
|
+
if (this.#shouldRenderSearchStatus()) {
|
|
513
|
+
lines.push(this.#renderSearchStatus(width));
|
|
514
|
+
}
|
|
515
|
+
lines.push(this.#theme.hint(" No matching settings"));
|
|
516
|
+
lines.push("");
|
|
517
|
+
lines.push(truncateToWidth(this.#theme.hint(" Backspace to edit search · Esc to cancel"), width));
|
|
518
|
+
return lines;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const sections = this.#sections();
|
|
522
|
+
const splitLines =
|
|
523
|
+
this.#options.layout !== "flat" && !this.#filterQuery.trim() && sections.length >= 2
|
|
524
|
+
? this.#renderSplitList(width, sections)
|
|
525
|
+
: null;
|
|
526
|
+
if (splitLines) {
|
|
527
|
+
lines.push(...splitLines);
|
|
528
|
+
} else {
|
|
529
|
+
const viewportHeight = Math.min(this.#maxVisible, this.#filteredItems.length);
|
|
530
|
+
const startIndex = Math.max(
|
|
531
|
+
0,
|
|
532
|
+
Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#filteredItems.length - viewportHeight),
|
|
533
|
+
);
|
|
534
|
+
const labelWidths = this.#filteredItems.filter(item => !item.heading).map(item => visibleWidth(item.label));
|
|
535
|
+
const maxLabelWidth = Math.min(30, labelWidths.length > 0 ? Math.max(...labelWidths) : 0);
|
|
536
|
+
const itemRowsOverflow = this.#filteredItems.length > viewportHeight;
|
|
537
|
+
const itemRowWidth = Math.max(0, width - (itemRowsOverflow ? 1 : 0));
|
|
538
|
+
const visibleItems = this.#filteredItems.slice(startIndex, startIndex + viewportHeight);
|
|
539
|
+
// In the flat layout the active section's heading row carries the
|
|
540
|
+
// section-focus cursor (the split layout shows it in the sidebar).
|
|
541
|
+
const active = sections[this.#activeSectionIndex(sections)];
|
|
542
|
+
const focusedHeadingIndex = this.#sectionFocus && active?.name ? active.firstItemIndex - 1 : -1;
|
|
543
|
+
const itemRows = visibleItems.map((item, index) =>
|
|
544
|
+
this.#renderItemRow(
|
|
545
|
+
item,
|
|
546
|
+
startIndex + index,
|
|
547
|
+
maxLabelWidth,
|
|
548
|
+
itemRowWidth,
|
|
549
|
+
false,
|
|
550
|
+
startIndex + index === focusedHeadingIndex,
|
|
551
|
+
),
|
|
552
|
+
);
|
|
553
|
+
visibleItems.forEach((item, index) => {
|
|
554
|
+
this.#hitRows[index] = item.heading ? undefined : item.id;
|
|
555
|
+
});
|
|
556
|
+
const scrollView = new ScrollView(itemRows, {
|
|
557
|
+
height: viewportHeight,
|
|
558
|
+
scrollbar: "auto",
|
|
559
|
+
totalRows: this.#filteredItems.length,
|
|
560
|
+
theme: {
|
|
561
|
+
track: text => this.#theme.hint(text),
|
|
562
|
+
thumb: text => this.#theme.label(text, true, false),
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
scrollView.setScrollOffset(startIndex);
|
|
566
|
+
lines.push(...scrollView.render(width));
|
|
567
|
+
// Pad short lists to the full viewport so the panel height is constant.
|
|
568
|
+
while (lines.length < this.#maxVisible) lines.push("");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Description area: 1 blank + exactly 3 rows, clamped with an ellipsis,
|
|
572
|
+
// so moving between items with/without descriptions never shifts rows.
|
|
573
|
+
lines.push("");
|
|
574
|
+
const selectedItem = this.#filteredItems[this.#selectedIndex];
|
|
575
|
+
const descLines: string[] = [];
|
|
576
|
+
if (selectedItem?.description && !selectedItem.heading) {
|
|
577
|
+
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
|
|
578
|
+
for (const line of wrappedDesc.slice(0, 3)) {
|
|
579
|
+
descLines.push(this.#theme.description(` ${line}`));
|
|
580
|
+
}
|
|
581
|
+
if (wrappedDesc.length > 3) {
|
|
582
|
+
descLines[2] = truncateToWidth(`${descLines[2]}…`, width);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
while (descLines.length < 3) descLines.push("");
|
|
586
|
+
lines.push(...descLines);
|
|
587
|
+
|
|
588
|
+
// External-search mode: the host renders the query; skip the status row.
|
|
589
|
+
if (this.#options.typeToSearch !== false) {
|
|
590
|
+
lines.push(this.#renderSearchStatus(width));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Add hint (suppressed entirely when the host owns the footer)
|
|
594
|
+
if (this.#options.hint !== "") {
|
|
595
|
+
lines.push("");
|
|
596
|
+
const jumpHint = sections.length >= 2 ? "PgUp/PgDn to jump sections · " : "";
|
|
597
|
+
const hintText = this.#options.hint ?? `Enter/Space to change · ${jumpHint}Type to search · Esc to cancel`;
|
|
598
|
+
lines.push(truncateToWidth(this.#theme.hint(` ${hintText}`), width));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return lines;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Split layout: section sidebar on the left, every item on the right with
|
|
606
|
+
* rows outside the active section dimmed so the section under the cursor
|
|
607
|
+
* pops. Up/Down navigation flows across section boundaries; the sidebar
|
|
608
|
+
* highlight follows the selection. Returns null when the width cannot fit
|
|
609
|
+
* both panes, falling back to the flat single-column layout.
|
|
610
|
+
*/
|
|
611
|
+
#renderSplitList(width: number, sections: SettingSection[]): string[] | null {
|
|
612
|
+
const sectionNames = sections.map(section => section.name || "Other");
|
|
613
|
+
let nameWidth = 0;
|
|
614
|
+
for (const name of sectionNames) nameWidth = Math.max(nameWidth, visibleWidth(name));
|
|
615
|
+
const sidebarWidth = this.#options.sidebarWidth ?? Math.min(22, nameWidth) + 4; // 2-space indent + 2-space gap
|
|
616
|
+
const paneWidth = width - sidebarWidth - 2; // "│ " separator
|
|
617
|
+
// Below this the value column starves (2 prefix + 30 label + 2 gap + ~25 value).
|
|
618
|
+
if (paneWidth < 60) return null;
|
|
619
|
+
|
|
620
|
+
const activeIndex = this.#activeSectionIndex(sections);
|
|
621
|
+
const active = sections[activeIndex];
|
|
622
|
+
|
|
623
|
+
const sectionStyle =
|
|
624
|
+
this.#theme.section ??
|
|
625
|
+
((text: string, isActive: boolean) =>
|
|
626
|
+
isActive ? this.#theme.label(text, true, false) : this.#theme.hint(text));
|
|
627
|
+
const sidebarRows = sectionNames.map((name, i) => {
|
|
628
|
+
const label = truncateToWidth(name, sidebarWidth - 4, Ellipsis.Omit);
|
|
629
|
+
// Section focus parks the cursor glyph on the active sidebar entry.
|
|
630
|
+
const prefix = this.#sectionFocus && i === activeIndex ? this.#theme.cursor : " ";
|
|
631
|
+
return `${prefix}${sectionStyle(label, i === activeIndex)}${padding(sidebarWidth - visibleWidth(prefix) - visibleWidth(label))}`;
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// Right pane: the whole list, continuously scrollable. The active
|
|
635
|
+
// section's heading row belongs to its dim-exempt range.
|
|
636
|
+
const activeStart = active.name ? active.firstItemIndex - 1 : active.firstItemIndex;
|
|
637
|
+
const viewportHeight = Math.min(this.#maxVisible, this.#filteredItems.length);
|
|
638
|
+
const startRow = Math.max(
|
|
120
639
|
0,
|
|
121
|
-
Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#
|
|
122
|
-
);
|
|
123
|
-
const maxLabelWidth = Math.min(30, Math.max(...this.#items.map(item => visibleWidth(item.label))));
|
|
124
|
-
const itemRowsOverflow = this.#items.length > viewportHeight;
|
|
125
|
-
const itemRowWidth = Math.max(0, width - (itemRowsOverflow ? 1 : 0));
|
|
126
|
-
const visibleItems = this.#items.slice(startIndex, startIndex + viewportHeight);
|
|
127
|
-
const itemRows = visibleItems.map((item, index) =>
|
|
128
|
-
this.#renderItemRow(item, startIndex + index, maxLabelWidth, itemRowWidth),
|
|
640
|
+
Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#filteredItems.length - viewportHeight),
|
|
129
641
|
);
|
|
642
|
+
// Label column width spans all items so the layout stays stable across sections.
|
|
643
|
+
const labelWidths = this.#filteredItems.filter(item => !item.heading).map(item => visibleWidth(item.label));
|
|
644
|
+
const maxLabelWidth = Math.min(30, labelWidths.length > 0 ? Math.max(...labelWidths) : 0);
|
|
645
|
+
const overflow = this.#filteredItems.length > viewportHeight;
|
|
646
|
+
const rowWidth = Math.max(0, paneWidth - (overflow ? 1 : 0));
|
|
647
|
+
const itemRows: string[] = [];
|
|
648
|
+
for (let r = 0; r < viewportHeight; r++) {
|
|
649
|
+
const index = startRow + r;
|
|
650
|
+
const item = this.#filteredItems[index];
|
|
651
|
+
if (!item) break;
|
|
652
|
+
const dimmed = index < activeStart || index > active.lastItemIndex;
|
|
653
|
+
itemRows.push(this.#renderItemRow(item, index, maxLabelWidth, rowWidth, dimmed));
|
|
654
|
+
}
|
|
130
655
|
const scrollView = new ScrollView(itemRows, {
|
|
131
656
|
height: viewportHeight,
|
|
132
657
|
scrollbar: "auto",
|
|
133
|
-
totalRows: this.#
|
|
658
|
+
totalRows: this.#filteredItems.length,
|
|
134
659
|
theme: {
|
|
135
660
|
track: text => this.#theme.hint(text),
|
|
136
661
|
thumb: text => this.#theme.label(text, true, false),
|
|
137
662
|
},
|
|
138
663
|
});
|
|
139
|
-
scrollView.setScrollOffset(
|
|
140
|
-
|
|
664
|
+
scrollView.setScrollOffset(startRow);
|
|
665
|
+
const paneRows = scrollView.render(paneWidth);
|
|
141
666
|
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
667
|
+
// Hit maps: sidebar rows resolve to each section's first item; pane rows
|
|
668
|
+
// to the item they render.
|
|
669
|
+
this.#sidebarHitCol = sidebarWidth;
|
|
670
|
+
for (let i = 0; i < sectionNames.length; i++) {
|
|
671
|
+
this.#sidebarHitRows[i] = this.#filteredItems[sections[i].firstItemIndex]?.id;
|
|
672
|
+
}
|
|
673
|
+
for (let r = 0; r < viewportHeight; r++) {
|
|
674
|
+
const item = this.#filteredItems[startRow + r];
|
|
675
|
+
if (item && !item.heading) this.#hitRows[r] = item.id;
|
|
150
676
|
}
|
|
151
677
|
|
|
152
|
-
|
|
153
|
-
lines
|
|
154
|
-
|
|
155
|
-
|
|
678
|
+
const separator = this.#theme.hint("│ ");
|
|
679
|
+
const lines: string[] = [];
|
|
680
|
+
const height = Math.max(this.#maxVisible, sidebarRows.length);
|
|
681
|
+
for (let i = 0; i < height; i++) {
|
|
682
|
+
const left = sidebarRows[i] ?? padding(sidebarWidth);
|
|
683
|
+
lines.push(truncateToWidth(left + separator + (paneRows[i] ?? ""), width));
|
|
684
|
+
}
|
|
156
685
|
return lines;
|
|
157
686
|
}
|
|
158
687
|
|
|
@@ -166,24 +695,49 @@ export class SettingsList implements Component {
|
|
|
166
695
|
|
|
167
696
|
// Main list input handling
|
|
168
697
|
const kb = getKeybindings();
|
|
698
|
+
if (kb.matches(data, "tui.select.cancel")) {
|
|
699
|
+
if (this.#filterQuery.length > 0) {
|
|
700
|
+
this.clearSearch();
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (this.#sectionFocus) {
|
|
704
|
+
this.#sectionFocus = false;
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
this.#onCancel();
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (this.#handleSearchInput(data)) {
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (this.#filteredItems.length === 0) return;
|
|
716
|
+
|
|
169
717
|
if (kb.matches(data, "tui.select.up")) {
|
|
170
|
-
|
|
718
|
+
if (this.#sectionFocus) this.#jumpSection(-1);
|
|
719
|
+
else this.#moveSelection(-1);
|
|
171
720
|
} else if (kb.matches(data, "tui.select.down")) {
|
|
172
|
-
|
|
721
|
+
if (this.#sectionFocus) this.#jumpSection(1);
|
|
722
|
+
else this.#moveSelection(1);
|
|
723
|
+
} else if (kb.matches(data, "tui.select.pageDown")) {
|
|
724
|
+
this.#jumpSection(1);
|
|
725
|
+
} else if (kb.matches(data, "tui.select.pageUp")) {
|
|
726
|
+
this.#jumpSection(-1);
|
|
173
727
|
} else if (kb.matches(data, "tui.select.confirm") || data === " " || data === "\n") {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
this.#
|
|
728
|
+
// Confirm on a focused heading drops into its first setting.
|
|
729
|
+
if (this.#sectionFocus) this.#sectionFocus = false;
|
|
730
|
+
else this.#activateItem();
|
|
177
731
|
}
|
|
178
732
|
}
|
|
179
733
|
|
|
180
734
|
#activateItem(): void {
|
|
181
|
-
const item = this.#
|
|
182
|
-
if (!item) return;
|
|
735
|
+
const item = this.#filteredItems[this.#selectedIndex];
|
|
736
|
+
if (!item || item.heading) return;
|
|
183
737
|
|
|
184
738
|
if (item.submenu) {
|
|
185
739
|
// Open submenu, passing current value so it can pre-select correctly
|
|
186
|
-
this.#
|
|
740
|
+
this.#submenuItemId = item.id;
|
|
187
741
|
this.#submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => {
|
|
188
742
|
if (selectedValue !== undefined) {
|
|
189
743
|
item.currentValue = selectedValue;
|
|
@@ -203,10 +757,18 @@ export class SettingsList implements Component {
|
|
|
203
757
|
|
|
204
758
|
#closeSubmenu(): void {
|
|
205
759
|
this.#submenuComponent = null;
|
|
206
|
-
// Restore selection to the item that opened the submenu
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
760
|
+
// Restore selection to the item that opened the submenu. Resolve by id:
|
|
761
|
+
// onChange handlers may have called setItems while the submenu was open,
|
|
762
|
+
// so a captured index could point at a different (or vanished) row.
|
|
763
|
+
if (this.#submenuItemId !== null) {
|
|
764
|
+
const index = this.#filteredItems.findIndex(item => !item.heading && item.id === this.#submenuItemId);
|
|
765
|
+
this.#submenuItemId = null;
|
|
766
|
+
if (index >= 0) {
|
|
767
|
+
this.#selectedIndex = index;
|
|
768
|
+
} else {
|
|
769
|
+
this.#clampSelectedIndex();
|
|
770
|
+
}
|
|
771
|
+
this.#notifySelection();
|
|
210
772
|
}
|
|
211
773
|
}
|
|
212
774
|
}
|