@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.
Files changed (89) hide show
  1. package/dist/body/bodyElement.d.ts +33 -0
  2. package/dist/body/bodyElement.js +87 -0
  3. package/dist/button/buttonElement.d.ts +18 -0
  4. package/dist/button/buttonElement.js +68 -0
  5. package/dist/completionlist/completionDetailsElement.d.ts +49 -0
  6. package/dist/completionlist/completionDetailsElement.js +165 -0
  7. package/dist/completionlist/completionItemKindIcon.d.ts +2 -0
  8. package/dist/completionlist/completionItemKindIcon.js +43 -0
  9. package/dist/completionlist/completionListElement.d.ts +102 -0
  10. package/dist/completionlist/completionListElement.js +309 -0
  11. package/dist/completionlist/completionWidgetElement.d.ts +41 -0
  12. package/dist/completionlist/completionWidgetElement.js +102 -0
  13. package/dist/contextview/contextMenuController.d.ts +23 -0
  14. package/dist/contextview/contextMenuController.js +75 -0
  15. package/dist/contextview/overlayHostElement.d.ts +23 -0
  16. package/dist/contextview/overlayHostElement.js +47 -0
  17. package/dist/editorgroup/editorTabItemElement.d.ts +41 -0
  18. package/dist/editorgroup/editorTabItemElement.js +186 -0
  19. package/dist/editorgroup/editorTabStripElement.d.ts +33 -0
  20. package/dist/editorgroup/editorTabStripElement.js +118 -0
  21. package/dist/editorpart/editorPartElement.d.ts +95 -0
  22. package/dist/editorpart/editorPartElement.js +285 -0
  23. package/dist/inputbox/inputElement.d.ts +34 -0
  24. package/dist/inputbox/inputElement.js +207 -0
  25. package/dist/inputbox/inputState.d.ts +69 -0
  26. package/dist/inputbox/inputState.js +326 -0
  27. package/dist/layout/boxContainerElement.d.ts +27 -0
  28. package/dist/layout/boxContainerElement.js +121 -0
  29. package/dist/layout/boxElement.d.ts +4 -0
  30. package/dist/layout/boxElement.js +30 -0
  31. package/dist/layout/fillerElement.d.ts +12 -0
  32. package/dist/layout/fillerElement.js +16 -0
  33. package/dist/layout/fitContentElement.d.ts +18 -0
  34. package/dist/layout/fitContentElement.js +46 -0
  35. package/dist/layout/hFlexElement.d.ts +26 -0
  36. package/dist/layout/hFlexElement.js +134 -0
  37. package/dist/layout/paddingContainerElement.d.ts +30 -0
  38. package/dist/layout/paddingContainerElement.js +88 -0
  39. package/dist/layout/sizedBoxElement.d.ts +30 -0
  40. package/dist/layout/sizedBoxElement.js +74 -0
  41. package/dist/layout/vFlexElement.d.ts +32 -0
  42. package/dist/layout/vFlexElement.js +136 -0
  43. package/dist/layout/vStackElement.d.ts +18 -0
  44. package/dist/layout/vStackElement.js +74 -0
  45. package/dist/list/listViewElement.d.ts +232 -0
  46. package/dist/list/listViewElement.js +944 -0
  47. package/dist/menu/menuBarElement.d.ts +47 -0
  48. package/dist/menu/menuBarElement.js +285 -0
  49. package/dist/menu/menuBarItemElement.d.ts +20 -0
  50. package/dist/menu/menuBarItemElement.js +68 -0
  51. package/dist/menu/popupMenuElement.d.ts +81 -0
  52. package/dist/menu/popupMenuElement.js +318 -0
  53. package/dist/menu/popupMenuItemElement.d.ts +24 -0
  54. package/dist/menu/popupMenuItemElement.js +89 -0
  55. package/dist/panel/panelContainerElement.d.ts +64 -0
  56. package/dist/panel/panelContainerElement.js +219 -0
  57. package/dist/quickpick/quickPickElement.d.ts +161 -0
  58. package/dist/quickpick/quickPickElement.js +561 -0
  59. package/dist/sash/sashElement.d.ts +30 -0
  60. package/dist/sash/sashElement.js +78 -0
  61. package/dist/scrollbar/iScrollable.d.ts +8 -0
  62. package/dist/scrollbar/iScrollable.js +1 -0
  63. package/dist/scrollbar/scrollBarRenderer.d.ts +36 -0
  64. package/dist/scrollbar/scrollBarRenderer.js +76 -0
  65. package/dist/scrollbar/scrollContainerElement.d.ts +39 -0
  66. package/dist/scrollbar/scrollContainerElement.js +91 -0
  67. package/dist/scrollbar/scrollViewport.d.ts +28 -0
  68. package/dist/scrollbar/scrollViewport.js +65 -0
  69. package/dist/scrollbar/scrollableElement.d.ts +22 -0
  70. package/dist/scrollbar/scrollableElement.js +42 -0
  71. package/dist/selectbox/selectBoxElement.d.ts +48 -0
  72. package/dist/selectbox/selectBoxElement.js +161 -0
  73. package/dist/terminal/encodeKeyForPty.d.ts +3 -0
  74. package/dist/terminal/encodeKeyForPty.js +56 -0
  75. package/dist/terminal/terminalViewElement.d.ts +17 -0
  76. package/dist/terminal/terminalViewElement.js +172 -0
  77. package/dist/text/textBlockElement.d.ts +13 -0
  78. package/dist/text/textBlockElement.js +35 -0
  79. package/dist/text/textLabelElement.d.ts +28 -0
  80. package/dist/text/textLabelElement.js +77 -0
  81. package/dist/titledpanel/titledPanelElement.d.ts +14 -0
  82. package/dist/titledpanel/titledPanelElement.js +40 -0
  83. package/dist/tree/iTreeDataProvider.d.ts +19 -0
  84. package/dist/tree/iTreeDataProvider.js +1 -0
  85. package/dist/tree/treeViewElement.d.ts +116 -0
  86. package/dist/tree/treeViewElement.js +748 -0
  87. package/dist/workbenchlayout/workbenchLayoutElement.d.ts +60 -0
  88. package/dist/workbenchlayout/workbenchLayoutElement.js +203 -0
  89. package/package.json +33 -0
