@tuidom/elements 0.2.0 → 0.2.3
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.
|
@@ -18,11 +18,33 @@ export declare class InputElement extends TUIElement {
|
|
|
18
18
|
readonly inputState: InputState;
|
|
19
19
|
placeholder: string | undefined;
|
|
20
20
|
showBorder: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Маска поля пароля: каждый графемный кластер значения рисуется этой
|
|
23
|
+
* строкой, сам текст на экран не попадает и не отдаётся инспектору.
|
|
24
|
+
* `undefined` — обычное поле. Модель (`inputState`) хранит настоящий текст:
|
|
25
|
+
* маска — это только представление, редактирование и `onChange` работают
|
|
26
|
+
* по-прежнему с реальным значением.
|
|
27
|
+
*/
|
|
28
|
+
maskChar: string | undefined;
|
|
21
29
|
onChange: ((value: string) => void) | undefined;
|
|
22
30
|
private scrollX;
|
|
23
31
|
constructor(inputState?: InputState);
|
|
24
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Наблюдаемость для инспектора/e2e: текст, курсор, выделение, плейсхолдер.
|
|
34
|
+
* Под маской (`maskChar`) отдаётся ровно то, что на экране, — секрет наружу
|
|
35
|
+
* не уходит; `cursorOffset` при этом тоже смещение в маске.
|
|
36
|
+
*/
|
|
25
37
|
inspectState(): Record<string, unknown>;
|
|
38
|
+
/**
|
|
39
|
+
* Что рисуем и где в нарисованном стоят курсор и выделение.
|
|
40
|
+
*
|
|
41
|
+
* Без маски — сам текст и его же смещения. С маской — по одному
|
|
42
|
+
* {@link maskChar} на графемный кластер, а смещения пересчитаны в этот новый
|
|
43
|
+
* текст: курсор за N-й графемой стоит за N-й маской. Смещения считаем через
|
|
44
|
+
* длину самой маски, а не «одна графема = один символ», — маской может быть
|
|
45
|
+
* и многокодовый глиф.
|
|
46
|
+
*/
|
|
47
|
+
private displayView;
|
|
26
48
|
getMinIntrinsicWidth(_height: number): number;
|
|
27
49
|
getMinIntrinsicHeight(_width: number): number;
|
|
28
50
|
getMaxIntrinsicHeight(_width: number): number;
|
|
@@ -19,6 +19,14 @@ export class InputElement extends TUIElement {
|
|
|
19
19
|
inputState;
|
|
20
20
|
placeholder = undefined;
|
|
21
21
|
showBorder = false;
|
|
22
|
+
/**
|
|
23
|
+
* Маска поля пароля: каждый графемный кластер значения рисуется этой
|
|
24
|
+
* строкой, сам текст на экран не попадает и не отдаётся инспектору.
|
|
25
|
+
* `undefined` — обычное поле. Модель (`inputState`) хранит настоящий текст:
|
|
26
|
+
* маска — это только представление, редактирование и `onChange` работают
|
|
27
|
+
* по-прежнему с реальным значением.
|
|
28
|
+
*/
|
|
29
|
+
maskChar = undefined;
|
|
22
30
|
onChange = undefined;
|
|
23
31
|
scrollX = 0;
|
|
24
32
|
constructor(inputState) {
|
|
@@ -29,15 +37,49 @@ export class InputElement extends TUIElement {
|
|
|
29
37
|
// ядро ставит focus-состояние и перерезолвит стиль (render дёргается).
|
|
30
38
|
this.style = { fg: "input.foreground", bg: "input.background" };
|
|
31
39
|
}
|
|
32
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Наблюдаемость для инспектора/e2e: текст, курсор, выделение, плейсхолдер.
|
|
42
|
+
* Под маской (`maskChar`) отдаётся ровно то, что на экране, — секрет наружу
|
|
43
|
+
* не уходит; `cursorOffset` при этом тоже смещение в маске.
|
|
44
|
+
*/
|
|
33
45
|
inspectState() {
|
|
46
|
+
const view = this.displayView();
|
|
34
47
|
return {
|
|
35
|
-
value:
|
|
36
|
-
cursorOffset:
|
|
48
|
+
value: view.text,
|
|
49
|
+
cursorOffset: view.cursorOffset,
|
|
37
50
|
hasSelection: this.inputState.hasSelection,
|
|
38
51
|
showsPlaceholder: this.inputState.value.length === 0 && this.placeholder !== undefined,
|
|
39
52
|
};
|
|
40
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Что рисуем и где в нарисованном стоят курсор и выделение.
|
|
56
|
+
*
|
|
57
|
+
* Без маски — сам текст и его же смещения. С маской — по одному
|
|
58
|
+
* {@link maskChar} на графемный кластер, а смещения пересчитаны в этот новый
|
|
59
|
+
* текст: курсор за N-й графемой стоит за N-й маской. Смещения считаем через
|
|
60
|
+
* длину самой маски, а не «одна графема = один символ», — маской может быть
|
|
61
|
+
* и многокодовый глиф.
|
|
62
|
+
*/
|
|
63
|
+
displayView() {
|
|
64
|
+
const state = this.inputState;
|
|
65
|
+
const mask = this.maskChar;
|
|
66
|
+
if (mask === undefined) {
|
|
67
|
+
return {
|
|
68
|
+
text: state.text,
|
|
69
|
+
cursorOffset: state.cursorOffset,
|
|
70
|
+
selectionStart: state.selectionStart,
|
|
71
|
+
selectionEnd: state.selectionEnd,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const slots = new DisplayLine(state.text).slots;
|
|
75
|
+
const toMask = (offset) => slots.filter((slot) => slot.offset < offset).length * mask.length;
|
|
76
|
+
return {
|
|
77
|
+
text: mask.repeat(slots.length),
|
|
78
|
+
cursorOffset: toMask(state.cursorOffset),
|
|
79
|
+
selectionStart: toMask(state.selectionStart),
|
|
80
|
+
selectionEnd: toMask(state.selectionEnd),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
41
83
|
// ─── Layout ─────────────────────────────────────────────────────────────
|
|
42
84
|
getMinIntrinsicWidth(_height) {
|
|
43
85
|
return this.showBorder ? 5 : 3;
|
|
@@ -57,8 +99,11 @@ export class InputElement extends TUIElement {
|
|
|
57
99
|
render(context) {
|
|
58
100
|
const w = this.layoutSize.width;
|
|
59
101
|
const h = this.layoutSize.height;
|
|
60
|
-
|
|
61
|
-
|
|
102
|
+
// Рисуем ПРЕДСТАВЛЕНИЕ значения: без маски это сам текст, под маской —
|
|
103
|
+
// её символы со смещениями, пересчитанными в них.
|
|
104
|
+
const view = this.displayView();
|
|
105
|
+
const text = view.text;
|
|
106
|
+
const cursorOffset = view.cursorOffset;
|
|
62
107
|
const focused = this.isFocused;
|
|
63
108
|
const contentXStart = this.showBorder ? 1 : 0;
|
|
64
109
|
const contentY = this.showBorder ? 1 : 0;
|
|
@@ -94,7 +139,7 @@ export class InputElement extends TUIElement {
|
|
|
94
139
|
});
|
|
95
140
|
}
|
|
96
141
|
else if (this.inputState.hasSelection) {
|
|
97
|
-
this.renderTextWithSelection(textContext, dl, text, contentXStart, contentY);
|
|
142
|
+
this.renderTextWithSelection(textContext, dl, text, view, contentXStart, contentY);
|
|
98
143
|
}
|
|
99
144
|
else {
|
|
100
145
|
textContext.drawText(contentXStart - this.scrollX, contentY, text, { fg, bg });
|
|
@@ -109,9 +154,11 @@ export class InputElement extends TUIElement {
|
|
|
109
154
|
/* v8 ignore stop */
|
|
110
155
|
}
|
|
111
156
|
}
|
|
112
|
-
renderTextWithSelection(context, dl, text, contentXStart, contentY) {
|
|
113
|
-
|
|
114
|
-
|
|
157
|
+
renderTextWithSelection(context, dl, text, view, contentXStart, contentY) {
|
|
158
|
+
// Границы выделения берём из представления: под маской они смещения в
|
|
159
|
+
// ней, а `text`/`dl` — тоже она.
|
|
160
|
+
const selStart = view.selectionStart;
|
|
161
|
+
const selEnd = view.selectionEnd;
|
|
115
162
|
const before = text.slice(0, selStart);
|
|
116
163
|
const selected = text.slice(selStart, selEnd);
|
|
117
164
|
const after = text.slice(selEnd);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuidom/elements",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Experimental terminal UI DOM — widgets: lists, trees, menus, inputs, quick pick. API is unstable.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,6 +28,6 @@
|
|
|
28
28
|
"./*": "./dist/*.js"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@tuidom/core": "0.2.
|
|
31
|
+
"@tuidom/core": "0.2.3"
|
|
32
32
|
}
|
|
33
33
|
}
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import { BoxConstraints, Size } from "@tuidom/core/common/geometryPromitives";
|
|
2
|
-
import type { TUIEventBase } from "@tuidom/core/dom/events/tuiEventBase";
|
|
3
|
-
import { RenderContext, TUIElement } from "@tuidom/core/dom/tuiElement";
|
|
4
|
-
import { InputElement } from "../inputbox/inputElement.js";
|
|
5
|
-
export interface QuickPickItem {
|
|
6
|
-
/** Nerd font icon character (e.g. a file icon like "\uf15b"). */
|
|
7
|
-
icon?: string;
|
|
8
|
-
/** Main display text. */
|
|
9
|
-
label: string;
|
|
10
|
-
/** Right-side text: file path, category, etc. */
|
|
11
|
-
description?: string;
|
|
12
|
-
/** Keyboard shortcut shown right-aligned, e.g. "Ctrl+Shift+P". */
|
|
13
|
-
shortcut?: string;
|
|
14
|
-
/** Action hint shown after description, e.g. "Configure Binding". */
|
|
15
|
-
hint?: string;
|
|
16
|
-
/** Marker badge, e.g. "recently used". */
|
|
17
|
-
badge?: string;
|
|
18
|
-
/** Byte-offset ranges in `label` to highlight as fuzzy-match hits. */
|
|
19
|
-
labelMatchRanges?: readonly [number, number][];
|
|
20
|
-
/** Byte-offset ranges in `description` to highlight as fuzzy-match hits. */
|
|
21
|
-
descriptionMatchRanges?: readonly [number, number][];
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Quick-open / command-palette style picker.
|
|
25
|
-
*
|
|
26
|
-
* Renders a bordered box with:
|
|
27
|
-
* - top row: text input (query)
|
|
28
|
-
* - optional separator + scrollable item list below
|
|
29
|
-
*
|
|
30
|
-
* The widget does NOT perform any filtering itself — pass pre-filtered
|
|
31
|
-
* `items` with `labelMatchRanges`/`descriptionMatchRanges` populated.
|
|
32
|
-
*
|
|
33
|
-
* Keyboard:
|
|
34
|
-
* ArrowDown/Up — move selection (no wrap)
|
|
35
|
-
* Enter — fire `onAccept(item, index)`
|
|
36
|
-
* Escape — fire `onCancel()`
|
|
37
|
-
* All other keys — forwarded to the InputElement (typing)
|
|
38
|
-
*
|
|
39
|
-
* Usage:
|
|
40
|
-
* const picker = new QuickPickElement();
|
|
41
|
-
* picker.placeholder = "Go to file…";
|
|
42
|
-
* picker.onQueryChange = query => { picker.items = search(query); };
|
|
43
|
-
* picker.onAccept = (item) => openFile(item.description!);
|
|
44
|
-
*/
|
|
45
|
-
export type QuickPickAcceptMode = "item" | "value";
|
|
46
|
-
export type ValidationSeverity = "error" | "warning" | "info";
|
|
47
|
-
export declare class QuickPickElement extends TUIElement {
|
|
48
|
-
placeholder: string;
|
|
49
|
-
/** Maximum rows of the list to show at once (scrolls when exceeded). */
|
|
50
|
-
maxVisibleItems: number;
|
|
51
|
-
/** Optional title drawn centered into the top border (e.g. "Save As"). */
|
|
52
|
-
title: string | undefined;
|
|
53
|
-
/**
|
|
54
|
-
* Optional subtitle drawn on a dedicated row under the input (dim).
|
|
55
|
-
* Used by the InputBox flavor. Overridden by `validationMessage` when set.
|
|
56
|
-
*/
|
|
57
|
-
prompt: string | undefined;
|
|
58
|
-
/** Validation/error text shown under the input; blocks Enter when severity is "error". */
|
|
59
|
-
validationMessage: string | null;
|
|
60
|
-
validationSeverity: ValidationSeverity;
|
|
61
|
-
/**
|
|
62
|
-
* How Enter is interpreted:
|
|
63
|
-
* "item" — fire onAccept(item, index), only when the list is non-empty (default; QuickOpen).
|
|
64
|
-
* "value" — always fire onAcceptValue(getQuery()) — the InputBox flavor.
|
|
65
|
-
*/
|
|
66
|
-
acceptMode: QuickPickAcceptMode;
|
|
67
|
-
/** Fired by Enter when acceptMode === "value". */
|
|
68
|
-
onAcceptValue: ((value: string) => void) | null;
|
|
69
|
-
/**
|
|
70
|
-
* Desired width of the picker in columns.
|
|
71
|
-
* When the element is laid out with loose constraints it will use this
|
|
72
|
-
* value (clamped to [minWidth, maxWidth]).
|
|
73
|
-
*/
|
|
74
|
-
preferredWidth: number;
|
|
75
|
-
onQueryChange: ((query: string) => void) | null;
|
|
76
|
-
onAccept: ((item: QuickPickItem, index: number) => void) | null;
|
|
77
|
-
onCancel: (() => void) | null;
|
|
78
|
-
/**
|
|
79
|
-
* Fired when the highlighted (active) item changes via keyboard navigation —
|
|
80
|
-
* used for live preview (e.g. the color-theme picker applies the theme as you
|
|
81
|
-
* arrow through the list). NOT fired by `items =` / `refreshItems` /
|
|
82
|
-
* `setActiveIndex` (those are programmatic repositioning, not user intent).
|
|
83
|
-
*/
|
|
84
|
-
onActiveItemChanged: ((item: QuickPickItem, index: number) => void) | null;
|
|
85
|
-
readonly inputElement: InputElement;
|
|
86
|
-
private itemsValue;
|
|
87
|
-
private selectedIndexValue;
|
|
88
|
-
private scrollOffset;
|
|
89
|
-
constructor();
|
|
90
|
-
protected performDefaultAction(event: TUIEventBase): void;
|
|
91
|
-
/** Follow the mouse: hovering a list row moves the selection onto it (VS Code behavior). */
|
|
92
|
-
private handleMouseMove;
|
|
93
|
-
/** Clicking a list row selects it and accepts it, mirroring Enter. */
|
|
94
|
-
private handleClick;
|
|
95
|
-
/**
|
|
96
|
-
* Maps a y offset local to this element onto an item index, or null when it
|
|
97
|
-
* falls outside the visible list rows (border, input, message, separator).
|
|
98
|
-
*/
|
|
99
|
-
private itemIndexFromLocalY;
|
|
100
|
-
get items(): readonly QuickPickItem[];
|
|
101
|
-
/** Observable state: current query, item labels and the active index. */
|
|
102
|
-
inspectState(): Record<string, unknown>;
|
|
103
|
-
set items(value: readonly QuickPickItem[]);
|
|
104
|
-
/**
|
|
105
|
-
* Replace the items WITHOUT jumping the cursor back to the top.
|
|
106
|
-
*
|
|
107
|
-
* Use this when the list is refreshed for the *same* query (e.g. the file
|
|
108
|
-
* index grew in the background and produced more results) — resetting the
|
|
109
|
-
* selection there would yank the cursor away from the user mid-navigation.
|
|
110
|
-
*
|
|
111
|
-
* The previously selected item is re-located by identity (label +
|
|
112
|
-
* description, which uniquely identifies a file row). If it is gone, the
|
|
113
|
-
* previous index is clamped into the new bounds. The selected row is kept
|
|
114
|
-
* on-screen.
|
|
115
|
-
*/
|
|
116
|
-
refreshItems(value: readonly QuickPickItem[]): void;
|
|
117
|
-
get selectedIndex(): number;
|
|
118
|
-
getQuery(): string;
|
|
119
|
-
setQuery(value: string): void;
|
|
120
|
-
/** Delegate focus to the inner InputElement. */
|
|
121
|
-
focus(): void;
|
|
122
|
-
private get visibleItemCount();
|
|
123
|
-
/**
|
|
124
|
-
* Сколько строк списка реально помещается в выделенную layout'ом высоту.
|
|
125
|
-
* Разрыв цикличности с {@link totalHeight}: natural-высота считается ДО
|
|
126
|
-
* layout (интринсики, visibleItemCount), фактическое окно — ПОСЛЕ, от
|
|
127
|
-
* layoutSize. На низком терминале (loose от overlay с маленьким остатком)
|
|
128
|
-
* окно меньше natural — рендер, хит-тест и скролл живут по нему.
|
|
129
|
-
*/
|
|
130
|
-
private get effectiveVisibleRows();
|
|
131
|
-
/**
|
|
132
|
-
* Строка сепаратора/нижней рамки сразу под «шапкой» (верхняя рамка + input +
|
|
133
|
-
* [message]) — она же счётчик занятых шапкой строк.
|
|
134
|
-
*/
|
|
135
|
-
private get bodyTop();
|
|
136
|
-
/**
|
|
137
|
-
* The message row shown under the input (InputBox flavor). A validation
|
|
138
|
-
* message wins over the plain prompt; returns null when neither is set.
|
|
139
|
-
*/
|
|
140
|
-
private get messageRow();
|
|
141
|
-
private get totalHeight();
|
|
142
|
-
getMinIntrinsicWidth(_height: number): number;
|
|
143
|
-
getMaxIntrinsicWidth(_height: number): number;
|
|
144
|
-
getMinIntrinsicHeight(_width: number): number;
|
|
145
|
-
getMaxIntrinsicHeight(_width: number): number;
|
|
146
|
-
protected performLayout(constraints: BoxConstraints): Size;
|
|
147
|
-
render(context: RenderContext): void;
|
|
148
|
-
/** Draws the title centered into the top border row as ┤ title ├. */
|
|
149
|
-
private renderTitle;
|
|
150
|
-
/** Draws the prompt / validation message row under the input. */
|
|
151
|
-
private renderMessageRow;
|
|
152
|
-
private renderItemRow;
|
|
153
|
-
private moveSelection;
|
|
154
|
-
/**
|
|
155
|
-
* Move the highlight to `index` programmatically (e.g. pre-select the current
|
|
156
|
-
* theme when the picker opens). Clamped into range; keeps the row on-screen.
|
|
157
|
-
* Does NOT fire {@link onActiveItemChanged} — this is not a user navigation.
|
|
158
|
-
*/
|
|
159
|
-
setActiveIndex(index: number): void;
|
|
160
|
-
private ensureVisible;
|
|
161
|
-
}
|
|
@@ -1,561 +0,0 @@
|
|
|
1
|
-
import { DisplayLine } from "@tuidom/core/common/displayLine";
|
|
2
|
-
import { BoxConstraints, Offset, Point, Rect, Size } from "@tuidom/core/common/geometryPromitives";
|
|
3
|
-
import { abbreviatePath, truncateEnd } from "@tuidom/core/common/textTruncation";
|
|
4
|
-
import { BORDER_THICKNESS } from "@tuidom/core/dom/borderStyle";
|
|
5
|
-
import { TUIKeyboardEvent } from "@tuidom/core/dom/events/tuiKeyboardEvent";
|
|
6
|
-
import { INHERITED_BG, INHERITED_FG } from "@tuidom/core/dom/styles/tuiStyle";
|
|
7
|
-
import { RenderContext, TUIElement } from "@tuidom/core/dom/tuiElement";
|
|
8
|
-
import { InputElement } from "../inputbox/inputElement.js";
|
|
9
|
-
// ─── Styles ─────────────────────────────────────────────────────────────────
|
|
10
|
-
/** Паддинг между контентом ряда и правой рамкой (не путать с самой рамкой). */
|
|
11
|
-
const RIGHT_PAD = 1;
|
|
12
|
-
export class QuickPickElement extends TUIElement {
|
|
13
|
-
placeholder = "Type to search…";
|
|
14
|
-
/** Maximum rows of the list to show at once (scrolls when exceeded). */
|
|
15
|
-
maxVisibleItems = 10;
|
|
16
|
-
/** Optional title drawn centered into the top border (e.g. "Save As"). */
|
|
17
|
-
title = undefined;
|
|
18
|
-
/**
|
|
19
|
-
* Optional subtitle drawn on a dedicated row under the input (dim).
|
|
20
|
-
* Used by the InputBox flavor. Overridden by `validationMessage` when set.
|
|
21
|
-
*/
|
|
22
|
-
prompt = undefined;
|
|
23
|
-
/** Validation/error text shown under the input; blocks Enter when severity is "error". */
|
|
24
|
-
validationMessage = null;
|
|
25
|
-
validationSeverity = "error";
|
|
26
|
-
/**
|
|
27
|
-
* How Enter is interpreted:
|
|
28
|
-
* "item" — fire onAccept(item, index), only when the list is non-empty (default; QuickOpen).
|
|
29
|
-
* "value" — always fire onAcceptValue(getQuery()) — the InputBox flavor.
|
|
30
|
-
*/
|
|
31
|
-
acceptMode = "item";
|
|
32
|
-
/** Fired by Enter when acceptMode === "value". */
|
|
33
|
-
onAcceptValue = null;
|
|
34
|
-
/**
|
|
35
|
-
* Desired width of the picker in columns.
|
|
36
|
-
* When the element is laid out with loose constraints it will use this
|
|
37
|
-
* value (clamped to [minWidth, maxWidth]).
|
|
38
|
-
*/
|
|
39
|
-
preferredWidth = 60;
|
|
40
|
-
onQueryChange = null;
|
|
41
|
-
onAccept = null;
|
|
42
|
-
onCancel = null;
|
|
43
|
-
/**
|
|
44
|
-
* Fired when the highlighted (active) item changes via keyboard navigation —
|
|
45
|
-
* used for live preview (e.g. the color-theme picker applies the theme as you
|
|
46
|
-
* arrow through the list). NOT fired by `items =` / `refreshItems` /
|
|
47
|
-
* `setActiveIndex` (those are programmatic repositioning, not user intent).
|
|
48
|
-
*/
|
|
49
|
-
onActiveItemChanged = null;
|
|
50
|
-
inputElement;
|
|
51
|
-
itemsValue = [];
|
|
52
|
-
selectedIndexValue = 0;
|
|
53
|
-
scrollOffset = 0;
|
|
54
|
-
constructor() {
|
|
55
|
-
super();
|
|
56
|
-
this.focusable = true;
|
|
57
|
-
this.style = { fg: "quickInput.foreground", bg: "quickInput.background" };
|
|
58
|
-
this.inputElement = new InputElement();
|
|
59
|
-
this.inputElement.showBorder = false;
|
|
60
|
-
// Строка запроса — часть пикера: наследует его цвета, а не input.*.
|
|
61
|
-
this.inputElement.style = { fg: INHERITED_FG, bg: INHERITED_BG };
|
|
62
|
-
this.appendChild(this.inputElement);
|
|
63
|
-
this.inputElement.onChange = (value) => {
|
|
64
|
-
this.onQueryChange?.(value);
|
|
65
|
-
};
|
|
66
|
-
// Handle navigation keys that bubble up from the focused InputElement.
|
|
67
|
-
// These keys are not consumed by InputElement.performDefaultAction, so they
|
|
68
|
-
// bubble here. We preventDefault() to suppress further handling.
|
|
69
|
-
this.addEventListener("keydown", (event) => {
|
|
70
|
-
const keyEvent = event;
|
|
71
|
-
switch (keyEvent.key) {
|
|
72
|
-
case "ArrowDown":
|
|
73
|
-
event.preventDefault();
|
|
74
|
-
this.moveSelection(1);
|
|
75
|
-
break;
|
|
76
|
-
case "ArrowUp":
|
|
77
|
-
event.preventDefault();
|
|
78
|
-
this.moveSelection(-1);
|
|
79
|
-
break;
|
|
80
|
-
case "PageDown":
|
|
81
|
-
event.preventDefault();
|
|
82
|
-
this.moveSelection(Math.max(1, this.effectiveVisibleRows));
|
|
83
|
-
break;
|
|
84
|
-
case "PageUp":
|
|
85
|
-
event.preventDefault();
|
|
86
|
-
this.moveSelection(-Math.max(1, this.effectiveVisibleRows));
|
|
87
|
-
break;
|
|
88
|
-
case "Enter":
|
|
89
|
-
event.preventDefault();
|
|
90
|
-
// A hard validation error blocks accept in every mode.
|
|
91
|
-
if (this.validationMessage !== null && this.validationSeverity === "error") {
|
|
92
|
-
break;
|
|
93
|
-
}
|
|
94
|
-
if (this.acceptMode === "value") {
|
|
95
|
-
this.onAcceptValue?.(this.getQuery());
|
|
96
|
-
}
|
|
97
|
-
else if (this.itemsValue.length > 0) {
|
|
98
|
-
this.onAccept?.(this.itemsValue[this.selectedIndexValue], this.selectedIndexValue);
|
|
99
|
-
}
|
|
100
|
-
break;
|
|
101
|
-
case "Escape":
|
|
102
|
-
event.preventDefault();
|
|
103
|
-
this.onCancel?.();
|
|
104
|
-
break;
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
// ─── Mouse ──────────────────────────────────────────────────────────────
|
|
109
|
-
performDefaultAction(event) {
|
|
110
|
-
if (event.type === "mousemove") {
|
|
111
|
-
this.handleMouseMove(event);
|
|
112
|
-
}
|
|
113
|
-
else if (event.type === "click") {
|
|
114
|
-
this.handleClick(event);
|
|
115
|
-
}
|
|
116
|
-
else {
|
|
117
|
-
super.performDefaultAction(event);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
/** Follow the mouse: hovering a list row moves the selection onto it (VS Code behavior). */
|
|
121
|
-
handleMouseMove(event) {
|
|
122
|
-
const index = this.itemIndexFromLocalY(event.localY);
|
|
123
|
-
if (index === null || index === this.selectedIndexValue)
|
|
124
|
-
return;
|
|
125
|
-
this.selectedIndexValue = index;
|
|
126
|
-
this.markDirty();
|
|
127
|
-
}
|
|
128
|
-
/** Clicking a list row selects it and accepts it, mirroring Enter. */
|
|
129
|
-
handleClick(event) {
|
|
130
|
-
if (event.button !== "left")
|
|
131
|
-
return;
|
|
132
|
-
const index = this.itemIndexFromLocalY(event.localY);
|
|
133
|
-
if (index === null)
|
|
134
|
-
return;
|
|
135
|
-
this.selectedIndexValue = index;
|
|
136
|
-
this.markDirty();
|
|
137
|
-
if (this.validationMessage !== null && this.validationSeverity === "error")
|
|
138
|
-
return;
|
|
139
|
-
this.onAccept?.(this.itemsValue[index], index);
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Maps a y offset local to this element onto an item index, or null when it
|
|
143
|
-
* falls outside the visible list rows (border, input, message, separator).
|
|
144
|
-
*/
|
|
145
|
-
itemIndexFromLocalY(localY) {
|
|
146
|
-
if (this.effectiveVisibleRows === 0)
|
|
147
|
-
return null;
|
|
148
|
-
const firstRowY = this.bodyTop + 1; // border/input/[message]/separator then rows
|
|
149
|
-
const row = localY - firstRowY;
|
|
150
|
-
if (row < 0 || row >= this.effectiveVisibleRows)
|
|
151
|
-
return null;
|
|
152
|
-
return this.scrollOffset + row;
|
|
153
|
-
}
|
|
154
|
-
// ─── Public API ─────────────────────────────────────────────────────────
|
|
155
|
-
get items() {
|
|
156
|
-
return this.itemsValue;
|
|
157
|
-
}
|
|
158
|
-
/** Observable state: current query, item labels and the active index. */
|
|
159
|
-
inspectState() {
|
|
160
|
-
return {
|
|
161
|
-
query: this.getQuery(),
|
|
162
|
-
activeIndex: this.selectedIndexValue,
|
|
163
|
-
title: this.title,
|
|
164
|
-
items: this.itemsValue.map((i) => i.label),
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
set items(value) {
|
|
168
|
-
this.itemsValue = value;
|
|
169
|
-
this.selectedIndexValue = 0;
|
|
170
|
-
this.scrollOffset = 0;
|
|
171
|
-
this.markDirty();
|
|
172
|
-
}
|
|
173
|
-
/**
|
|
174
|
-
* Replace the items WITHOUT jumping the cursor back to the top.
|
|
175
|
-
*
|
|
176
|
-
* Use this when the list is refreshed for the *same* query (e.g. the file
|
|
177
|
-
* index grew in the background and produced more results) — resetting the
|
|
178
|
-
* selection there would yank the cursor away from the user mid-navigation.
|
|
179
|
-
*
|
|
180
|
-
* The previously selected item is re-located by identity (label +
|
|
181
|
-
* description, which uniquely identifies a file row). If it is gone, the
|
|
182
|
-
* previous index is clamped into the new bounds. The selected row is kept
|
|
183
|
-
* on-screen.
|
|
184
|
-
*/
|
|
185
|
-
refreshItems(value) {
|
|
186
|
-
const hadPrevious = this.itemsValue.length > 0;
|
|
187
|
-
const previous = this.itemsValue[this.selectedIndexValue];
|
|
188
|
-
this.itemsValue = value;
|
|
189
|
-
if (value.length === 0) {
|
|
190
|
-
this.selectedIndexValue = 0;
|
|
191
|
-
this.scrollOffset = 0;
|
|
192
|
-
this.markDirty();
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
let next = hadPrevious ? value.findIndex((item) => sameItem(item, previous)) : -1;
|
|
196
|
-
if (next < 0) {
|
|
197
|
-
next = Math.min(this.selectedIndexValue, value.length - 1);
|
|
198
|
-
}
|
|
199
|
-
this.selectedIndexValue = Math.max(0, next);
|
|
200
|
-
// Keep the scroll window valid and the selected row visible.
|
|
201
|
-
const maxScroll = Math.max(0, value.length - this.maxVisibleItems);
|
|
202
|
-
this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
|
|
203
|
-
this.ensureVisible(this.selectedIndexValue);
|
|
204
|
-
this.markDirty();
|
|
205
|
-
}
|
|
206
|
-
get selectedIndex() {
|
|
207
|
-
return this.selectedIndexValue;
|
|
208
|
-
}
|
|
209
|
-
getQuery() {
|
|
210
|
-
return this.inputElement.inputState.value;
|
|
211
|
-
}
|
|
212
|
-
setQuery(value) {
|
|
213
|
-
this.inputElement.inputState.value = value;
|
|
214
|
-
this.markDirty();
|
|
215
|
-
}
|
|
216
|
-
/** Delegate focus to the inner InputElement. */
|
|
217
|
-
focus() {
|
|
218
|
-
this.inputElement.focus();
|
|
219
|
-
}
|
|
220
|
-
// ─── Layout ─────────────────────────────────────────────────────────────
|
|
221
|
-
get visibleItemCount() {
|
|
222
|
-
return Math.min(this.itemsValue.length, this.maxVisibleItems);
|
|
223
|
-
}
|
|
224
|
-
/**
|
|
225
|
-
* Сколько строк списка реально помещается в выделенную layout'ом высоту.
|
|
226
|
-
* Разрыв цикличности с {@link totalHeight}: natural-высота считается ДО
|
|
227
|
-
* layout (интринсики, visibleItemCount), фактическое окно — ПОСЛЕ, от
|
|
228
|
-
* layoutSize. На низком терминале (loose от overlay с маленьким остатком)
|
|
229
|
-
* окно меньше natural — рендер, хит-тест и скролл живут по нему.
|
|
230
|
-
*/
|
|
231
|
-
get effectiveVisibleRows() {
|
|
232
|
-
if (this.visibleItemCount === 0)
|
|
233
|
-
return 0;
|
|
234
|
-
// Всё, кроме строк списка: верх (рамка + input + [message]) + сепаратор + нижняя рамка.
|
|
235
|
-
const chrome = this.bodyTop + 1 + BORDER_THICKNESS;
|
|
236
|
-
return Math.max(0, Math.min(this.visibleItemCount, this.layoutSize.height - chrome));
|
|
237
|
-
}
|
|
238
|
-
/**
|
|
239
|
-
* Строка сепаратора/нижней рамки сразу под «шапкой» (верхняя рамка + input +
|
|
240
|
-
* [message]) — она же счётчик занятых шапкой строк.
|
|
241
|
-
*/
|
|
242
|
-
get bodyTop() {
|
|
243
|
-
return BORDER_THICKNESS + 1 + (this.messageRow !== null ? 1 : 0);
|
|
244
|
-
}
|
|
245
|
-
/**
|
|
246
|
-
* The message row shown under the input (InputBox flavor). A validation
|
|
247
|
-
* message wins over the plain prompt; returns null when neither is set.
|
|
248
|
-
*/
|
|
249
|
-
get messageRow() {
|
|
250
|
-
if (this.validationMessage !== null) {
|
|
251
|
-
const fg = this.validationSeverity === "warning"
|
|
252
|
-
? this.styleVar("editorWarning.foreground")
|
|
253
|
-
: this.validationSeverity === "info"
|
|
254
|
-
? this.styleVar("editorInfo.foreground")
|
|
255
|
-
: this.styleVar("editorError.foreground");
|
|
256
|
-
return { text: this.validationMessage, fg };
|
|
257
|
-
}
|
|
258
|
-
if (this.prompt !== undefined) {
|
|
259
|
-
return { text: this.prompt, fg: this.styleVar("quickPick.promptForeground") };
|
|
260
|
-
}
|
|
261
|
-
return null;
|
|
262
|
-
}
|
|
263
|
-
get totalHeight() {
|
|
264
|
-
const listRows = this.visibleItemCount;
|
|
265
|
-
if (listRows === 0) {
|
|
266
|
-
// top border + input row + [message row] + bottom border
|
|
267
|
-
return this.bodyTop + BORDER_THICKNESS;
|
|
268
|
-
}
|
|
269
|
-
// top border + input row + [message row] + separator + list rows + bottom border
|
|
270
|
-
return this.bodyTop + 1 + listRows + BORDER_THICKNESS;
|
|
271
|
-
}
|
|
272
|
-
getMinIntrinsicWidth(_height) {
|
|
273
|
-
return 20;
|
|
274
|
-
}
|
|
275
|
-
getMaxIntrinsicWidth(_height) {
|
|
276
|
-
return this.preferredWidth;
|
|
277
|
-
}
|
|
278
|
-
getMinIntrinsicHeight(_width) {
|
|
279
|
-
return this.totalHeight;
|
|
280
|
-
}
|
|
281
|
-
getMaxIntrinsicHeight(_width) {
|
|
282
|
-
return this.totalHeight;
|
|
283
|
-
}
|
|
284
|
-
performLayout(constraints) {
|
|
285
|
-
const natural = new Size(this.getMaxIntrinsicWidth(1), this.getMaxIntrinsicHeight(0));
|
|
286
|
-
const size = constraints.constrain(natural);
|
|
287
|
-
super.performLayout(BoxConstraints.tight(size));
|
|
288
|
-
// Единственная точка ресинка скролла с фактическим окном: высота могла
|
|
289
|
-
// ужаться против natural, и старый scrollOffset/выделение могли выпасть
|
|
290
|
-
// из видимого окна.
|
|
291
|
-
const rows = this.effectiveVisibleRows;
|
|
292
|
-
const maxScroll = Math.max(0, this.itemsValue.length - Math.max(rows, 1));
|
|
293
|
-
this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
|
|
294
|
-
this.ensureVisible(this.selectedIndexValue);
|
|
295
|
-
// Position InputElement inside the top border, padded by the border on each side.
|
|
296
|
-
const inputWidth = Math.max(0, size.width - BORDER_THICKNESS * 2);
|
|
297
|
-
this.layoutChild(this.inputElement, BORDER_THICKNESS, BORDER_THICKNESS, BoxConstraints.tight(new Size(inputWidth, 1)));
|
|
298
|
-
return size;
|
|
299
|
-
}
|
|
300
|
-
// ─── Children ───────────────────────────────────────────────────────────
|
|
301
|
-
// ─── Render ─────────────────────────────────────────────────────────────
|
|
302
|
-
render(context) {
|
|
303
|
-
const w = this.layoutSize.width;
|
|
304
|
-
// Use natural height when the parent allocates more vertical space than
|
|
305
|
-
// needed (prevents a gap between the last item and the bottom border);
|
|
306
|
-
// when allocated LESS (низкий терминал), рисуем строго в выделенном.
|
|
307
|
-
const h = Math.min(this.totalHeight, this.layoutSize.height);
|
|
308
|
-
if (w < BORDER_THICKNESS * 2 || h < BORDER_THICKNESS * 2)
|
|
309
|
-
return; // не помещается даже рамка
|
|
310
|
-
const visibleRows = this.effectiveVisibleRows;
|
|
311
|
-
const hasItems = visibleRows > 0;
|
|
312
|
-
// Row after the input (+ message row when present): separator/bottom border.
|
|
313
|
-
const message = this.messageRow;
|
|
314
|
-
const bodyTop = this.bodyTop;
|
|
315
|
-
// ── Background fill + frame ───────────────────────────────────────────
|
|
316
|
-
// The separator between the input area and the list is a T-connector row
|
|
317
|
-
// (├───┤); item rows re-draw their own side borders under the row bg.
|
|
318
|
-
const separators = hasItems ? [bodyTop] : undefined;
|
|
319
|
-
context.drawBox(0, 0, w, h, {
|
|
320
|
-
fg: this.styleVar("quickPick.border"),
|
|
321
|
-
bg: this.styleVar("quickInput.background"),
|
|
322
|
-
fill: true,
|
|
323
|
-
separators,
|
|
324
|
-
});
|
|
325
|
-
// ── Top border title (optional, centered as ┤ title ├) ────────────────
|
|
326
|
-
if (this.title !== undefined && this.title !== "") {
|
|
327
|
-
this.renderTitle(context, w);
|
|
328
|
-
}
|
|
329
|
-
// ── Render InputElement (только если строка не легла на нижнюю рамку) ─
|
|
330
|
-
// Give it an explicit placeholder since we own the visual chrome.
|
|
331
|
-
if (h >= 3) {
|
|
332
|
-
this.inputElement.placeholder = this.placeholder;
|
|
333
|
-
const inputClip = new Rect(this.inputElement.globalPosition, this.inputElement.layoutSize);
|
|
334
|
-
const inputOffset = new Offset(this.inputElement.localPosition.dx, this.inputElement.localPosition.dy);
|
|
335
|
-
const inputContext = context.withOffset(inputOffset).withClip(inputClip);
|
|
336
|
-
if (!inputContext.clipRect.isEmpty)
|
|
337
|
-
this.inputElement.render(inputContext);
|
|
338
|
-
}
|
|
339
|
-
// ── Optional message row (InputBox prompt / validation) ───────────────
|
|
340
|
-
if (message !== null && h >= 4) {
|
|
341
|
-
this.renderMessageRow(context, w, 2, message);
|
|
342
|
-
}
|
|
343
|
-
// ── Item rows (frame + separator already drawn by drawBox above) ──────
|
|
344
|
-
if (hasItems) {
|
|
345
|
-
const hasIcons = this.itemsValue.some((item) => item.icon !== undefined);
|
|
346
|
-
for (let i = 0; i < visibleRows; i++) {
|
|
347
|
-
const itemIndex = this.scrollOffset + i;
|
|
348
|
-
this.renderItemRow(context, w, bodyTop + 1 + i, itemIndex, hasIcons);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
/** Draws the title centered into the top border row as ┤ title ├. */
|
|
353
|
-
renderTitle(context, w) {
|
|
354
|
-
const label = ` ${this.title} `;
|
|
355
|
-
const labelWidth = new DisplayLine(label).displayWidth;
|
|
356
|
-
// Need room for the two caps plus the border corners.
|
|
357
|
-
if (labelWidth + 4 > w)
|
|
358
|
-
return;
|
|
359
|
-
const startX = Math.max(2, Math.floor((w - labelWidth) / 2));
|
|
360
|
-
context.setCell(startX - 1, 0, {
|
|
361
|
-
char: "┤",
|
|
362
|
-
fg: this.styleVar("quickPick.border"),
|
|
363
|
-
bg: this.styleVar("quickInput.background"),
|
|
364
|
-
});
|
|
365
|
-
context.drawText(startX, 0, label, {
|
|
366
|
-
fg: this.styleVar("quickPick.titleForeground"),
|
|
367
|
-
bg: this.styleVar("quickInput.background"),
|
|
368
|
-
});
|
|
369
|
-
context.setCell(startX + labelWidth, 0, {
|
|
370
|
-
char: "├",
|
|
371
|
-
fg: this.styleVar("quickPick.border"),
|
|
372
|
-
bg: this.styleVar("quickInput.background"),
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
/** Draws the prompt / validation message row under the input. */
|
|
376
|
-
renderMessageRow(context, w, rowY, message) {
|
|
377
|
-
for (let x = 0; x < w; x++) {
|
|
378
|
-
context.setCell(x, rowY, { char: " ", fg: message.fg, bg: this.styleVar("quickInput.background") });
|
|
379
|
-
}
|
|
380
|
-
context.setCell(0, rowY, {
|
|
381
|
-
char: "│",
|
|
382
|
-
fg: this.styleVar("quickPick.border"),
|
|
383
|
-
bg: this.styleVar("quickInput.background"),
|
|
384
|
-
});
|
|
385
|
-
context.setCell(w - 1, rowY, {
|
|
386
|
-
char: "│",
|
|
387
|
-
fg: this.styleVar("quickPick.border"),
|
|
388
|
-
bg: this.styleVar("quickInput.background"),
|
|
389
|
-
});
|
|
390
|
-
const avail = Math.max(0, w - 3);
|
|
391
|
-
const text = new DisplayLine(message.text).displayWidth <= avail ? message.text : truncateEnd(message.text, avail);
|
|
392
|
-
context.drawText(2, rowY, text, { fg: message.fg, bg: this.styleVar("quickInput.background") }, { maxWidth: avail });
|
|
393
|
-
}
|
|
394
|
-
renderItemRow(context, w, rowY, itemIndex, hasIcons) {
|
|
395
|
-
const item = this.itemsValue[itemIndex];
|
|
396
|
-
const isSelected = itemIndex === this.selectedIndexValue;
|
|
397
|
-
const rowBg = isSelected
|
|
398
|
-
? this.styleVar("list.activeSelectionBackground")
|
|
399
|
-
: this.styleVar("quickInput.background");
|
|
400
|
-
const rowFg = isSelected
|
|
401
|
-
? this.styleVar("list.activeSelectionForeground")
|
|
402
|
-
: this.styleVar("quickInput.foreground");
|
|
403
|
-
// ── Row background ────────────────────────────────────────────────────
|
|
404
|
-
// Fill only the interior; the border columns keep the box background so
|
|
405
|
-
// the selection highlight never bleeds onto the frame (see issue #94).
|
|
406
|
-
for (let x = 1; x < w - 1; x++) {
|
|
407
|
-
context.setCell(x, rowY, { char: " ", fg: rowFg, bg: rowBg });
|
|
408
|
-
}
|
|
409
|
-
// ── Side borders ──────────────────────────────────────────────────────
|
|
410
|
-
context.setCell(0, rowY, {
|
|
411
|
-
char: "│",
|
|
412
|
-
fg: this.styleVar("quickPick.border"),
|
|
413
|
-
bg: this.styleVar("quickInput.background"),
|
|
414
|
-
});
|
|
415
|
-
context.setCell(w - 1, rowY, {
|
|
416
|
-
char: "│",
|
|
417
|
-
fg: this.styleVar("quickPick.border"),
|
|
418
|
-
bg: this.styleVar("quickInput.background"),
|
|
419
|
-
});
|
|
420
|
-
let x = 2;
|
|
421
|
-
// ── Icon column ───────────────────────────────────────────────────────
|
|
422
|
-
if (hasIcons) {
|
|
423
|
-
const icon = item.icon ?? " ";
|
|
424
|
-
context.setCell(x, rowY, { char: icon, fg: rowFg, bg: rowBg });
|
|
425
|
-
x += 2; // icon char + trailing space
|
|
426
|
-
}
|
|
427
|
-
// ── Layout budget ─────────────────────────────────────────────────────
|
|
428
|
-
// The label (filename) has priority: it is shown in full whenever it
|
|
429
|
-
// fits, and the description (file path) is shrunk to whatever space is
|
|
430
|
-
// left so nothing overflows the picker border. The other metadata
|
|
431
|
-
// fields (badge / shortcut / hint) are short and always shown whole.
|
|
432
|
-
const contentRight = w - BORDER_THICKNESS - RIGHT_PAD; // exclusive, inside right border
|
|
433
|
-
const avail = Math.max(0, contentRight - x);
|
|
434
|
-
const metaBefore = [];
|
|
435
|
-
const metaAfter = [];
|
|
436
|
-
if (item.badge !== undefined) {
|
|
437
|
-
metaBefore.push({ text: " ★ " + item.badge, fg: this.styleVar("quickPick.badgeForeground") });
|
|
438
|
-
}
|
|
439
|
-
if (item.shortcut !== undefined) {
|
|
440
|
-
metaAfter.push({
|
|
441
|
-
text: " " + item.shortcut,
|
|
442
|
-
fg: isSelected
|
|
443
|
-
? this.styleVar("list.activeSelectionForeground")
|
|
444
|
-
: this.styleVar("quickPick.shortcutForeground"),
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
if (item.hint !== undefined) {
|
|
448
|
-
metaAfter.push({
|
|
449
|
-
text: " " + item.hint,
|
|
450
|
-
fg: isSelected
|
|
451
|
-
? this.styleVar("list.activeSelectionForeground")
|
|
452
|
-
: this.styleVar("quickPick.hintForeground"),
|
|
453
|
-
});
|
|
454
|
-
}
|
|
455
|
-
const metaWidth = [...metaBefore, ...metaAfter].reduce((sum, p) => sum + new DisplayLine(p.text).displayWidth, 0);
|
|
456
|
-
// Space shared between label and description, after fixed metadata.
|
|
457
|
-
const shared = Math.max(0, avail - metaWidth);
|
|
458
|
-
const labelNatural = new DisplayLine(item.label).displayWidth;
|
|
459
|
-
// ── Label (priority) ──────────────────────────────────────────────────
|
|
460
|
-
const labelFits = labelNatural <= shared;
|
|
461
|
-
const labelText = labelFits ? item.label : truncateEnd(item.label, shared);
|
|
462
|
-
const labelDraw = labelFits ? labelNatural : new DisplayLine(labelText).displayWidth;
|
|
463
|
-
// ── Description (fills remaining space, abbreviated if needed) ─────────
|
|
464
|
-
const DESC_SEPARATOR = " ";
|
|
465
|
-
const descParts = [];
|
|
466
|
-
const descBudget = labelFits ? shared - labelNatural : 0;
|
|
467
|
-
const dirBudget = descBudget - DESC_SEPARATOR.length;
|
|
468
|
-
if (item.description !== undefined && item.description !== "" && dirBudget >= 1) {
|
|
469
|
-
const dir = item.description;
|
|
470
|
-
const shown = new DisplayLine(dir).displayWidth <= dirBudget ? dir : abbreviatePath(dir, dirBudget);
|
|
471
|
-
descParts.push({
|
|
472
|
-
text: DESC_SEPARATOR + shown,
|
|
473
|
-
fg: isSelected
|
|
474
|
-
? this.styleVar("list.activeSelectionForeground")
|
|
475
|
-
: this.styleVar("descriptionForeground"),
|
|
476
|
-
});
|
|
477
|
-
}
|
|
478
|
-
const matchSet = buildMatchSet(item.labelMatchRanges ?? []);
|
|
479
|
-
context.drawText(x, rowY, labelText, { fg: rowFg, bg: rowBg }, {
|
|
480
|
-
maxWidth: labelDraw,
|
|
481
|
-
getStyle: (offset) => {
|
|
482
|
-
if (matchSet.has(offset)) {
|
|
483
|
-
return {
|
|
484
|
-
fg: isSelected
|
|
485
|
-
? this.styleVar("list.activeSelectionForeground")
|
|
486
|
-
: this.styleVar("list.highlightForeground"),
|
|
487
|
-
};
|
|
488
|
-
}
|
|
489
|
-
return undefined;
|
|
490
|
-
},
|
|
491
|
-
});
|
|
492
|
-
// ── Right parts (right-aligned, guaranteed within the border) ─────────
|
|
493
|
-
const rightParts = [...metaBefore, ...descParts, ...metaAfter];
|
|
494
|
-
const rightTotalWidth = rightParts.reduce((sum, p) => sum + new DisplayLine(p.text).displayWidth, 0);
|
|
495
|
-
let rx = contentRight - rightTotalWidth;
|
|
496
|
-
for (const part of rightParts) {
|
|
497
|
-
const partWidth = new DisplayLine(part.text).displayWidth;
|
|
498
|
-
context.drawText(rx, rowY, part.text, { fg: part.fg, bg: rowBg });
|
|
499
|
-
rx += partWidth;
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
// ─── Selection ───────────────────────────────────────────────────────────
|
|
503
|
-
moveSelection(delta) {
|
|
504
|
-
if (this.itemsValue.length === 0)
|
|
505
|
-
return;
|
|
506
|
-
const next = Math.max(0, Math.min(this.itemsValue.length - 1, this.selectedIndexValue + delta));
|
|
507
|
-
if (next === this.selectedIndexValue)
|
|
508
|
-
return;
|
|
509
|
-
this.selectedIndexValue = next;
|
|
510
|
-
this.ensureVisible(next);
|
|
511
|
-
this.markDirty();
|
|
512
|
-
this.onActiveItemChanged?.(this.itemsValue[next], next);
|
|
513
|
-
}
|
|
514
|
-
/**
|
|
515
|
-
* Move the highlight to `index` programmatically (e.g. pre-select the current
|
|
516
|
-
* theme when the picker opens). Clamped into range; keeps the row on-screen.
|
|
517
|
-
* Does NOT fire {@link onActiveItemChanged} — this is not a user navigation.
|
|
518
|
-
*/
|
|
519
|
-
setActiveIndex(index) {
|
|
520
|
-
if (this.itemsValue.length === 0)
|
|
521
|
-
return;
|
|
522
|
-
const clamped = Math.max(0, Math.min(this.itemsValue.length - 1, index));
|
|
523
|
-
this.selectedIndexValue = clamped;
|
|
524
|
-
this.ensureVisible(clamped);
|
|
525
|
-
this.markDirty();
|
|
526
|
-
}
|
|
527
|
-
ensureVisible(index) {
|
|
528
|
-
const rows = this.effectiveVisibleRows;
|
|
529
|
-
if (rows <= 0)
|
|
530
|
-
return;
|
|
531
|
-
if (index < this.scrollOffset) {
|
|
532
|
-
this.scrollOffset = index;
|
|
533
|
-
}
|
|
534
|
-
else if (index >= this.scrollOffset + rows) {
|
|
535
|
-
this.scrollOffset = index - rows + 1;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
540
|
-
/**
|
|
541
|
-
* Identity check used to keep the selection stable across `refreshItems`.
|
|
542
|
-
* Items are rebuilt as fresh objects on every refresh, so we compare the
|
|
543
|
-
* fields that uniquely identify a row: `label` + `description` (for file rows
|
|
544
|
-
* that is basename + directory, i.e. the relative path).
|
|
545
|
-
*/
|
|
546
|
-
function sameItem(a, b) {
|
|
547
|
-
return a.label === b.label && a.description === b.description;
|
|
548
|
-
}
|
|
549
|
-
/**
|
|
550
|
-
* Expands [start, end) range pairs into a flat Set of matched byte offsets,
|
|
551
|
-
* for per-character colour lookups in `drawText.getStyle`.
|
|
552
|
-
*/
|
|
553
|
-
function buildMatchSet(ranges) {
|
|
554
|
-
const set = new Set();
|
|
555
|
-
for (const [start, end] of ranges) {
|
|
556
|
-
for (let i = start; i < end; i++) {
|
|
557
|
-
set.add(i);
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
return set;
|
|
561
|
-
}
|