@tuidom/elements 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/body/bodyElement.d.ts +33 -0
- package/dist/body/bodyElement.js +87 -0
- package/dist/button/buttonElement.d.ts +18 -0
- package/dist/button/buttonElement.js +68 -0
- package/dist/completionlist/completionDetailsElement.d.ts +49 -0
- package/dist/completionlist/completionDetailsElement.js +165 -0
- package/dist/completionlist/completionItemKindIcon.d.ts +2 -0
- package/dist/completionlist/completionItemKindIcon.js +43 -0
- package/dist/completionlist/completionListElement.d.ts +102 -0
- package/dist/completionlist/completionListElement.js +309 -0
- package/dist/completionlist/completionWidgetElement.d.ts +41 -0
- package/dist/completionlist/completionWidgetElement.js +102 -0
- package/dist/contextview/contextMenuController.d.ts +23 -0
- package/dist/contextview/contextMenuController.js +75 -0
- package/dist/contextview/overlayHostElement.d.ts +23 -0
- package/dist/contextview/overlayHostElement.js +47 -0
- package/dist/editorgroup/editorTabItemElement.d.ts +41 -0
- package/dist/editorgroup/editorTabItemElement.js +186 -0
- package/dist/editorgroup/editorTabStripElement.d.ts +33 -0
- package/dist/editorgroup/editorTabStripElement.js +118 -0
- package/dist/editorpart/editorPartElement.d.ts +95 -0
- package/dist/editorpart/editorPartElement.js +285 -0
- package/dist/inputbox/inputElement.d.ts +34 -0
- package/dist/inputbox/inputElement.js +207 -0
- package/dist/inputbox/inputState.d.ts +69 -0
- package/dist/inputbox/inputState.js +326 -0
- package/dist/layout/boxContainerElement.d.ts +27 -0
- package/dist/layout/boxContainerElement.js +121 -0
- package/dist/layout/boxElement.d.ts +4 -0
- package/dist/layout/boxElement.js +30 -0
- package/dist/layout/fillerElement.d.ts +12 -0
- package/dist/layout/fillerElement.js +16 -0
- package/dist/layout/fitContentElement.d.ts +18 -0
- package/dist/layout/fitContentElement.js +46 -0
- package/dist/layout/hFlexElement.d.ts +26 -0
- package/dist/layout/hFlexElement.js +134 -0
- package/dist/layout/paddingContainerElement.d.ts +30 -0
- package/dist/layout/paddingContainerElement.js +88 -0
- package/dist/layout/sizedBoxElement.d.ts +30 -0
- package/dist/layout/sizedBoxElement.js +74 -0
- package/dist/layout/vFlexElement.d.ts +32 -0
- package/dist/layout/vFlexElement.js +136 -0
- package/dist/layout/vStackElement.d.ts +18 -0
- package/dist/layout/vStackElement.js +74 -0
- package/dist/list/listViewElement.d.ts +232 -0
- package/dist/list/listViewElement.js +944 -0
- package/dist/menu/menuBarElement.d.ts +47 -0
- package/dist/menu/menuBarElement.js +285 -0
- package/dist/menu/menuBarItemElement.d.ts +20 -0
- package/dist/menu/menuBarItemElement.js +68 -0
- package/dist/menu/popupMenuElement.d.ts +81 -0
- package/dist/menu/popupMenuElement.js +318 -0
- package/dist/menu/popupMenuItemElement.d.ts +24 -0
- package/dist/menu/popupMenuItemElement.js +89 -0
- package/dist/panel/panelContainerElement.d.ts +64 -0
- package/dist/panel/panelContainerElement.js +219 -0
- package/dist/quickpick/quickPickElement.d.ts +161 -0
- package/dist/quickpick/quickPickElement.js +561 -0
- package/dist/sash/sashElement.d.ts +30 -0
- package/dist/sash/sashElement.js +78 -0
- package/dist/scrollbar/iScrollable.d.ts +8 -0
- package/dist/scrollbar/iScrollable.js +1 -0
- package/dist/scrollbar/scrollBarRenderer.d.ts +36 -0
- package/dist/scrollbar/scrollBarRenderer.js +76 -0
- package/dist/scrollbar/scrollContainerElement.d.ts +39 -0
- package/dist/scrollbar/scrollContainerElement.js +91 -0
- package/dist/scrollbar/scrollViewport.d.ts +28 -0
- package/dist/scrollbar/scrollViewport.js +65 -0
- package/dist/scrollbar/scrollableElement.d.ts +22 -0
- package/dist/scrollbar/scrollableElement.js +42 -0
- package/dist/selectbox/selectBoxElement.d.ts +48 -0
- package/dist/selectbox/selectBoxElement.js +161 -0
- package/dist/terminal/encodeKeyForPty.d.ts +3 -0
- package/dist/terminal/encodeKeyForPty.js +56 -0
- package/dist/terminal/terminalViewElement.d.ts +17 -0
- package/dist/terminal/terminalViewElement.js +172 -0
- package/dist/text/textBlockElement.d.ts +13 -0
- package/dist/text/textBlockElement.js +35 -0
- package/dist/text/textLabelElement.d.ts +28 -0
- package/dist/text/textLabelElement.js +77 -0
- package/dist/titledpanel/titledPanelElement.d.ts +14 -0
- package/dist/titledpanel/titledPanelElement.js +40 -0
- package/dist/tree/iTreeDataProvider.d.ts +19 -0
- package/dist/tree/iTreeDataProvider.js +1 -0
- package/dist/tree/treeViewElement.d.ts +116 -0
- package/dist/tree/treeViewElement.js +748 -0
- package/dist/workbenchlayout/workbenchLayoutElement.d.ts +60 -0
- package/dist/workbenchlayout/workbenchLayoutElement.js +203 -0
- package/package.json +33 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { DisplayLine } from "@tuidom/core/common/displayLine";
|
|
2
|
+
import { BoxConstraints, Size } from "@tuidom/core/common/geometryPromitives";
|
|
3
|
+
import { truncateEnd } from "@tuidom/core/common/textTruncation";
|
|
4
|
+
import { BORDER_THICKNESS } from "@tuidom/core/dom/borderStyle";
|
|
5
|
+
import { RenderContext, TUIElement } from "@tuidom/core/dom/tuiElement";
|
|
6
|
+
import { kindIcon } from "./completionItemKindIcon.js";
|
|
7
|
+
// ─── Styles ──────────────────────────────────────────────────────────────────
|
|
8
|
+
// ─── Layout ───────────────────────────────────────────────────────────────────
|
|
9
|
+
// [│(0)][pad(1)][icon(2)][gap(3,4)][label…][…][pad(w-2)][│(w-1)]
|
|
10
|
+
const LABEL_X = 5;
|
|
11
|
+
const RIGHT_PAD = 1;
|
|
12
|
+
const MIN_WIDTH = 16;
|
|
13
|
+
/** Сколько пунктов участвует в измерении ширины (ответы LSP — тысячи пунктов). */
|
|
14
|
+
const WIDTH_SAMPLE_LIMIT = 200;
|
|
15
|
+
/**
|
|
16
|
+
* Компактный дропдаун автодополнения в стиле NvChad: рамка (углы `╭╮╰╯` —
|
|
17
|
+
* единый стиль с остальными оверлеями), выбранный ряд подсвечивается фоном (без
|
|
18
|
+
* указателей), 1-ячейка паддинга от рамки, колонка codicon-иконки типа.
|
|
19
|
+
* Собственной строки ввода нет — фильтр внутренний (набор символов сужает
|
|
20
|
+
* список, не трогая буфер редактора).
|
|
21
|
+
*
|
|
22
|
+
* Как в VS Code suggest widget попап **не забирает фокус** (`focusable = false`):
|
|
23
|
+
* навигацией/принятием/скрытием управляет {@link import("../../../src/vs/workbench/contrib/suggest/browser/completionService.ts").CompletionService} через
|
|
24
|
+
* публичные методы (клавиши приходят командами по `suggestWidgetVisible`), а
|
|
25
|
+
* фильтрация идёт от префикса под кареткой редактора. Мышью: наведение
|
|
26
|
+
* подсвечивает ряд, клик принимает пункт.
|
|
27
|
+
*/
|
|
28
|
+
export class CompletionListElement extends TUIElement {
|
|
29
|
+
maxVisibleItems = 10;
|
|
30
|
+
preferredWidth = 40;
|
|
31
|
+
onAccept = null;
|
|
32
|
+
/**
|
|
33
|
+
* Сменился выбранный пункт (клавиатурой, мышью или пере-фильтрацией).
|
|
34
|
+
* По нему владелец догружает описание для панели — у LSP-источников оно
|
|
35
|
+
* приходит отдельным запросом на конкретный пункт.
|
|
36
|
+
*/
|
|
37
|
+
onSelectionChanged = null;
|
|
38
|
+
allItems = [];
|
|
39
|
+
filteredItems = [];
|
|
40
|
+
filterValue = "";
|
|
41
|
+
selectedIndexValue = 0;
|
|
42
|
+
scrollOffset = 0;
|
|
43
|
+
/** Кэш {@link contentWidth}; сбрасывается сменой отфильтрованного списка. */
|
|
44
|
+
contentWidthCache = null;
|
|
45
|
+
constructor() {
|
|
46
|
+
super();
|
|
47
|
+
// Не фокусируемся: редактор сохраняет фокус и каретку (см. класс-док).
|
|
48
|
+
this.focusable = false;
|
|
49
|
+
this.addEventListener("mousemove", (event) => {
|
|
50
|
+
this.handleMouseMove(event);
|
|
51
|
+
});
|
|
52
|
+
this.addEventListener("click", (event) => {
|
|
53
|
+
this.handleClick(event);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// ─── Public API ──────────────────────────────────────────────────────────
|
|
57
|
+
/** Задаёт полный набор элементов; переприменяет текущий фильтр (fresh). */
|
|
58
|
+
setItems(items) {
|
|
59
|
+
this.allItems = items;
|
|
60
|
+
this.applyFilter(false);
|
|
61
|
+
}
|
|
62
|
+
/** Задаёт фильтр «начисто» (пустой результат сворачивает список). */
|
|
63
|
+
setFilter(value) {
|
|
64
|
+
this.filterValue = value;
|
|
65
|
+
this.applyFilter(false);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Инкрементальное сужение по мере набора: если новый префикс не совпал ни с
|
|
69
|
+
* чем — **оставляем последний непустой список** (как в VS Code), а не
|
|
70
|
+
* сворачиваем попап.
|
|
71
|
+
*/
|
|
72
|
+
refineFilter(value) {
|
|
73
|
+
this.filterValue = value;
|
|
74
|
+
this.applyFilter(true);
|
|
75
|
+
}
|
|
76
|
+
/** Видимые (отфильтрованные) элементы. */
|
|
77
|
+
get items() {
|
|
78
|
+
return this.filteredItems;
|
|
79
|
+
}
|
|
80
|
+
get selectedIndex() {
|
|
81
|
+
return this.selectedIndexValue;
|
|
82
|
+
}
|
|
83
|
+
getSelectedItem() {
|
|
84
|
+
return this.filteredItems[this.selectedIndexValue] ?? null;
|
|
85
|
+
}
|
|
86
|
+
// ─── Filtering ───────────────────────────────────────────────────────────
|
|
87
|
+
applyFilter(keepLastNonEmpty) {
|
|
88
|
+
const needle = this.filterValue.toLowerCase();
|
|
89
|
+
const matched = needle === ""
|
|
90
|
+
? [...this.allItems]
|
|
91
|
+
: this.allItems.filter((item) => matchText(item).toLowerCase().includes(needle));
|
|
92
|
+
// «Последний непустой»: при доборе не сворачиваем список до нуля.
|
|
93
|
+
if (keepLastNonEmpty && matched.length === 0 && this.filteredItems.length > 0)
|
|
94
|
+
return;
|
|
95
|
+
// Порядок: сначала совпадения с НАЧАЛА (их пользователь и набирает),
|
|
96
|
+
// потом совпадения внутри слова; внутри группы — по ключу источника
|
|
97
|
+
// (`sortText`). Сортировка стабильная, поэтому пункты без ключа
|
|
98
|
+
// сохраняют исходный порядок провайдера.
|
|
99
|
+
this.filteredItems = matched.sort((a, b) => {
|
|
100
|
+
const rank = matchRank(a, needle) - matchRank(b, needle);
|
|
101
|
+
if (rank !== 0)
|
|
102
|
+
return rank;
|
|
103
|
+
const aKey = a.sortText ?? "";
|
|
104
|
+
const bKey = b.sortText ?? "";
|
|
105
|
+
return aKey < bKey ? -1 : aKey > bKey ? 1 : 0;
|
|
106
|
+
});
|
|
107
|
+
this.contentWidthCache = null;
|
|
108
|
+
this.selectedIndexValue = 0;
|
|
109
|
+
this.scrollOffset = 0;
|
|
110
|
+
this.markDirty();
|
|
111
|
+
this.fireSelectionChanged();
|
|
112
|
+
}
|
|
113
|
+
// ─── Sizing ──────────────────────────────────────────────────────────────
|
|
114
|
+
get visibleItemCount() {
|
|
115
|
+
return Math.min(this.filteredItems.length, this.maxVisibleItems);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Ширина содержимого. Считается один раз на смену списка и кэшируется:
|
|
119
|
+
* layout зовут на каждый кадр, а список от language server'а — это сотни
|
|
120
|
+
* и тысячи пунктов. По той же причине меряем только первые
|
|
121
|
+
* {@link WIDTH_SAMPLE_LIMIT} — попап всё равно упирается в `preferredWidth`.
|
|
122
|
+
*/
|
|
123
|
+
get contentWidth() {
|
|
124
|
+
if (this.contentWidthCache !== null)
|
|
125
|
+
return this.contentWidthCache;
|
|
126
|
+
let max = 0;
|
|
127
|
+
const sample = Math.min(this.filteredItems.length, WIDTH_SAMPLE_LIMIT);
|
|
128
|
+
for (let i = 0; i < sample; i++) {
|
|
129
|
+
const item = this.filteredItems[i];
|
|
130
|
+
const labelW = new DisplayLine(item.label).displayWidth;
|
|
131
|
+
const labelDetailW = item.labelDetail !== undefined && item.labelDetail !== ""
|
|
132
|
+
? 1 + new DisplayLine(item.labelDetail).displayWidth
|
|
133
|
+
: 0;
|
|
134
|
+
const detailW = item.detail !== undefined && item.detail !== "" ? 2 + new DisplayLine(item.detail).displayWidth : 0;
|
|
135
|
+
max = Math.max(max, labelW + labelDetailW + detailW);
|
|
136
|
+
}
|
|
137
|
+
this.contentWidthCache = max;
|
|
138
|
+
return max;
|
|
139
|
+
}
|
|
140
|
+
get boxWidth() {
|
|
141
|
+
// Левая рамка сидит внутри LABEL_X (см. схему колонок выше).
|
|
142
|
+
const natural = LABEL_X + this.contentWidth + RIGHT_PAD + BORDER_THICKNESS;
|
|
143
|
+
return Math.max(MIN_WIDTH, Math.min(this.preferredWidth, natural));
|
|
144
|
+
}
|
|
145
|
+
get boxHeight() {
|
|
146
|
+
return this.visibleItemCount + BORDER_THICKNESS * 2; // рамка сверху/снизу
|
|
147
|
+
}
|
|
148
|
+
getMinIntrinsicWidth(_height) {
|
|
149
|
+
return this.boxWidth;
|
|
150
|
+
}
|
|
151
|
+
getMaxIntrinsicWidth(_height) {
|
|
152
|
+
return this.boxWidth;
|
|
153
|
+
}
|
|
154
|
+
getMinIntrinsicHeight(_width) {
|
|
155
|
+
return this.boxHeight;
|
|
156
|
+
}
|
|
157
|
+
getMaxIntrinsicHeight(_width) {
|
|
158
|
+
return this.boxHeight;
|
|
159
|
+
}
|
|
160
|
+
performLayout(constraints) {
|
|
161
|
+
const size = constraints.constrain(new Size(this.boxWidth, this.boxHeight));
|
|
162
|
+
super.performLayout(BoxConstraints.tight(size));
|
|
163
|
+
return size;
|
|
164
|
+
}
|
|
165
|
+
// ─── Render ──────────────────────────────────────────────────────────────
|
|
166
|
+
render(context) {
|
|
167
|
+
const w = this.layoutSize.width;
|
|
168
|
+
const h = this.boxHeight;
|
|
169
|
+
// Фон + рамка (единый стиль углов с остальными оверлеями). Рамку рисуем
|
|
170
|
+
// только здесь; ряды заливают лишь внутреннюю область, чтобы фон
|
|
171
|
+
// выделения не залезал на боковые рамки.
|
|
172
|
+
context.drawBox(0, 0, w, h, {
|
|
173
|
+
fg: this.styleVar("editorSuggestWidget.border"),
|
|
174
|
+
bg: this.styleVar("editorSuggestWidget.background"),
|
|
175
|
+
fill: true,
|
|
176
|
+
});
|
|
177
|
+
// Ряды
|
|
178
|
+
for (let i = 0; i < this.visibleItemCount; i++) {
|
|
179
|
+
this.renderRow(context, w, BORDER_THICKNESS + i, this.scrollOffset + i);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
renderRow(context, w, rowY, itemIndex) {
|
|
183
|
+
const item = this.filteredItems[itemIndex];
|
|
184
|
+
const isSelected = itemIndex === this.selectedIndexValue;
|
|
185
|
+
const rowBg = isSelected
|
|
186
|
+
? this.styleVar("editorSuggestWidget.selectedBackground")
|
|
187
|
+
: this.styleVar("editorSuggestWidget.background");
|
|
188
|
+
const rowFg = isSelected
|
|
189
|
+
? this.styleVar("editorSuggestWidget.selectedForeground")
|
|
190
|
+
: this.styleVar("editorSuggestWidget.foreground");
|
|
191
|
+
// Фон ряда только во внутренней области [1, w-1) — боковые рамки (их
|
|
192
|
+
// нарисовал drawBox) не трогаем, иначе фон выделения залезает на рамку.
|
|
193
|
+
for (let x = 1; x < w - 1; x++)
|
|
194
|
+
context.setCell(x, rowY, { char: " ", fg: rowFg, bg: rowBg });
|
|
195
|
+
// Иконка типа
|
|
196
|
+
const icon = kindIcon(item.kind);
|
|
197
|
+
context.drawText(2, rowY, icon, {
|
|
198
|
+
fg: isSelected ? rowFg : this.styleVar("editorSuggestWidget.iconForeground"),
|
|
199
|
+
bg: rowBg,
|
|
200
|
+
});
|
|
201
|
+
// Правый блок: detail (dim, right-aligned)
|
|
202
|
+
const contentRight = w - 1 - RIGHT_PAD; // exclusive
|
|
203
|
+
let rightWidth = 0;
|
|
204
|
+
if (item.detail !== undefined && item.detail !== "") {
|
|
205
|
+
const detailText = " " + item.detail;
|
|
206
|
+
const detailW = new DisplayLine(detailText).displayWidth;
|
|
207
|
+
if (LABEL_X + 1 + detailW <= contentRight) {
|
|
208
|
+
context.drawText(contentRight - detailW, rowY, detailText, {
|
|
209
|
+
fg: isSelected ? rowFg : this.styleVar("editorSuggestWidget.detailForeground"),
|
|
210
|
+
bg: rowBg,
|
|
211
|
+
});
|
|
212
|
+
rightWidth = detailW;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Label (приоритет, усечение по остатку)
|
|
216
|
+
const labelAvail = Math.max(0, contentRight - rightWidth - LABEL_X);
|
|
217
|
+
const labelNatural = new DisplayLine(item.label).displayWidth;
|
|
218
|
+
const labelText = labelNatural <= labelAvail ? item.label : truncateEnd(item.label, labelAvail);
|
|
219
|
+
context.drawText(LABEL_X, rowY, labelText, { fg: rowFg, bg: rowBg }, { maxWidth: labelAvail });
|
|
220
|
+
// Сигнатура вплотную за лейблом (labelDetails.detail у LSP) — приглушённо
|
|
221
|
+
// и только если влезает целиком: обрезанная сигнатура вводит в заблуждение.
|
|
222
|
+
if (item.labelDetail !== undefined && item.labelDetail !== "" && labelNatural <= labelAvail) {
|
|
223
|
+
const detailX = LABEL_X + labelNatural + 1;
|
|
224
|
+
const available = contentRight - rightWidth - detailX;
|
|
225
|
+
if (available > 0) {
|
|
226
|
+
context.drawText(detailX, rowY, item.labelDetail, {
|
|
227
|
+
fg: isSelected ? rowFg : this.styleVar("editorSuggestWidget.detailForeground"),
|
|
228
|
+
bg: rowBg,
|
|
229
|
+
}, { maxWidth: available });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// ─── Navigation (driven by CompletionService via commands) ─────────────
|
|
234
|
+
selectNext() {
|
|
235
|
+
this.moveSelection(1);
|
|
236
|
+
}
|
|
237
|
+
selectPrevious() {
|
|
238
|
+
this.moveSelection(-1);
|
|
239
|
+
}
|
|
240
|
+
selectNextPage() {
|
|
241
|
+
this.moveSelection(Math.max(1, this.visibleItemCount));
|
|
242
|
+
}
|
|
243
|
+
selectPreviousPage() {
|
|
244
|
+
this.moveSelection(-Math.max(1, this.visibleItemCount));
|
|
245
|
+
}
|
|
246
|
+
// ─── Mouse ────────────────────────────────────────────────────────────────
|
|
247
|
+
/**
|
|
248
|
+
* Индекс пункта под локальной Y, или `null`, если это рамка/за пределами
|
|
249
|
+
* видимых строк. `visibleItemCount` + `scrollOffset` гарантируют, что
|
|
250
|
+
* возвращённый индекс всегда в пределах `filteredItems`.
|
|
251
|
+
*/
|
|
252
|
+
rowAt(localY) {
|
|
253
|
+
const row = localY - BORDER_THICKNESS; // строка 0 — верхняя рамка
|
|
254
|
+
if (row < 0 || row >= this.visibleItemCount)
|
|
255
|
+
return null;
|
|
256
|
+
return this.scrollOffset + row;
|
|
257
|
+
}
|
|
258
|
+
handleMouseMove(event) {
|
|
259
|
+
const index = this.rowAt(event.localY);
|
|
260
|
+
if (index === null || index === this.selectedIndexValue)
|
|
261
|
+
return;
|
|
262
|
+
this.selectedIndexValue = index;
|
|
263
|
+
this.markDirty();
|
|
264
|
+
this.fireSelectionChanged();
|
|
265
|
+
}
|
|
266
|
+
handleClick(event) {
|
|
267
|
+
const index = this.rowAt(event.localY);
|
|
268
|
+
if (index === null)
|
|
269
|
+
return;
|
|
270
|
+
this.selectedIndexValue = index;
|
|
271
|
+
this.markDirty();
|
|
272
|
+
this.onAccept?.(this.filteredItems[index]);
|
|
273
|
+
}
|
|
274
|
+
moveSelection(delta) {
|
|
275
|
+
if (this.filteredItems.length === 0)
|
|
276
|
+
return;
|
|
277
|
+
const next = Math.max(0, Math.min(this.filteredItems.length - 1, this.selectedIndexValue + delta));
|
|
278
|
+
if (next === this.selectedIndexValue)
|
|
279
|
+
return;
|
|
280
|
+
this.selectedIndexValue = next;
|
|
281
|
+
this.ensureVisible(next);
|
|
282
|
+
this.markDirty();
|
|
283
|
+
this.fireSelectionChanged();
|
|
284
|
+
}
|
|
285
|
+
fireSelectionChanged() {
|
|
286
|
+
this.onSelectionChanged?.(this.getSelectedItem());
|
|
287
|
+
}
|
|
288
|
+
ensureVisible(index) {
|
|
289
|
+
if (index < this.scrollOffset) {
|
|
290
|
+
this.scrollOffset = index;
|
|
291
|
+
}
|
|
292
|
+
else if (index >= this.scrollOffset + this.visibleItemCount) {
|
|
293
|
+
this.scrollOffset = index - this.visibleItemCount + 1;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Текст, по которому фильтруем: `filterText` источника, иначе label (правый
|
|
299
|
+
* `detail` в фильтрации не участвует).
|
|
300
|
+
*/
|
|
301
|
+
function matchText(item) {
|
|
302
|
+
return item.filterText ?? item.label;
|
|
303
|
+
}
|
|
304
|
+
/** 0 — совпадение с начала, 1 — где-то внутри (при пустом фильтре все равны). */
|
|
305
|
+
function matchRank(item, needle) {
|
|
306
|
+
if (needle === "")
|
|
307
|
+
return 0;
|
|
308
|
+
return matchText(item).toLowerCase().startsWith(needle) ? 0 : 1;
|
|
309
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { BoxConstraints, Size } from "@tuidom/core/common/geometryPromitives";
|
|
2
|
+
import { TUIElement } from "@tuidom/core/dom/tuiElement";
|
|
3
|
+
import { CompletionDetailsElement } from "./completionDetailsElement.js";
|
|
4
|
+
import { CompletionListElement } from "./completionListElement.js";
|
|
5
|
+
/** С какой стороны от списка стоит панель описания. */
|
|
6
|
+
export type CompletionDetailsSide = "right" | "left";
|
|
7
|
+
/**
|
|
8
|
+
* Виджет автодополнения целиком: список пунктов и (опционально) панель описания
|
|
9
|
+
* рядом. Именно он живёт в overlay-сессии — оверлей клампит и переворачивает
|
|
10
|
+
* ОДИН элемент, поэтому список с панелью обязаны быть одним элементом, иначе у
|
|
11
|
+
* края экрана они разъезжались бы поодиночке.
|
|
12
|
+
*
|
|
13
|
+
* Панель по умолчанию скрыта (как в VS Code) и включается тумблером
|
|
14
|
+
* `toggleSuggestionDetails`; сторону выбирает владелец
|
|
15
|
+
* ({@link import("../../../src/vs/workbench/contrib/suggest/browser/suggestComponent.js").SuggestComponent})
|
|
16
|
+
* по свободному месту справа от каретки.
|
|
17
|
+
*/
|
|
18
|
+
export declare class CompletionWidgetElement extends TUIElement {
|
|
19
|
+
readonly list: CompletionListElement;
|
|
20
|
+
readonly details: CompletionDetailsElement;
|
|
21
|
+
private detailsVisibleValue;
|
|
22
|
+
private detailsSideValue;
|
|
23
|
+
constructor();
|
|
24
|
+
/** Включён ли показ панели описания (состояние тумблера). */
|
|
25
|
+
get detailsVisible(): boolean;
|
|
26
|
+
set detailsVisible(value: boolean);
|
|
27
|
+
get detailsSide(): CompletionDetailsSide;
|
|
28
|
+
set detailsSide(value: CompletionDetailsSide);
|
|
29
|
+
/** Панель реально показывается: тумблер включён И описание непустое. */
|
|
30
|
+
get showsDetails(): boolean;
|
|
31
|
+
private get listWidth();
|
|
32
|
+
private get listHeight();
|
|
33
|
+
private get detailsWidth();
|
|
34
|
+
private get widgetWidth();
|
|
35
|
+
private get widgetHeight();
|
|
36
|
+
getMinIntrinsicWidth(_height: number): number;
|
|
37
|
+
getMaxIntrinsicWidth(_height: number): number;
|
|
38
|
+
getMinIntrinsicHeight(_width: number): number;
|
|
39
|
+
getMaxIntrinsicHeight(_width: number): number;
|
|
40
|
+
protected performLayout(constraints: BoxConstraints): Size;
|
|
41
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { BoxConstraints, Size } from "@tuidom/core/common/geometryPromitives";
|
|
2
|
+
import { TUIElement } from "@tuidom/core/dom/tuiElement";
|
|
3
|
+
import { CompletionDetailsElement } from "./completionDetailsElement.js";
|
|
4
|
+
import { CompletionListElement } from "./completionListElement.js";
|
|
5
|
+
/**
|
|
6
|
+
* Виджет автодополнения целиком: список пунктов и (опционально) панель описания
|
|
7
|
+
* рядом. Именно он живёт в overlay-сессии — оверлей клампит и переворачивает
|
|
8
|
+
* ОДИН элемент, поэтому список с панелью обязаны быть одним элементом, иначе у
|
|
9
|
+
* края экрана они разъезжались бы поодиночке.
|
|
10
|
+
*
|
|
11
|
+
* Панель по умолчанию скрыта (как в VS Code) и включается тумблером
|
|
12
|
+
* `toggleSuggestionDetails`; сторону выбирает владелец
|
|
13
|
+
* ({@link import("../../../src/vs/workbench/contrib/suggest/browser/suggestComponent.ts").SuggestComponent})
|
|
14
|
+
* по свободному месту справа от каретки.
|
|
15
|
+
*/
|
|
16
|
+
export class CompletionWidgetElement extends TUIElement {
|
|
17
|
+
list;
|
|
18
|
+
details;
|
|
19
|
+
detailsVisibleValue = false;
|
|
20
|
+
detailsSideValue = "right";
|
|
21
|
+
constructor() {
|
|
22
|
+
super();
|
|
23
|
+
// Как и список: фокус остаётся у редактора (см. CompletionListElement).
|
|
24
|
+
this.focusable = false;
|
|
25
|
+
this.list = new CompletionListElement();
|
|
26
|
+
this.details = new CompletionDetailsElement();
|
|
27
|
+
this.appendChild(this.list);
|
|
28
|
+
this.appendChild(this.details);
|
|
29
|
+
}
|
|
30
|
+
/** Включён ли показ панели описания (состояние тумблера). */
|
|
31
|
+
get detailsVisible() {
|
|
32
|
+
return this.detailsVisibleValue;
|
|
33
|
+
}
|
|
34
|
+
set detailsVisible(value) {
|
|
35
|
+
if (this.detailsVisibleValue === value)
|
|
36
|
+
return;
|
|
37
|
+
this.detailsVisibleValue = value;
|
|
38
|
+
this.markDirty();
|
|
39
|
+
}
|
|
40
|
+
get detailsSide() {
|
|
41
|
+
return this.detailsSideValue;
|
|
42
|
+
}
|
|
43
|
+
set detailsSide(value) {
|
|
44
|
+
if (this.detailsSideValue === value)
|
|
45
|
+
return;
|
|
46
|
+
this.detailsSideValue = value;
|
|
47
|
+
this.markDirty();
|
|
48
|
+
}
|
|
49
|
+
/** Панель реально показывается: тумблер включён И описание непустое. */
|
|
50
|
+
get showsDetails() {
|
|
51
|
+
return this.detailsVisibleValue && !this.details.isEmpty;
|
|
52
|
+
}
|
|
53
|
+
// ─── Sizing ──────────────────────────────────────────────────────────────
|
|
54
|
+
get listWidth() {
|
|
55
|
+
return this.list.getMaxIntrinsicWidth(0);
|
|
56
|
+
}
|
|
57
|
+
get listHeight() {
|
|
58
|
+
return this.list.getMaxIntrinsicHeight(0);
|
|
59
|
+
}
|
|
60
|
+
get detailsWidth() {
|
|
61
|
+
if (!this.showsDetails)
|
|
62
|
+
return 0;
|
|
63
|
+
// Панель не выше списка: попап у каретки и так близко к краю экрана.
|
|
64
|
+
this.details.maxHeight = this.listHeight;
|
|
65
|
+
return this.details.getMaxIntrinsicWidth(0);
|
|
66
|
+
}
|
|
67
|
+
get widgetWidth() {
|
|
68
|
+
return this.listWidth + this.detailsWidth;
|
|
69
|
+
}
|
|
70
|
+
get widgetHeight() {
|
|
71
|
+
if (!this.showsDetails)
|
|
72
|
+
return this.listHeight;
|
|
73
|
+
this.details.maxHeight = this.listHeight;
|
|
74
|
+
return Math.max(this.listHeight, this.details.getMaxIntrinsicHeight(0));
|
|
75
|
+
}
|
|
76
|
+
getMinIntrinsicWidth(_height) {
|
|
77
|
+
return this.widgetWidth;
|
|
78
|
+
}
|
|
79
|
+
getMaxIntrinsicWidth(_height) {
|
|
80
|
+
return this.widgetWidth;
|
|
81
|
+
}
|
|
82
|
+
getMinIntrinsicHeight(_width) {
|
|
83
|
+
return this.widgetHeight;
|
|
84
|
+
}
|
|
85
|
+
getMaxIntrinsicHeight(_width) {
|
|
86
|
+
return this.widgetHeight;
|
|
87
|
+
}
|
|
88
|
+
performLayout(constraints) {
|
|
89
|
+
const size = constraints.constrain(new Size(this.widgetWidth, this.widgetHeight));
|
|
90
|
+
super.performLayout(BoxConstraints.tight(size));
|
|
91
|
+
const listWidth = Math.min(this.listWidth, size.width);
|
|
92
|
+
const listHeight = Math.min(this.listHeight, size.height);
|
|
93
|
+
const detailsWidth = Math.max(0, Math.min(this.detailsWidth, size.width - listWidth));
|
|
94
|
+
const detailsHeight = detailsWidth === 0 ? 0 : Math.min(this.details.getMaxIntrinsicHeight(0), size.height);
|
|
95
|
+
const detailsFirst = this.detailsSideValue === "left" && detailsWidth > 0;
|
|
96
|
+
const listX = detailsFirst ? detailsWidth : 0;
|
|
97
|
+
const detailsX = detailsFirst ? 0 : listWidth;
|
|
98
|
+
this.layoutChild(this.list, listX, 0, BoxConstraints.tight(new Size(listWidth, listHeight)));
|
|
99
|
+
this.layoutChild(this.details, detailsX, 0, BoxConstraints.tight(new Size(detailsWidth, detailsHeight)));
|
|
100
|
+
return size;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { OverlayAnchorPosition } from "@tuidom/core/dom/overlayLayer";
|
|
2
|
+
import type { TUIElement } from "@tuidom/core/dom/tuiElement";
|
|
3
|
+
import type { MenuEntry } from "../menu/popupMenuElement.js";
|
|
4
|
+
export interface ContextMenuShowRequest {
|
|
5
|
+
/** Владелец меню — по нему ищется ближайший overlay-слой. */
|
|
6
|
+
owner: TUIElement;
|
|
7
|
+
anchor: OverlayAnchorPosition;
|
|
8
|
+
entries: MenuEntry[];
|
|
9
|
+
onHide?: () => void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Механика показа контекстного меню: PopupMenuElement в overlay-сессии с
|
|
13
|
+
* каноническим поведением — флип у краёв экрана, Escape и клик мимо закрывают,
|
|
14
|
+
* фокус после закрытия возвращается прежнему владельцу, выбор пункта сперва
|
|
15
|
+
* закрывает меню. Владеет единственной активной сессией: показ нового меню
|
|
16
|
+
* закрывает предыдущее. Состав пунктов — не его дело (его собирает вызывающий).
|
|
17
|
+
*/
|
|
18
|
+
export declare class ContextMenuController {
|
|
19
|
+
private session;
|
|
20
|
+
show(request: ContextMenuShowRequest): void;
|
|
21
|
+
hide(): void;
|
|
22
|
+
isOpen(): boolean;
|
|
23
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { PopupMenuElement } from "../menu/popupMenuElement.js";
|
|
2
|
+
/**
|
|
3
|
+
* Механика показа контекстного меню: PopupMenuElement в overlay-сессии с
|
|
4
|
+
* каноническим поведением — флип у краёв экрана, Escape и клик мимо закрывают,
|
|
5
|
+
* фокус после закрытия возвращается прежнему владельцу, выбор пункта сперва
|
|
6
|
+
* закрывает меню. Владеет единственной активной сессией: показ нового меню
|
|
7
|
+
* закрывает предыдущее. Состав пунктов — не его дело (его собирает вызывающий).
|
|
8
|
+
*/
|
|
9
|
+
export class ContextMenuController {
|
|
10
|
+
session = null;
|
|
11
|
+
show(request) {
|
|
12
|
+
this.hide();
|
|
13
|
+
const layer = request.owner.getOverlayLayer();
|
|
14
|
+
if (!layer || request.entries.length === 0)
|
|
15
|
+
return;
|
|
16
|
+
// Выбор пункта закрывает меню ДО действия: команда может открыть
|
|
17
|
+
// диалог/переставить фокус, и restoreFocus сессии не должен затирать
|
|
18
|
+
// фокус, выставленный командой. Подменю закрывают цепочку сами.
|
|
19
|
+
const entries = request.entries.map((entry) => {
|
|
20
|
+
if (entry.type === "separator" || entry.type === "submenu")
|
|
21
|
+
return entry;
|
|
22
|
+
const original = entry.onSelect;
|
|
23
|
+
return {
|
|
24
|
+
...entry,
|
|
25
|
+
onSelect: () => {
|
|
26
|
+
this.hide();
|
|
27
|
+
original?.();
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
const menu = new PopupMenuElement(entries);
|
|
32
|
+
menu.focusable = true;
|
|
33
|
+
let session = null;
|
|
34
|
+
session = layer.openPopupSession(menu, request.anchor, {
|
|
35
|
+
visible: true,
|
|
36
|
+
restoreFocus: true,
|
|
37
|
+
focusOnOpen: true,
|
|
38
|
+
closeOnEscape: true,
|
|
39
|
+
pointerPolicy: "close-on-outside",
|
|
40
|
+
disposeOnClose: true,
|
|
41
|
+
// Клик по вложенному подменю (отдельный item слоя) — не «мимо».
|
|
42
|
+
ownsTarget: (target) => menu.ownsElement(target),
|
|
43
|
+
// Escape при открытом подменю закрывает уровень, а не всё меню, —
|
|
44
|
+
// роутинг делает само меню (performDefaultAction).
|
|
45
|
+
shouldCloseOnEscape: () => !menu.hasOpenSubmenu(),
|
|
46
|
+
onClose: () => {
|
|
47
|
+
// Сессия закрыта (Escape, клик мимо, hide) — каскадно закрываем
|
|
48
|
+
// открытые подменю: их сессии слой сам не увяжет.
|
|
49
|
+
menu.closeSubmenus();
|
|
50
|
+
// Через hide() поле занулено до close() — не трогаем: там может
|
|
51
|
+
// быть уже открыта следующая сессия.
|
|
52
|
+
if (this.session === session) {
|
|
53
|
+
this.session = null;
|
|
54
|
+
}
|
|
55
|
+
request.onHide?.();
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
menu.onClose = () => {
|
|
59
|
+
session.close();
|
|
60
|
+
};
|
|
61
|
+
this.session = session;
|
|
62
|
+
}
|
|
63
|
+
hide() {
|
|
64
|
+
if (!this.session)
|
|
65
|
+
return;
|
|
66
|
+
const session = this.session;
|
|
67
|
+
this.session = null;
|
|
68
|
+
// Именно close(), не dispose(): close восстанавливает сохранённый фокус
|
|
69
|
+
// (restoreFocus), а disposeOnClose доводит teardown до конца.
|
|
70
|
+
session.close();
|
|
71
|
+
}
|
|
72
|
+
isOpen() {
|
|
73
|
+
return this.session !== null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { BoxConstraints, Size } from "@tuidom/core/common/geometryPromitives";
|
|
2
|
+
import { OverlayLayer } from "@tuidom/core/dom/overlayLayer";
|
|
3
|
+
import { TUIElement } from "@tuidom/core/dom/tuiElement";
|
|
4
|
+
/**
|
|
5
|
+
* Контейнер «контент + локальный overlay-слой поверх»: оба ребёнка кладутся
|
|
6
|
+
* tight на весь размер хоста, слой — последним (z-порядок = порядок детей,
|
|
7
|
+
* значит хит-тест сначала видит оверлей).
|
|
8
|
+
*
|
|
9
|
+
* Слой хоста — только для адресно докнутых виджетов (find и т.п.), которые
|
|
10
|
+
* приходят к нему напрямую через {@link overlayLayer}. getOverlayLayer
|
|
11
|
+
* сознательно НЕ переопределён: попапы/контекстные меню из содержимого хоста
|
|
12
|
+
* должны жить в глобальном слое BodyElement (позиции слоя — в локальных
|
|
13
|
+
* координатах хоста, а слой вдобавок клипует к его границам).
|
|
14
|
+
*/
|
|
15
|
+
export declare class OverlayHostElement extends TUIElement {
|
|
16
|
+
readonly overlayLayer: OverlayLayer;
|
|
17
|
+
private content;
|
|
18
|
+
constructor();
|
|
19
|
+
getContent(): TUIElement | null;
|
|
20
|
+
setContent(element: TUIElement | null): void;
|
|
21
|
+
private syncChildren;
|
|
22
|
+
protected performLayout(constraints: BoxConstraints): Size;
|
|
23
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { BoxConstraints, Size } from "@tuidom/core/common/geometryPromitives";
|
|
2
|
+
import { OverlayLayer } from "@tuidom/core/dom/overlayLayer";
|
|
3
|
+
import { TUIElement } from "@tuidom/core/dom/tuiElement";
|
|
4
|
+
/**
|
|
5
|
+
* Контейнер «контент + локальный overlay-слой поверх»: оба ребёнка кладутся
|
|
6
|
+
* tight на весь размер хоста, слой — последним (z-порядок = порядок детей,
|
|
7
|
+
* значит хит-тест сначала видит оверлей).
|
|
8
|
+
*
|
|
9
|
+
* Слой хоста — только для адресно докнутых виджетов (find и т.п.), которые
|
|
10
|
+
* приходят к нему напрямую через {@link overlayLayer}. getOverlayLayer
|
|
11
|
+
* сознательно НЕ переопределён: попапы/контекстные меню из содержимого хоста
|
|
12
|
+
* должны жить в глобальном слое BodyElement (позиции слоя — в локальных
|
|
13
|
+
* координатах хоста, а слой вдобавок клипует к его границам).
|
|
14
|
+
*/
|
|
15
|
+
export class OverlayHostElement extends TUIElement {
|
|
16
|
+
overlayLayer;
|
|
17
|
+
content = null;
|
|
18
|
+
constructor() {
|
|
19
|
+
super();
|
|
20
|
+
this.overlayLayer = new OverlayLayer();
|
|
21
|
+
this.syncChildren();
|
|
22
|
+
}
|
|
23
|
+
getContent() {
|
|
24
|
+
return this.content;
|
|
25
|
+
}
|
|
26
|
+
setContent(element) {
|
|
27
|
+
this.content = element;
|
|
28
|
+
this.syncChildren();
|
|
29
|
+
this.markDirty();
|
|
30
|
+
}
|
|
31
|
+
syncChildren() {
|
|
32
|
+
const children = [];
|
|
33
|
+
if (this.content)
|
|
34
|
+
children.push(this.content);
|
|
35
|
+
children.push(this.overlayLayer);
|
|
36
|
+
this.setChildren(children);
|
|
37
|
+
}
|
|
38
|
+
performLayout(constraints) {
|
|
39
|
+
const containerSize = super.performLayout(constraints);
|
|
40
|
+
if (this.content) {
|
|
41
|
+
this.layoutChild(this.content, 0, 0, BoxConstraints.tight(containerSize));
|
|
42
|
+
}
|
|
43
|
+
// Слой покрывает весь хост; позиции item'ов — относительно его top-left.
|
|
44
|
+
this.layoutChild(this.overlayLayer, 0, 0, BoxConstraints.tight(containerSize));
|
|
45
|
+
return containerSize;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { BoxConstraints, Size } from "@tuidom/core/common/geometryPromitives";
|
|
2
|
+
import { RenderContext, TUIElement } from "@tuidom/core/dom/tuiElement";
|
|
3
|
+
export declare class EditorTabItemElement extends TUIElement {
|
|
4
|
+
private label;
|
|
5
|
+
private icon;
|
|
6
|
+
private iconColor;
|
|
7
|
+
private modified;
|
|
8
|
+
private readOnly;
|
|
9
|
+
private paddingLeft;
|
|
10
|
+
private paddingRight;
|
|
11
|
+
private hovered;
|
|
12
|
+
onActivate: (() => void) | null;
|
|
13
|
+
onClose: (() => void) | null;
|
|
14
|
+
constructor(label: string, icon: string, iconColor: number, options?: {
|
|
15
|
+
modified?: boolean;
|
|
16
|
+
readOnly?: boolean;
|
|
17
|
+
paddingLeft?: number;
|
|
18
|
+
paddingRight?: number;
|
|
19
|
+
});
|
|
20
|
+
getLabel(): string;
|
|
21
|
+
setLabel(label: string): void;
|
|
22
|
+
getIcon(): string;
|
|
23
|
+
setIcon(icon: string, color: number): void;
|
|
24
|
+
getModified(): boolean;
|
|
25
|
+
setModified(modified: boolean): void;
|
|
26
|
+
getReadOnly(): boolean;
|
|
27
|
+
/** Показывает/прячет метку-замок перед именем файла (VS Code read-only tab). */
|
|
28
|
+
setReadOnly(readOnly: boolean): void;
|
|
29
|
+
getPaddingLeft(): number;
|
|
30
|
+
setPaddingLeft(value: number): void;
|
|
31
|
+
getPaddingRight(): number;
|
|
32
|
+
setPaddingRight(value: number): void;
|
|
33
|
+
private getContentWidth;
|
|
34
|
+
private getCloseButtonStart;
|
|
35
|
+
getMinIntrinsicWidth(_height: number): number;
|
|
36
|
+
getMaxIntrinsicWidth(_height: number): number;
|
|
37
|
+
getMinIntrinsicHeight(_width: number): number;
|
|
38
|
+
getMaxIntrinsicHeight(_width: number): number;
|
|
39
|
+
protected performLayout(constraints: BoxConstraints): Size;
|
|
40
|
+
render(context: RenderContext): void;
|
|
41
|
+
}
|