@@ -0,0 +1,748 @@
1
+ import { DisplayLine } from "@tuidom/core/common/displayLine";
2
+ import { Point } from "@tuidom/core/common/geometryPromitives";
3
+ import { ScrollableElement } from "../scrollbar/scrollableElement.js";
4
+ const INDENT_SIZE = 2;
5
+ // Окно, в течение которого напечатанные символы складываются в одну последовательность
6
+ // быстрого поиска; после паузы буфер сбрасывается (как в VS Code / файловых менеджерах).
7
+ const TYPEAHEAD_TIMEOUT_MS = 800;
8
+ const ICON_EXPANDED = "\uF107"; //  nf-fa-angle_down — chevron, как в nvim-tree/NvChad
9
+ const ICON_COLLAPSED = "\uF105"; //  nf-fa-angle_right
10
+ const SYMLINK_BADGE = "\u21B5"; // enter-like arrow marking a symlink, pinned to the right edge
11
+ export class TreeViewElement extends ScrollableElement {
12
+ provider;
13
+ expandedKeys = new Set();
14
+ childrenCache = new Map();
15
+ flatNodes = [];
16
+ selectedIndex = 0;
17
+ selectionAnchor = 0;
18
+ hoveredIndex = null;
19
+ selectedKeys = new Set();
20
+ cutKeys = new Set();
21
+ maxRowWidth = 0;
22
+ /**
23
+ * Отступ контента строк от левого края элемента. В отличие от внешнего
24
+ * паддинг-контейнера, входит в саму строку, поэтому фон курсора/выделения
25
+ * заливает строку от края, а текст начинается со сдвигом.
26
+ */
27
+ leftPadding;
28
+ typeaheadBuffer = "";
29
+ typeaheadTimer = null;
30
+ onSelect = null;
31
+ onActivate = null;
32
+ onExpandedChanged = null;
33
+ onContextMenu = null;
34
+ constructor(provider, options) {
35
+ super();
36
+ this.focusable = true;
37
+ this.leftPadding = options?.leftPadding ?? 0;
38
+ this.provider = provider;
39
+ this.provider.onChange = (element) => {
40
+ void this.refresh(element);
41
+ };
42
+ }
43
+ get contentHeight() {
44
+ return this.flatNodes.length;
45
+ }
46
+ get contentWidth() {
47
+ return this.maxRowWidth;
48
+ }
49
+ async refresh(element) {
50
+ const selectedKey = this.getSelectedKey();
51
+ const previousIndex = this.selectedIndex;
52
+ if (element !== undefined) {
53
+ const key = this.provider.getKey(element);
54
+ this.invalidateSubtreeCache(key);
55
+ await this.reloadExpandedChildren(key);
56
+ }
57
+ else {
58
+ this.childrenCache.clear();
59
+ await this.loadRootChildren();
60
+ }
61
+ this.rebuildFlatList();
62
+ this.restoreSelection(selectedKey, previousIndex);
63
+ this.markDirty();
64
+ }
65
+ async toggleExpand(element) {
66
+ const key = this.provider.getKey(element);
67
+ if (this.expandedKeys.has(key)) {
68
+ this.expandedKeys.delete(key);
69
+ this.onExpandedChanged?.(element, false);
70
+ }
71
+ else {
72
+ if (!this.childrenCache.has(key)) {
73
+ const children = await this.provider.getChildren(element);
74
+ this.childrenCache.set(key, children);
75
+ }
76
+ this.expandedKeys.add(key);
77
+ this.onExpandedChanged?.(element, true);
78
+ }
79
+ this.rebuildFlatList();
80
+ this.markDirty();
81
+ }
82
+ /** Раскрывает узел (идемпотентно, без прокрутки/выделения) и перестраивает список. */
83
+ async expand(element) {
84
+ if (this.expandedKeys.has(this.provider.getKey(element)))
85
+ return;
86
+ await this.expandElement(element);
87
+ this.rebuildFlatList();
88
+ this.markDirty();
89
+ }
90
+ /** Раскрывает узел (загружая детей в кеш), если он ещё не раскрыт. No-op для уже раскрытого. */
91
+ async expandElement(element) {
92
+ const key = this.provider.getKey(element);
93
+ if (this.expandedKeys.has(key))
94
+ return;
95
+ if (!this.childrenCache.has(key)) {
96
+ const children = await this.provider.getChildren(element);
97
+ this.childrenCache.set(key, children);
98
+ }
99
+ this.expandedKeys.add(key);
100
+ this.onExpandedChanged?.(element, true);
101
+ }
102
+ /**
103
+ * Раскрывает цепочку предков и ставит курсор на целевой узел, проматывая к нему.
104
+ * `chain` — путь от корня к цели включительно: все элементы кроме последнего
105
+ * трактуются как предки-контейнеры и раскрываются, последний — цель выделения.
106
+ * Если цель не находится в дереве после раскрытия (например, путь вне корня),
107
+ * выделение не меняется.
108
+ */
109
+ async reveal(chain) {
110
+ if (chain.length === 0)
111
+ return;
112
+ for (let i = 0; i < chain.length - 1; i++) {
113
+ await this.expandElement(chain[i]);
114
+ }
115
+ this.rebuildFlatList();
116
+ const targetKey = this.provider.getKey(chain[chain.length - 1]);
117
+ const index = this.flatNodes.findIndex((node) => this.provider.getKey(node.element) === targetKey);
118
+ if (index >= 0) {
119
+ this.setSelectedIndex(index);
120
+ }
121
+ this.markDirty();
122
+ }
123
+ setCutKeys(keys) {
124
+ this.cutKeys = keys;
125
+ this.markDirty();
126
+ }
127
+ clearCutKeys() {
128
+ this.cutKeys.clear();
129
+ this.markDirty();
130
+ }
131
+ /** Узел под курсором, либо null если дерево пусто. */
132
+ getSelectedNode() {
133
+ if (this.selectedIndex >= 0 && this.selectedIndex < this.flatNodes.length) {
134
+ return this.flatNodes[this.selectedIndex].element;
135
+ }
136
+ return null;
137
+ }
138
+ /**
139
+ * Глобальные экранные координаты выделенной строки — якорь для контекстного меню,
140
+ * открытого с клавиатуры (Shift+F10). X сдвинут на отступ по глубине узла, чтобы
141
+ * меню появлялось у имени, а не у левого края панели. Возвращает `null`, если
142
+ * дерево пусто.
143
+ */
144
+ getSelectedRowGlobalPosition() {
145
+ if (this.selectedIndex < 0 || this.selectedIndex >= this.flatNodes.length)
146
+ return null;
147
+ const indentX = this.rowIndentX(this.flatNodes[this.selectedIndex].depth);
148
+ return new Point(this.globalPosition.x + indentX - this.scrollLeft, this.globalPosition.y + (this.selectedIndex - this.scrollTop));
149
+ }
150
+ /**
151
+ * Все выбранные узлы. Если множественный выбор пуст, возвращает узел под курсором
152
+ * (в виде массива из одного элемента). Порядок — как в дереве.
153
+ */
154
+ getSelectedNodes() {
155
+ if (this.selectedKeys.size === 0) {
156
+ const node = this.getSelectedNode();
157
+ return node ? [node] : [];
158
+ }
159
+ const result = [];
160
+ for (const node of this.flatNodes) {
161
+ if (this.selectedKeys.has(this.provider.getKey(node.element))) {
162
+ result.push(node.element);
163
+ }
164
+ }
165
+ return result;
166
+ }
167
+ performDefaultAction(event) {
168
+ if (event.type === "keypress") {
169
+ this.handleKeypress(event);
170
+ }
171
+ else if (event.type === "click") {
172
+ this.handleClick(event);
173
+ }
174
+ else if (event.type === "contextmenu") {
175
+ this.handleContextMenu(event);
176
+ }
177
+ else if (event.type === "dblclick") {
178
+ this.handleDblClick(event);
179
+ }
180
+ else if (event.type === "wheel") {
181
+ this.handleWheel(event);
182
+ }
183
+ else if (event.type === "mousemove") {
184
+ this.handleMouseMove(event);
185
+ }
186
+ else if (event.type === "mouseleave") {
187
+ this.handleMouseLeave();
188
+ }
189
+ else {
190
+ super.performDefaultAction(event);
191
+ }
192
+ }
193
+ renderViewport(context, viewport) {
194
+ const { scrollTop, scrollLeft, viewportWidth, viewportHeight } = viewport;
195
+ const resolved = this.resolvedStyle;
196
+ const focused = this.isFocused;
197
+ for (let screenY = 0; screenY < viewportHeight; screenY++) {
198
+ const nodeIndex = scrollTop + screenY;
199
+ if (nodeIndex >= this.flatNodes.length) {
200
+ for (let x = 0; x < viewportWidth; x++) {
201
+ context.setCell(x, screenY, { char: " ", fg: resolved.fg, bg: resolved.bg });
202
+ }
203
+ continue;
204
+ }
205
+ const node = this.flatNodes[nodeIndex];
206
+ const nodeKey = this.provider.getKey(node.element);
207
+ const isCursor = nodeIndex === this.selectedIndex;
208
+ const isSelected = this.selectedKeys.has(nodeKey);
209
+ const isHovered = nodeIndex === this.hoveredIndex;
210
+ const isCut = this.cutKeys.has(nodeKey);
211
+ const { bg: rowBg, fg: rowFg } = this.resolveRowColors(resolved, focused, isCursor, isSelected, isHovered, isCut);
212
+ const rowText = this.formatRow(node);
213
+ const rowIcon = node.item.icon;
214
+ // Данные могут отдавать имена токенов (gitDecoration.*) — резолвим
215
+ // в контексте дерева: раскраска переживает смену темы без пере-пуша.
216
+ const rowIconColor = node.item.iconColor !== undefined ? this.resolveColor(node.item.iconColor) : undefined;
217
+ const labelColor = node.item.labelColor !== undefined ? this.resolveColor(node.item.labelColor) : undefined;
218
+ // Метка имени начинается сразу после иконки типа (если она есть).
219
+ const labelStart = this.rowIndentX(node.depth) + 2 + (rowIcon ? 2 : 0);
220
+ // Цвет-декорация имени уступает выделению/курсору, чтобы выбранная строка
221
+ // оставалась читаемой (тот же приоритет, что и у cutFg).
222
+ const labelColorActive = labelColor !== undefined && !(isCursor || isSelected);
223
+ const dl = new DisplayLine(rowText);
224
+ let screenX = 0;
225
+ let col = scrollLeft;
226
+ while (col < scrollLeft + viewportWidth) {
227
+ if (col >= dl.displayWidth) {
228
+ context.setCell(screenX, screenY, { char: " ", fg: resolved.fg, bg: rowBg });
229
+ col++;
230
+ screenX++;
231
+ continue;
232
+ }
233
+ const char = dl.charAtColumn(col);
234
+ if (char === "") {
235
+ col++;
236
+ screenX++;
237
+ continue;
238
+ }
239
+ const slot = dl.graphemeAtColumn(col);
240
+ /* v8 ignore start -- defensive: graphemeAtColumn is non-null whenever charAtColumn already returned a non-empty char */
241
+ const w = slot ? slot.displayWidth : 1;
242
+ /* v8 ignore stop */
243
+ let fg = rowFg;
244
+ // Override the name span with the git-status colour (deemphasised by
245
+ // selection above). Applies only to the label columns, so the type icon
246
+ // keeps its own colour.
247
+ if (labelColorActive && col >= labelStart) {
248
+ fg = labelColor;
249
+ }
250
+ // Color the icon character
251
+ const iconStart = this.rowIndentX(node.depth) + 2;
252
+ if (rowIcon && rowIconColor !== undefined && col === iconStart) {
253
+ fg = rowIconColor;
254
+ }
255
+ // Color the expand icon
256
+ const expandIconPos = this.rowIndentX(node.depth);
257
+ if (col === expandIconPos && node.item.collapsible) {
258
+ fg = this.styleVar("list.deemphasizedForeground");
259
+ }
260
+ if (w === 2 && screenX + 1 >= viewportWidth) {
261
+ context.setCell(screenX, screenY, { char: " ", fg, bg: rowBg, width: 1 });
262
+ col++;
263
+ screenX++;
264
+ }
265
+ else {
266
+ context.setCell(screenX, screenY, { char, fg, bg: rowBg, width: w });
267
+ col += w;
268
+ screenX += w;
269
+ }
270
+ }
271
+ // Правый край строки: буква-бейдж статуса (git) прижат к самому краю и
272
+ // имеет приоритет; стрелка симлинка, если есть, сдвигается влево, чтобы
273
+ // две метки не перетирали друг друга. Обе рисуются поверх крайних ячеек и
274
+ // не входят в ширину строки — иконки типов остаются на своих местах.
275
+ let symlinkX = viewportWidth - 1;
276
+ if (node.item.badge) {
277
+ const badgeChars = Array.from(node.item.badge);
278
+ const badgeFg = labelColor ?? rowFg;
279
+ const badgeStart = viewportWidth - badgeChars.length;
280
+ for (let i = 0; i < badgeChars.length; i++) {
281
+ const x = badgeStart + i;
282
+ if (x >= 0) {
283
+ context.setCell(x, screenY, { char: badgeChars[i], fg: badgeFg, bg: rowBg, width: 1 });
284
+ }
285
+ }
286
+ symlinkX = badgeStart - 1;
287
+ }
288
+ if (node.item.symlink && symlinkX >= 0) {
289
+ context.setCell(symlinkX, screenY, {
290
+ char: SYMLINK_BADGE,
291
+ fg: this.styleVar("list.deemphasizedForeground"),
292
+ bg: rowBg,
293
+ width: 1,
294
+ });
295
+ }
296
+ }
297
+ }
298
+ resolveRowColors(resolved, focused, isCursor, isSelected, isHovered, isCut) {
299
+ // Priority: cursor/selection > hover > cut > normal
300
+ if (isCursor || isSelected) {
301
+ if (focused) {
302
+ return {
303
+ bg: this.styleVar("list.activeSelectionBackground"),
304
+ fg: this.styleVar("list.activeSelectionForeground"),
305
+ };
306
+ }
307
+ return {
308
+ bg: this.styleVar("list.inactiveSelectionBackground"),
309
+ fg: this.styleVar("list.inactiveSelectionForeground"),
310
+ };
311
+ }
312
+ if (isHovered) {
313
+ return { bg: this.styleVar("list.hoverBackground"), fg: resolved.fg };
314
+ }
315
+ if (isCut) {
316
+ return { bg: resolved.bg, fg: this.styleVar("list.deemphasizedForeground") };
317
+ }
318
+ return { bg: resolved.bg, fg: resolved.fg };
319
+ }
320
+ // ─── Private: data loading ───
321
+ async loadRootChildren() {
322
+ const rootChildren = await this.provider.getChildren();
323
+ this.childrenCache.set("__root__", rootChildren);
324
+ for (const key of this.expandedKeys) {
325
+ await this.reloadExpandedChildren(key);
326
+ }
327
+ }
328
+ async reloadExpandedChildren(key) {
329
+ if (!this.expandedKeys.has(key))
330
+ return;
331
+ const node = this.findElementByKey(key);
332
+ /* v8 ignore start -- unreachable: findElementByKey always resolves an expanded key (see its own v8-ignore note) */
333
+ if (node === null)
334
+ return;
335
+ /* v8 ignore stop */
336
+ const children = await this.provider.getChildren(node);
337
+ this.childrenCache.set(key, children);
338
+ for (const child of children) {
339
+ const childKey = this.provider.getKey(child);
340
+ if (this.expandedKeys.has(childKey)) {
341
+ await this.reloadExpandedChildren(childKey);
342
+ }
343
+ }
344
+ }
345
+ invalidateSubtreeCache(key) {
346
+ const children = this.childrenCache.get(key);
347
+ if (children) {
348
+ for (const child of children) {
349
+ const childKey = this.provider.getKey(child);
350
+ this.invalidateSubtreeCache(childKey);
351
+ }
352
+ }
353
+ this.childrenCache.delete(key);
354
+ }
355
+ // ─── Private: flat list ───
356
+ rebuildFlatList() {
357
+ this.flatNodes = [];
358
+ this.maxRowWidth = 0;
359
+ const rootChildren = this.childrenCache.get("__root__");
360
+ if (!rootChildren)
361
+ return;
362
+ this.appendChildren(rootChildren, 0, null);
363
+ }
364
+ appendChildren(children, depth, parentKey) {
365
+ for (const element of children) {
366
+ const key = this.provider.getKey(element);
367
+ const item = this.provider.getTreeItem(element);
368
+ this.flatNodes.push({ element, depth, item, parentKey });
369
+ const rowWidth = this.calculateRowWidth(depth, item);
370
+ if (rowWidth > this.maxRowWidth) {
371
+ this.maxRowWidth = rowWidth;
372
+ }
373
+ if (this.expandedKeys.has(key)) {
374
+ const cachedChildren = this.childrenCache.get(key);
375
+ /* v8 ignore start -- defensive: an expanded key always has its children cached (cache and expandedKeys stay in sync) */
376
+ if (cachedChildren) {
377
+ this.appendChildren(cachedChildren, depth + 1, key);
378
+ }
379
+ /* v8 ignore stop */
380
+ }
381
+ }
382
+ }
383
+ /** Колонка (в координатах контента), с которой начинается строка узла данной глубины. */
384
+ rowIndentX(depth) {
385
+ return this.leftPadding + depth * INDENT_SIZE;
386
+ }
387
+ calculateRowWidth(depth, item) {
388
+ // indent + expandIcon + space + icon + space + label
389
+ return this.rowIndentX(depth) + 2 + (item.icon ? 2 : 0) + new DisplayLine(item.label).displayWidth;
390
+ }
391
+ formatRow(node) {
392
+ const indent = " ".repeat(this.rowIndentX(node.depth));
393
+ const expandIcon = node.item.collapsible
394
+ ? this.expandedKeys.has(this.provider.getKey(node.element))
395
+ ? ICON_EXPANDED
396
+ : ICON_COLLAPSED
397
+ : " ";
398
+ const icon = node.item.icon ? node.item.icon + " " : "";
399
+ return `${indent}${expandIcon} ${icon}${node.item.label}`;
400
+ }
401
+ // ─── Private: selection ───
402
+ getSelectedKey() {
403
+ if (this.selectedIndex >= 0 && this.selectedIndex < this.flatNodes.length) {
404
+ return this.provider.getKey(this.flatNodes[this.selectedIndex].element);
405
+ }
406
+ return null;
407
+ }
408
+ restoreSelection(key, previousIndex) {
409
+ if (key !== null) {
410
+ const idx = this.flatNodes.findIndex((n) => this.provider.getKey(n.element) === key);
411
+ if (idx >= 0) {
412
+ this.selectedIndex = idx;
413
+ this.selectionAnchor = idx;
414
+ return;
415
+ }
416
+ }
417
+ // The previously selected node is gone (e.g. it was deleted). Keep the cursor
418
+ // on the nearest remaining row instead of jumping to the top of the tree: the
419
+ // next sibling shifts into the same index, or we clamp to the last row when the
420
+ // deleted node was last.
421
+ if (this.flatNodes.length === 0) {
422
+ this.selectedIndex = 0;
423
+ this.selectionAnchor = 0;
424
+ return;
425
+ }
426
+ this.selectedIndex = Math.min(Math.max(previousIndex, 0), this.flatNodes.length - 1);
427
+ this.selectionAnchor = this.selectedIndex;
428
+ }
429
+ focusPageDown() {
430
+ const pageSize = Math.max(1, this.layoutSize.height - 1);
431
+ this.setSelectedIndex(this.selectedIndex + pageSize);
432
+ }
433
+ focusPageUp() {
434
+ const pageSize = Math.max(1, this.layoutSize.height - 1);
435
+ this.setSelectedIndex(this.selectedIndex - pageSize);
436
+ }
437
+ focusFirst() {
438
+ this.setSelectedIndex(0);
439
+ }
440
+ focusLast() {
441
+ this.setSelectedIndex(this.flatNodes.length - 1);
442
+ }
443
+ setSelectedIndex(index) {
444
+ if (index < 0)
445
+ index = 0;
446
+ if (index >= this.flatNodes.length)
447
+ index = this.flatNodes.length - 1;
448
+ if (index < 0)
449
+ return;
450
+ this.selectedIndex = index;
451
+ this.selectionAnchor = index;
452
+ this.selectedKeys.clear();
453
+ this.applyCursor(index);
454
+ }
455
+ /** Двигает курсор и расширяет множественный выбор диапазоном от якоря (Shift+стрелки). */
456
+ extendSelectionTo(index) {
457
+ if (index < 0)
458
+ index = 0;
459
+ if (index >= this.flatNodes.length)
460
+ index = this.flatNodes.length - 1;
461
+ if (index < 0)
462
+ return;
463
+ this.selectedIndex = index;
464
+ this.selectRange(this.selectionAnchor, index);
465
+ this.applyCursor(index);
466
+ }
467
+ selectRange(anchor, cursor) {
468
+ const lo = Math.min(anchor, cursor);
469
+ const hi = Math.max(anchor, cursor);
470
+ this.selectedKeys.clear();
471
+ for (let i = lo; i <= hi; i++) {
472
+ this.selectedKeys.add(this.provider.getKey(this.flatNodes[i].element));
473
+ }
474
+ }
475
+ /** Переключает выбор одной строки (Ctrl/Cmd+клик), не сбрасывая остальное. */
476
+ toggleSelectionAt(index) {
477
+ /* v8 ignore start -- defensive: the mouse handler bounds-checks the row before calling */
478
+ if (index < 0 || index >= this.flatNodes.length)
479
+ return;
480
+ /* v8 ignore stop */
481
+ // Первый Ctrl+клик: включаем в набор текущий курсор, чтобы он не потерялся.
482
+ if (this.selectedKeys.size === 0) {
483
+ this.selectedKeys.add(this.provider.getKey(this.flatNodes[this.selectedIndex].element));
484
+ }
485
+ const key = this.provider.getKey(this.flatNodes[index].element);
486
+ if (this.selectedKeys.has(key)) {
487
+ this.selectedKeys.delete(key);
488
+ }
489
+ else {
490
+ this.selectedKeys.add(key);
491
+ }
492
+ this.selectedIndex = index;
493
+ this.selectionAnchor = index;
494
+ this.applyCursor(index);
495
+ }
496
+ applyCursor(index) {
497
+ this.ensureVisible(index);
498
+ this.onSelect?.(this.flatNodes[index].element);
499
+ this.markDirty();
500
+ }
501
+ ensureVisible(index) {
502
+ const viewportHeight = this.layoutSize.height;
503
+ if (index < this.scrollTop) {
504
+ this.scrollTo(this.scrollLeft, index);
505
+ }
506
+ else if (index >= this.scrollTop + viewportHeight) {
507
+ this.scrollTo(this.scrollLeft, index - viewportHeight + 1);
508
+ }
509
+ }
510
+ // ─── Private: key helpers ───
511
+ findElementByKey(key) {
512
+ for (const node of this.flatNodes) {
513
+ if (this.provider.getKey(node.element) === key) {
514
+ return node.element;
515
+ }
516
+ }
517
+ /* v8 ignore start -- unreachable: findElementByKey is only called with keys of currently-flattened (visible) nodes, which the loop above always finds */
518
+ // Search in cache values too
519
+ for (const children of this.childrenCache.values()) {
520
+ for (const child of children) {
521
+ if (this.provider.getKey(child) === key) {
522
+ return child;
523
+ }
524
+ }
525
+ }
526
+ return null;
527
+ /* v8 ignore stop */
528
+ }
529
+ // ─── Private: input handlers ───
530
+ handleKeypress(event) {
531
+ switch (event.key) {
532
+ case "ArrowUp":
533
+ if (event.shiftKey) {
534
+ this.extendSelectionTo(this.selectedIndex - 1);
535
+ }
536
+ else {
537
+ this.setSelectedIndex(this.selectedIndex - 1);
538
+ }
539
+ break;
540
+ case "ArrowDown":
541
+ if (event.shiftKey) {
542
+ this.extendSelectionTo(this.selectedIndex + 1);
543
+ }
544
+ else {
545
+ this.setSelectedIndex(this.selectedIndex + 1);
546
+ }
547
+ break;
548
+ case "ArrowRight":
549
+ void this.handleExpandOrMoveToChild();
550
+ break;
551
+ case "ArrowLeft":
552
+ void this.handleCollapseOrMoveToParent();
553
+ break;
554
+ case "Enter":
555
+ this.activateSelected();
556
+ break;
557
+ case " ":
558
+ void this.toggleSelected();
559
+ break;
560
+ default:
561
+ this.handleTypeahead(event);
562
+ return;
563
+ }
564
+ }
565
+ // ─── Private: type-ahead поиск ───
566
+ /**
567
+ * Быстрый поиск по набору: печатаешь символы — курсор перепрыгивает к первому узлу,
568
+ * чья метка начинается с набранной последовательности. Повтор одного и того же
569
+ * символа циклически перебирает совпадения; набор из разных символов уточняет
570
+ * префикс, начиная с текущей строки. Буфер копится, пока не выйдет тайм-аут.
571
+ */
572
+ handleTypeahead(event) {
573
+ if (event.ctrlKey || event.altKey || event.metaKey)
574
+ return;
575
+ const ch = event.key;
576
+ // Только одиночные печатные символы: имена клавиш ("Enter", "F1"), управляющие
577
+ // символы и пробел (уже занят разворачиванием) в поиск не идут.
578
+ if (ch.length !== 1 || ch === " " || ch.charCodeAt(0) < 0x20)
579
+ return;
580
+ this.typeaheadBuffer += ch;
581
+ this.restartTypeaheadTimer();
582
+ this.jumpToTypeaheadMatch();
583
+ }
584
+ restartTypeaheadTimer() {
585
+ if (this.typeaheadTimer !== null) {
586
+ clearTimeout(this.typeaheadTimer);
587
+ }
588
+ this.typeaheadTimer = setTimeout(() => {
589
+ this.typeaheadTimer = null;
590
+ this.typeaheadBuffer = "";
591
+ }, TYPEAHEAD_TIMEOUT_MS);
592
+ // Незавершённый таймер поиска не должен удерживать процесс от выхода.
593
+ this.typeaheadTimer.unref();
594
+ }
595
+ jumpToTypeaheadMatch() {
596
+ const count = this.flatNodes.length;
597
+ if (count === 0)
598
+ return;
599
+ const buffer = this.typeaheadBuffer.toLowerCase();
600
+ // Повтор одного символа ("aa", "aaa") трактуем как перебор совпадений по одной
601
+ // букве, а не как буквальный префикс — так работает быстрый поиск в проводниках.
602
+ const allSameChar = Array.from(buffer).every((c) => c === buffer[0]);
603
+ const prefix = allSameChar ? buffer[0] : buffer;
604
+ // Перебор (следующая строка) — только когда пользователь реально жмёт ту же
605
+ // клавишу повторно (буфер ≥ 2 и все символы одинаковы). Первое нажатие и
606
+ // уточнение префикса ищут с текущей строки включительно, чтобы найти первое
607
+ // совпадение на текущей позиции или после неё.
608
+ const cycling = buffer.length >= 2 && allSameChar;
609
+ const start = cycling ? this.selectedIndex + 1 : this.selectedIndex;
610
+ for (let i = 0; i < count; i++) {
611
+ const idx = (start + i) % count;
612
+ const label = this.flatNodes[idx].item.label.toLowerCase();
613
+ if (label.startsWith(prefix)) {
614
+ this.setSelectedIndex(idx);
615
+ return;
616
+ }
617
+ }
618
+ }
619
+ async handleExpandOrMoveToChild() {
620
+ const node = this.flatNodes[this.selectedIndex];
621
+ if (node.item.collapsible) {
622
+ const key = this.provider.getKey(node.element);
623
+ if (!this.expandedKeys.has(key)) {
624
+ await this.toggleExpand(node.element);
625
+ }
626
+ else {
627
+ // Already expanded — move to first child
628
+ if (this.selectedIndex + 1 < this.flatNodes.length) {
629
+ const nextNode = this.flatNodes[this.selectedIndex + 1];
630
+ if (nextNode.depth > node.depth) {
631
+ this.setSelectedIndex(this.selectedIndex + 1);
632
+ }
633
+ }
634
+ }
635
+ }
636
+ }
637
+ async handleCollapseOrMoveToParent() {
638
+ const node = this.flatNodes[this.selectedIndex];
639
+ const key = this.provider.getKey(node.element);
640
+ if (node.item.collapsible && this.expandedKeys.has(key)) {
641
+ await this.toggleExpand(node.element);
642
+ }
643
+ else if (node.parentKey !== null) {
644
+ const parentIdx = this.flatNodes.findIndex((n) => this.provider.getKey(n.element) === node.parentKey);
645
+ /* v8 ignore start -- defensive: a visible child's parent is always present in the flat list */
646
+ if (parentIdx >= 0) {
647
+ this.setSelectedIndex(parentIdx);
648
+ }
649
+ /* v8 ignore stop */
650
+ }
651
+ }
652
+ activateSelected() {
653
+ const node = this.flatNodes[this.selectedIndex];
654
+ this.onActivate?.(node.element);
655
+ }
656
+ async toggleSelected() {
657
+ const node = this.flatNodes[this.selectedIndex];
658
+ if (!node.item.collapsible)
659
+ return;
660
+ await this.toggleExpand(node.element);
661
+ }
662
+ /**
663
+ * Единый вход контекстного меню: от мыши — узел под кликом (мультивыбор по
664
+ * выбранной строке не сбрасывается), от клавиатуры — узел курсора с якорем
665
+ * на его глобальной позиции. Виджет только сигналит {@link onContextMenu}.
666
+ */
667
+ handleContextMenu(event) {
668
+ if (event.trigger === "mouse") {
669
+ const index = this.scrollTop + event.localY;
670
+ if (index < 0 || index >= this.flatNodes.length)
671
+ return;
672
+ // Не сбрасываем множественный выбор, если кликнули по уже выбранной строке.
673
+ const key = this.provider.getKey(this.flatNodes[index].element);
674
+ if (!this.selectedKeys.has(key)) {
675
+ this.setSelectedIndex(index);
676
+ }
677
+ else {
678
+ this.selectedIndex = index;
679
+ this.applyCursor(index);
680
+ }
681
+ this.onContextMenu?.(this.flatNodes[index].element, event.screenX, event.screenY);
682
+ return;
683
+ }
684
+ const anchor = this.getSelectedRowGlobalPosition();
685
+ if (anchor === null || this.selectedIndex < 0 || this.selectedIndex >= this.flatNodes.length)
686
+ return;
687
+ this.onContextMenu?.(this.flatNodes[this.selectedIndex].element, anchor.x, anchor.y);
688
+ }
689
+ handleClick(event) {
690
+ const index = this.scrollTop + event.localY;
691
+ if (index < 0 || index >= this.flatNodes.length)
692
+ return;
693
+ if (event.button !== "left")
694
+ return; // правый клик несёт событие contextmenu
695
+ if (event.ctrlKey) {
696
+ this.toggleSelectionAt(index);
697
+ return;
698
+ }
699
+ if (event.shiftKey) {
700
+ this.selectedIndex = index;
701
+ this.selectRange(this.selectionAnchor, index);
702
+ this.applyCursor(index);
703
+ return;
704
+ }
705
+ this.setSelectedIndex(index);
706
+ // Check if clicked on expand icon area
707
+ const node = this.flatNodes[index];
708
+ const expandIconX = this.rowIndentX(node.depth);
709
+ const clickX = this.scrollLeft + event.localX;
710
+ if (node.item.collapsible && clickX >= expandIconX && clickX <= expandIconX + 1) {
711
+ void this.toggleExpand(node.element);
712
+ }
713
+ }
714
+ handleDblClick(event) {
715
+ const index = this.scrollTop + event.localY;
716
+ if (index < 0 || index >= this.flatNodes.length)
717
+ return;
718
+ const node = this.flatNodes[index];
719
+ if (node.item.collapsible) {
720
+ void this.toggleExpand(node.element);
721
+ }
722
+ else {
723
+ this.onActivate?.(node.element);
724
+ }
725
+ }
726
+ handleMouseMove(event) {
727
+ const index = this.scrollTop + event.localY;
728
+ const newHovered = index >= 0 && index < this.flatNodes.length ? index : null;
729
+ if (newHovered !== this.hoveredIndex) {
730
+ this.hoveredIndex = newHovered;
731
+ this.markDirty();
732
+ }
733
+ }
734
+ handleMouseLeave() {
735
+ if (this.hoveredIndex !== null) {
736
+ this.hoveredIndex = null;
737
+ this.markDirty();
738
+ }
739
+ }
740
+ handleWheel(event) {
741
+ if (event.wheelDirection === "up") {
742
+ this.scrollBy(0, -3);
743
+ }
744
+ else if (event.wheelDirection === "down") {
745
+ this.scrollBy(0, 3);
746
+ }
747
+ }
748
+ }