Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as dom from '../../dom.js';
7
import * as domStylesheetsJs from '../../domStylesheets.js';
8
import { IMouseEvent } from '../../mouseEvent.js';
9
import { DomScrollableElement } from '../scrollbar/scrollableElement.js';
10
import { commonPrefixLength } from '../../../common/arrays.js';
11
import { ThemeIcon } from '../../../common/themables.js';
12
import { Emitter, Event } from '../../../common/event.js';
13
import { DisposableStore, dispose, IDisposable } from '../../../common/lifecycle.js';
14
import { ScrollbarVisibility } from '../../../common/scrollable.js';
15
import './breadcrumbsWidget.css';
16
17
export abstract class BreadcrumbsItem {
18
abstract dispose(): void;
19
abstract equals(other: BreadcrumbsItem): boolean;
20
abstract render(container: HTMLElement): void;
21
}
22
23
export interface IBreadcrumbsWidgetStyles {
24
readonly breadcrumbsBackground: string | undefined;
25
readonly breadcrumbsForeground: string | undefined;
26
readonly breadcrumbsHoverForeground: string | undefined;
27
readonly breadcrumbsFocusForeground: string | undefined;
28
readonly breadcrumbsFocusAndSelectionForeground: string | undefined;
29
}
30
31
export interface IBreadcrumbsItemEvent {
32
type: 'select' | 'focus';
33
item: BreadcrumbsItem;
34
node: HTMLElement;
35
payload: unknown;
36
}
37
38
export class BreadcrumbsWidget {
39
40
private readonly _disposables = new DisposableStore();
41
private readonly _domNode: HTMLDivElement;
42
private readonly _scrollable: DomScrollableElement;
43
44
private readonly _onDidSelectItem = new Emitter<IBreadcrumbsItemEvent>();
45
private readonly _onDidFocusItem = new Emitter<IBreadcrumbsItemEvent>();
46
private readonly _onDidChangeFocus = new Emitter<boolean>();
47
48
readonly onDidSelectItem: Event<IBreadcrumbsItemEvent> = this._onDidSelectItem.event;
49
readonly onDidFocusItem: Event<IBreadcrumbsItemEvent> = this._onDidFocusItem.event;
50
readonly onDidChangeFocus: Event<boolean> = this._onDidChangeFocus.event;
51
52
private readonly _items = new Array<BreadcrumbsItem>();
53
private readonly _nodes = new Array<HTMLDivElement>();
54
private readonly _freeNodes = new Array<HTMLDivElement>();
55
private readonly _separatorIcon: ThemeIcon;
56
57
private _enabled: boolean = true;
58
private _focusedItemIdx: number = -1;
59
private _selectedItemIdx: number = -1;
60
61
private _pendingDimLayout: IDisposable | undefined;
62
private _pendingLayout: IDisposable | undefined;
63
private _dimension: dom.Dimension | undefined;
64
65
constructor(
66
container: HTMLElement,
67
horizontalScrollbarSize: number,
68
horizontalScrollbarVisibility: ScrollbarVisibility = ScrollbarVisibility.Auto,
69
separatorIcon: ThemeIcon,
70
styles: IBreadcrumbsWidgetStyles
71
) {
72
this._domNode = document.createElement('div');
73
this._domNode.className = 'monaco-breadcrumbs';
74
this._domNode.tabIndex = 0;
75
this._domNode.setAttribute('role', 'list');
76
this._scrollable = new DomScrollableElement(this._domNode, {
77
vertical: ScrollbarVisibility.Hidden,
78
horizontal: horizontalScrollbarVisibility,
79
horizontalScrollbarSize,
80
useShadows: false,
81
scrollYToX: true
82
});
83
this._separatorIcon = separatorIcon;
84
this._disposables.add(this._scrollable);
85
this._disposables.add(dom.addStandardDisposableListener(this._domNode, 'click', e => this._onClick(e)));
86
container.appendChild(this._scrollable.getDomNode());
87
88
const styleElement = domStylesheetsJs.createStyleSheet(this._domNode);
89
this._style(styleElement, styles);
90
91
const focusTracker = dom.trackFocus(this._domNode);
92
this._disposables.add(focusTracker);
93
this._disposables.add(focusTracker.onDidBlur(_ => this._onDidChangeFocus.fire(false)));
94
this._disposables.add(focusTracker.onDidFocus(_ => this._onDidChangeFocus.fire(true)));
95
}
96
97
setHorizontalScrollbarSize(size: number) {
98
this._scrollable.updateOptions({
99
horizontalScrollbarSize: size
100
});
101
}
102
103
setHorizontalScrollbarVisibility(visibility: ScrollbarVisibility) {
104
this._scrollable.updateOptions({
105
horizontal: visibility
106
});
107
}
108
109
dispose(): void {
110
this._disposables.dispose();
111
this._pendingLayout?.dispose();
112
this._pendingDimLayout?.dispose();
113
this._onDidSelectItem.dispose();
114
this._onDidFocusItem.dispose();
115
this._onDidChangeFocus.dispose();
116
this._domNode.remove();
117
this._nodes.length = 0;
118
this._freeNodes.length = 0;
119
}
120
121
layout(dim: dom.Dimension | undefined): void {
122
if (dim && dom.Dimension.equals(dim, this._dimension)) {
123
return;
124
}
125
if (dim) {
126
// only measure
127
this._pendingDimLayout?.dispose();
128
this._pendingDimLayout = this._updateDimensions(dim);
129
} else {
130
this._pendingLayout?.dispose();
131
this._pendingLayout = this._updateScrollbar();
132
}
133
}
134
135
private _updateDimensions(dim: dom.Dimension): IDisposable {
136
const disposables = new DisposableStore();
137
disposables.add(dom.modify(dom.getWindow(this._domNode), () => {
138
this._dimension = dim;
139
this._domNode.style.width = `${dim.width}px`;
140
this._domNode.style.height = `${dim.height}px`;
141
disposables.add(this._updateScrollbar());
142
}));
143
return disposables;
144
}
145
146
private _updateScrollbar(): IDisposable {
147
return dom.measure(dom.getWindow(this._domNode), () => {
148
dom.measure(dom.getWindow(this._domNode), () => { // double RAF
149
this._scrollable.setRevealOnScroll(false);
150
this._scrollable.scanDomNode();
151
this._scrollable.setRevealOnScroll(true);
152
});
153
});
154
}
155
156
private _style(styleElement: HTMLStyleElement, style: IBreadcrumbsWidgetStyles): void {
157
let content = '';
158
if (style.breadcrumbsBackground) {
159
content += `.monaco-breadcrumbs { background-color: ${style.breadcrumbsBackground}}`;
160
}
161
if (style.breadcrumbsForeground) {
162
content += `.monaco-breadcrumbs .monaco-breadcrumb-item { color: ${style.breadcrumbsForeground}}\n`;
163
}
164
if (style.breadcrumbsFocusForeground) {
165
content += `.monaco-breadcrumbs .monaco-breadcrumb-item.focused { color: ${style.breadcrumbsFocusForeground}}\n`;
166
}
167
if (style.breadcrumbsFocusAndSelectionForeground) {
168
content += `.monaco-breadcrumbs .monaco-breadcrumb-item.focused.selected { color: ${style.breadcrumbsFocusAndSelectionForeground}}\n`;
169
}
170
if (style.breadcrumbsHoverForeground) {
171
content += `.monaco-breadcrumbs:not(.disabled ) .monaco-breadcrumb-item:hover:not(.focused):not(.selected) { color: ${style.breadcrumbsHoverForeground}}\n`;
172
}
173
styleElement.textContent = content;
174
}
175
176
setEnabled(value: boolean) {
177
this._enabled = value;
178
this._domNode.classList.toggle('disabled', !this._enabled);
179
}
180
181
domFocus(): void {
182
const idx = this._focusedItemIdx >= 0 ? this._focusedItemIdx : this._items.length - 1;
183
if (idx >= 0 && idx < this._items.length) {
184
this._focus(idx, undefined);
185
} else {
186
this._domNode.focus();
187
}
188
}
189
190
isDOMFocused(): boolean {
191
return dom.isAncestorOfActiveElement(this._domNode);
192
}
193
194
getFocused(): BreadcrumbsItem {
195
return this._items[this._focusedItemIdx];
196
}
197
198
setFocused(item: BreadcrumbsItem | undefined, payload?: any): void {
199
this._focus(this._items.indexOf(item!), payload);
200
}
201
202
focusPrev(payload?: any): void {
203
if (this._focusedItemIdx > 0) {
204
this._focus(this._focusedItemIdx - 1, payload);
205
}
206
}
207
208
focusNext(payload?: any): void {
209
if (this._focusedItemIdx + 1 < this._nodes.length) {
210
this._focus(this._focusedItemIdx + 1, payload);
211
}
212
}
213
214
private _focus(nth: number, payload: any): void {
215
this._focusedItemIdx = -1;
216
for (let i = 0; i < this._nodes.length; i++) {
217
const node = this._nodes[i];
218
if (i !== nth) {
219
node.classList.remove('focused');
220
} else {
221
this._focusedItemIdx = i;
222
node.classList.add('focused');
223
node.focus();
224
}
225
}
226
this._reveal(this._focusedItemIdx, true);
227
this._onDidFocusItem.fire({ type: 'focus', item: this._items[this._focusedItemIdx], node: this._nodes[this._focusedItemIdx], payload });
228
}
229
230
reveal(item: BreadcrumbsItem): void {
231
const idx = this._items.indexOf(item);
232
if (idx >= 0) {
233
this._reveal(idx, false);
234
}
235
}
236
237
revealLast(): void {
238
this._reveal(this._items.length - 1, false);
239
}
240
241
private _reveal(nth: number, minimal: boolean): void {
242
if (nth < 0 || nth >= this._nodes.length) {
243
return;
244
}
245
const node = this._nodes[nth];
246
if (!node) {
247
return;
248
}
249
const { width } = this._scrollable.getScrollDimensions();
250
const { scrollLeft } = this._scrollable.getScrollPosition();
251
if (!minimal || node.offsetLeft > scrollLeft + width || node.offsetLeft < scrollLeft) {
252
this._scrollable.setRevealOnScroll(false);
253
this._scrollable.setScrollPosition({ scrollLeft: node.offsetLeft });
254
this._scrollable.setRevealOnScroll(true);
255
}
256
}
257
258
getSelection(): BreadcrumbsItem {
259
return this._items[this._selectedItemIdx];
260
}
261
262
setSelection(item: BreadcrumbsItem | undefined, payload?: any): void {
263
this._select(this._items.indexOf(item!), payload);
264
}
265
266
private _select(nth: number, payload: any): void {
267
this._selectedItemIdx = -1;
268
for (let i = 0; i < this._nodes.length; i++) {
269
const node = this._nodes[i];
270
if (i !== nth) {
271
node.classList.remove('selected');
272
} else {
273
this._selectedItemIdx = i;
274
node.classList.add('selected');
275
}
276
}
277
this._onDidSelectItem.fire({ type: 'select', item: this._items[this._selectedItemIdx], node: this._nodes[this._selectedItemIdx], payload });
278
}
279
280
getItems(): readonly BreadcrumbsItem[] {
281
return this._items;
282
}
283
284
setItems(items: BreadcrumbsItem[]): void {
285
let prefix: number | undefined;
286
let removed: BreadcrumbsItem[] = [];
287
try {
288
prefix = commonPrefixLength(this._items, items, (a, b) => a.equals(b));
289
removed = this._items.splice(prefix, this._items.length - prefix, ...items.slice(prefix));
290
this._render(prefix);
291
dispose(removed);
292
dispose(items.slice(0, prefix));
293
this._focus(-1, undefined);
294
} catch (e) {
295
const newError = new Error(`BreadcrumbsItem#setItems: newItems: ${items.length}, prefix: ${prefix}, removed: ${removed.length}`);
296
newError.name = e.name;
297
newError.stack = e.stack;
298
throw newError;
299
}
300
}
301
302
private _render(start: number): void {
303
let didChange = false;
304
for (; start < this._items.length && start < this._nodes.length; start++) {
305
const item = this._items[start];
306
const node = this._nodes[start];
307
this._renderItem(item, node);
308
didChange = true;
309
}
310
// case a: more nodes -> remove them
311
while (start < this._nodes.length) {
312
const free = this._nodes.pop();
313
if (free) {
314
this._freeNodes.push(free);
315
free.remove();
316
didChange = true;
317
}
318
}
319
320
// case b: more items -> render them
321
for (; start < this._items.length; start++) {
322
const item = this._items[start];
323
const node = this._freeNodes.length > 0 ? this._freeNodes.pop() : document.createElement('div');
324
if (node) {
325
this._renderItem(item, node);
326
this._domNode.appendChild(node);
327
this._nodes.push(node);
328
didChange = true;
329
}
330
}
331
if (didChange) {
332
this.layout(undefined);
333
}
334
}
335
336
private _renderItem(item: BreadcrumbsItem, container: HTMLDivElement): void {
337
dom.clearNode(container);
338
container.className = '';
339
try {
340
item.render(container);
341
} catch (err) {
342
container.textContent = '<<RENDER ERROR>>';
343
console.error(err);
344
}
345
container.tabIndex = -1;
346
container.setAttribute('role', 'listitem');
347
container.classList.add('monaco-breadcrumb-item');
348
const iconContainer = dom.$(ThemeIcon.asCSSSelector(this._separatorIcon));
349
container.appendChild(iconContainer);
350
}
351
352
private _onClick(event: IMouseEvent): void {
353
if (!this._enabled) {
354
return;
355
}
356
for (let el: HTMLElement | null = event.target; el; el = el.parentElement) {
357
const idx = this._nodes.indexOf(el as HTMLDivElement);
358
if (idx >= 0) {
359
this._focus(idx, event);
360
this._select(idx, event);
361
break;
362
}
363
}
364
}
365
}
366
367